@adecore/editor-core 0.22.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/comments.js +67 -14
- package/dist/doc-comments.d.ts +10 -0
- package/dist/doc-comments.js +85 -0
- package/dist/document.d.ts +8 -0
- package/dist/document.js +111 -29
- package/dist/edit-regions.d.ts +6 -0
- package/dist/edit-regions.js +87 -0
- package/dist/enter.js +24 -12
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/languages.d.ts +2 -1
- package/dist/languages.js +7 -1
- package/dist/markup-regions.d.ts +1 -0
- package/dist/markup-regions.js +53 -1
- package/dist/offsets.d.ts +1 -0
- package/dist/offsets.js +23 -0
- package/dist/php-markup.d.ts +7 -0
- package/dist/php-markup.js +99 -0
- package/dist/rope.d.ts +6 -0
- package/dist/rope.js +33 -4
- package/dist/script-source.d.ts +2 -0
- package/dist/script-source.js +44 -0
- package/dist/search.d.ts +3 -1
- package/dist/search.js +67 -12
- package/dist/types.d.ts +3 -0
- package/dist/typing-context.d.ts +8 -0
- package/dist/typing-context.js +70 -22
- package/package.json +1 -1
- package/src/comments.ts +70 -15
- package/src/doc-comments.ts +104 -0
- package/src/document.ts +131 -31
- package/src/edit-regions.ts +96 -0
- package/src/enter.ts +25 -13
- package/src/index.ts +2 -1
- package/src/languages.ts +9 -2
- package/src/markup-regions.ts +54 -1
- package/src/offsets.ts +24 -0
- package/src/php-markup.ts +104 -0
- package/src/rope.ts +35 -5
- package/src/script-source.ts +43 -0
- package/src/search.ts +69 -12
- package/src/types.ts +6 -0
- package/src/typing-context.ts +85 -22
package/dist/comments.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { editRegions, phpLiteralAt, phpTagAt } from './edit-regions.js';
|
|
2
|
+
import { hasJsx } from './markup-regions.js';
|
|
1
3
|
import { commentSyntax } from './languages.js';
|
|
2
4
|
import { indentText, isBlank, rangeOf, whitespaceOf } from './line-text.js';
|
|
3
5
|
import { mapOffset } from './offsets.js';
|
|
4
6
|
import { indentationColumn } from './structure.js';
|
|
5
|
-
function syntaxOf(
|
|
6
|
-
const syntax = commentSyntax(options.language,
|
|
7
|
+
function syntaxOf(regions, options, line, offset) {
|
|
8
|
+
const syntax = commentSyntax(options.language, regions(line, offset));
|
|
7
9
|
return options.lineToken === undefined ? syntax : { ...syntax, line: options.lineToken };
|
|
8
10
|
}
|
|
9
11
|
/* The runs of adjacent lines the selections touch. A selection that ends at the start of a line does not touch it. */
|
|
@@ -62,19 +64,42 @@ function geometryOf(source, edits, index) {
|
|
|
62
64
|
* The markers go in at the smallest indentation of the lines, and a lone caret moves to the next line.
|
|
63
65
|
*/
|
|
64
66
|
export function planLineComments(source, selections, options) {
|
|
67
|
+
const regions = editRegions(source, options.language);
|
|
65
68
|
const blocks = blocksOf(source, selections, options.lineSpan);
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
+
const lineBased = new Map();
|
|
70
|
+
for (const block of blocks) {
|
|
71
|
+
let marker;
|
|
72
|
+
let mixed = false;
|
|
73
|
+
const firstSyntax = hasJsx(options.language) && block.first !== block.last ? syntaxOf(regions, options, block.first) : undefined;
|
|
74
|
+
for (let line = block.first; line <= block.last; line++) {
|
|
75
|
+
const syntax = firstSyntax ?? syntaxOf(regions, options, line);
|
|
76
|
+
const effective = syntax.line === null ? syntax : { ...syntax, block: null };
|
|
77
|
+
lineBased.set(line, effective);
|
|
78
|
+
if (effective.line !== null || effective.block !== null) {
|
|
79
|
+
mixed ||=
|
|
80
|
+
marker !== undefined &&
|
|
81
|
+
(marker.line !== effective.line || marker.block?.open !== effective.block?.open || marker.block?.close !== effective.block?.close);
|
|
82
|
+
marker ??= effective;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// Commenting a region opener would change the markers needed for the next toggle.
|
|
86
|
+
if (mixed && /^(php|vue)$/i.test(options.language)) {
|
|
87
|
+
for (let line = block.first; line <= block.last; line++) {
|
|
88
|
+
lineBased.set(line, { line: null, block: null });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
69
91
|
}
|
|
70
|
-
const lineBased = syntaxes.map((syntax) => (syntax.line === null ? syntax : { ...syntax, block: null }));
|
|
71
92
|
let allCommented = true;
|
|
72
93
|
let anyCommented = false;
|
|
73
|
-
for (const
|
|
94
|
+
for (const block of blocks) {
|
|
74
95
|
const single = block.first === block.last;
|
|
75
96
|
for (let line = block.first; line <= block.last; line++) {
|
|
76
97
|
const text = source.line(line).text;
|
|
77
|
-
const
|
|
98
|
+
const syntax = lineBased.get(line);
|
|
99
|
+
if (syntax.line === null && syntax.block === null) {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const commented = isCommented(text, syntax, !single);
|
|
78
103
|
if (commented && !isBlank(text)) {
|
|
79
104
|
anyCommented = true;
|
|
80
105
|
}
|
|
@@ -88,8 +113,8 @@ export function planLineComments(source, selections, options) {
|
|
|
88
113
|
}
|
|
89
114
|
const edits = [];
|
|
90
115
|
const placed = new Set();
|
|
91
|
-
for (const
|
|
92
|
-
const syntax = lineBased
|
|
116
|
+
for (const block of blocks) {
|
|
117
|
+
const syntax = lineBased.get(block.first);
|
|
93
118
|
const multiline = block.first !== block.last;
|
|
94
119
|
const startingNewLine = !multiline &&
|
|
95
120
|
block.carets.length === 1 &&
|
|
@@ -98,6 +123,10 @@ export function planLineComments(source, selections, options) {
|
|
|
98
123
|
const minimum = allCommented ? 0 : minimumIndent(source, block, syntax, options);
|
|
99
124
|
for (let line = block.first; line <= block.last; line++) {
|
|
100
125
|
const bounds = source.line(line);
|
|
126
|
+
const syntax = lineBased.get(line);
|
|
127
|
+
if (syntax.line === null && syntax.block === null) {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
101
130
|
const edit = allCommented
|
|
102
131
|
? uncommentLine(bounds.start, bounds.text, syntax)
|
|
103
132
|
: commentLine(bounds.start, bounds.text, syntax, minimum, multiline, startingNewLine, options);
|
|
@@ -201,13 +230,17 @@ function finalSelections(source, selections, blocks, edits, allCommented, placed
|
|
|
201
230
|
if (!moveCarets) {
|
|
202
231
|
return result;
|
|
203
232
|
}
|
|
204
|
-
for (const
|
|
233
|
+
for (const block of blocks) {
|
|
205
234
|
for (const index of block.carets) {
|
|
206
235
|
const selection = selections[index];
|
|
207
236
|
const { from, to } = rangeOf(selection);
|
|
208
237
|
const line = source.lineAt(from);
|
|
209
238
|
if (from === to) {
|
|
210
|
-
|
|
239
|
+
const syntax = syntaxes.get(line);
|
|
240
|
+
if (syntax.line === null && syntax.block === null) {
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
result[index] = caretAfter(source, sorted, line, span?.(line).last ?? line, selection.head, allCommented, placed, syntax);
|
|
211
244
|
continue;
|
|
212
245
|
}
|
|
213
246
|
const lastLine = source.lineAt(to - 1);
|
|
@@ -241,7 +274,7 @@ function commentAround(source, offset, block) {
|
|
|
241
274
|
if ((context.mode !== 'block-comment' && context.mode !== 'html-comment') || context.commentStart === undefined) {
|
|
242
275
|
return null;
|
|
243
276
|
}
|
|
244
|
-
const from = context.commentStart;
|
|
277
|
+
const from = block.open === '{/*' && source.charAt(context.commentStart - 1) === '{' ? context.commentStart - 1 : context.commentStart;
|
|
245
278
|
if (source.slice(from, from + block.open.length) !== block.open) {
|
|
246
279
|
return null;
|
|
247
280
|
}
|
|
@@ -264,12 +297,32 @@ function commentOf(source, from, to, block) {
|
|
|
264
297
|
* With nothing selected it opens an empty comment around the caret.
|
|
265
298
|
*/
|
|
266
299
|
export function planBlockComment(source, selections, options) {
|
|
300
|
+
const regions = editRegions(source, options.language);
|
|
267
301
|
const edits = [];
|
|
268
302
|
const result = [];
|
|
269
303
|
let offsetDelta = 0;
|
|
270
304
|
for (const selection of selections) {
|
|
271
305
|
const { from, to } = rangeOf(selection);
|
|
272
|
-
|
|
306
|
+
if (/^php$/i.test(options.language)) {
|
|
307
|
+
const context = source.context(from);
|
|
308
|
+
const { mode } = context;
|
|
309
|
+
const endContext = source.context(to);
|
|
310
|
+
const endMode = endContext.mode;
|
|
311
|
+
const endsLineComment = mode === 'code' && endMode === 'line-comment' && source.slice(to, source.line(source.lineAt(to)).end).trim() === '';
|
|
312
|
+
const text = source.slice(from, to);
|
|
313
|
+
// Wrapping a PHP tag can move a closing marker into a different language.
|
|
314
|
+
if (phpLiteralAt(source, from) ||
|
|
315
|
+
phpLiteralAt(source, to) ||
|
|
316
|
+
phpTagAt(source, from) ||
|
|
317
|
+
phpTagAt(source, to) ||
|
|
318
|
+
(mode !== endMode && !endsLineComment) ||
|
|
319
|
+
context.embedStart !== endContext.embedStart ||
|
|
320
|
+
/<\?(?:php\b|=)/.test(text) ||
|
|
321
|
+
(mode !== 'html' && context.embedded === undefined && text.includes('?>'))) {
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
const block = syntaxOf(regions, options, source.lineAt(from), from).block;
|
|
273
326
|
if (!block) {
|
|
274
327
|
return null;
|
|
275
328
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface DocComment {
|
|
2
|
+
from: number;
|
|
3
|
+
to: number;
|
|
4
|
+
startLine: number;
|
|
5
|
+
endLine: number;
|
|
6
|
+
indent: string;
|
|
7
|
+
body: string;
|
|
8
|
+
}
|
|
9
|
+
export declare const MAX_DOC_COMMENTS = 500;
|
|
10
|
+
export declare function findDocComments(text: string, language: string, limit?: number): DocComment[];
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { commentRole } from './fold-roles.js';
|
|
2
|
+
import { commentSyntax, vueRegionAt } from './languages.js';
|
|
3
|
+
import { scanStructure } from './structure.js';
|
|
4
|
+
/* Past this many comments in one document the rest are left as text. */
|
|
5
|
+
export const MAX_DOC_COMMENTS = 500;
|
|
6
|
+
function bodyOf(inner) {
|
|
7
|
+
const lines = inner.split(/\r\n|\r|\n/).map((line, index) => (index === 0 ? line.replace(/^[ \t]+/, '') : line.replace(/^[ \t]*\*?[ \t]?/, '')));
|
|
8
|
+
const last = lines.length - 1;
|
|
9
|
+
lines[last] = lines[last].replace(/[ \t*]+$/, '');
|
|
10
|
+
while (lines.length > 0 && lines[0].trim() === '') {
|
|
11
|
+
lines.shift();
|
|
12
|
+
}
|
|
13
|
+
while (lines.length > 0 && lines.at(-1).trim() === '') {
|
|
14
|
+
lines.pop();
|
|
15
|
+
}
|
|
16
|
+
return lines.map((line) => line.replace(/[ \t]+$/, '')).join('\n');
|
|
17
|
+
}
|
|
18
|
+
const HEREDOC_OPENER = /<<<[ \t]*(?:'(\w+)'|"(\w+)"|(\w+))\r?\n/g;
|
|
19
|
+
/* The bodies of PHP heredocs and nowdocs, which the structure scan reads as code. Offsets of the first character after the opener and of the closing label. */
|
|
20
|
+
function heredocBodies(text) {
|
|
21
|
+
const result = [];
|
|
22
|
+
for (const opener of text.matchAll(HEREDOC_OPENER)) {
|
|
23
|
+
const label = opener[1] ?? opener[2] ?? opener[3];
|
|
24
|
+
const from = opener.index + opener[0].length;
|
|
25
|
+
const closer = new RegExp(`^[ \\t]*${label}\\b`, 'm').exec(text.slice(from));
|
|
26
|
+
result.push([from, closer === null ? text.length : from + closer.index]);
|
|
27
|
+
}
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
30
|
+
/*
|
|
31
|
+
* The block comments that open with two stars and have their lines to themselves: nothing but
|
|
32
|
+
* whitespace before the opener and after the closer. Only languages that write block comments as
|
|
33
|
+
* a slash and a star have any, and a Vue file has them in its script. A comment that shares a line
|
|
34
|
+
* with code is left out, since a row could not take its lines without hiding the code. A banner of
|
|
35
|
+
* three stars or more is no documentation, and nor is the text of a PHP heredoc or nowdoc. A heredoc
|
|
36
|
+
* body is still read as code by the scan, so a quote in it can hide the comments that follow.
|
|
37
|
+
*/
|
|
38
|
+
export function findDocComments(text, language, limit = MAX_DOC_COMMENTS) {
|
|
39
|
+
const vue = language.toLowerCase() === 'vue';
|
|
40
|
+
const syntax = commentSyntax(language, vue ? 'script' : null);
|
|
41
|
+
if (syntax.block?.open !== '/*' || syntax.line !== '//' || !text.includes('/**')) {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
const result = [];
|
|
45
|
+
const bodies = language.toLowerCase() === 'php' && text.includes('<<<') ? heredocBodies(text) : [];
|
|
46
|
+
const lines = vue ? text.split(/\r\n|\r|\n/) : null;
|
|
47
|
+
let counted = 0;
|
|
48
|
+
let line = 0;
|
|
49
|
+
for (const range of scanStructure(text)) {
|
|
50
|
+
if (result.length >= limit) {
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
if (range.kind !== 'comment' || commentRole((from, to) => text.slice(from, to), range.from, range.to) === undefined) {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
// Three stars or more is a banner, not a comment about the code under it.
|
|
57
|
+
if (text[range.from + 3] === '*' || bodies.some(([from, to]) => range.from >= from && range.from < to)) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const lineStart = Math.max(text.lastIndexOf('\n', range.from - 1), text.lastIndexOf('\r', range.from - 1)) + 1;
|
|
61
|
+
const newline = text.slice(range.to).search(/[\r\n]/);
|
|
62
|
+
const lineEnd = newline === -1 ? text.length : range.to + newline;
|
|
63
|
+
const before = text.slice(lineStart, range.from);
|
|
64
|
+
if (!/^[ \t]*$/.test(before) || !/^[ \t]*$/.test(text.slice(range.to, lineEnd))) {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
for (; counted < range.from; counted++) {
|
|
68
|
+
if (text[counted] === '\n' || (text[counted] === '\r' && text[counted + 1] !== '\n')) {
|
|
69
|
+
line++;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const startLine = line;
|
|
73
|
+
if (lines !== null && vueRegionAt((index) => lines[index] ?? '', startLine) !== 'script') {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
let endLine = startLine;
|
|
77
|
+
for (let at = range.from; at < range.to; at++) {
|
|
78
|
+
if (text[at] === '\n' || (text[at] === '\r' && text[at + 1] !== '\n')) {
|
|
79
|
+
endLine++;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
result.push({ from: range.from, to: range.to, startLine, endLine, indent: before, body: bodyOf(text.slice(range.innerFrom + 1, range.innerTo)) });
|
|
83
|
+
}
|
|
84
|
+
return result;
|
|
85
|
+
}
|
package/dist/document.d.ts
CHANGED
|
@@ -8,6 +8,10 @@ export interface OffsetRange {
|
|
|
8
8
|
readonly to: number;
|
|
9
9
|
}
|
|
10
10
|
export interface DocumentEditOptions extends EditOptions {
|
|
11
|
+
selectionPositions?: readonly {
|
|
12
|
+
readonly anchor: Position;
|
|
13
|
+
readonly head: Position;
|
|
14
|
+
}[];
|
|
11
15
|
expectedRevision?: number;
|
|
12
16
|
}
|
|
13
17
|
export interface TypeTextOptions extends CommandOptions {
|
|
@@ -43,6 +47,7 @@ export declare class DocumentModel {
|
|
|
43
47
|
occurrencesExhausted: boolean;
|
|
44
48
|
private closers;
|
|
45
49
|
private bracketCache?;
|
|
50
|
+
private scriptCache?;
|
|
46
51
|
constructor(text?: string);
|
|
47
52
|
getText(): string;
|
|
48
53
|
getLength(): number;
|
|
@@ -66,6 +71,7 @@ export declare class DocumentModel {
|
|
|
66
71
|
findNext(query: string, from?: number, options?: FindNextOptions): FindMatch | null;
|
|
67
72
|
replace(match: FindMatch, replacement: string, options?: ReplaceOptions): boolean;
|
|
68
73
|
replaceAll(query: string, replacement: string, options?: ReplaceAllOptions): number;
|
|
74
|
+
replaceMatches(matches: readonly FindMatch[], replacement: string, options?: ReplaceOptions): number;
|
|
69
75
|
getFoldingRanges(options?: FoldingOptions): FoldingRange[];
|
|
70
76
|
typeText(text: string, options?: TypeTextOptions): boolean;
|
|
71
77
|
private takeBackArrows;
|
|
@@ -112,6 +118,8 @@ export declare class DocumentModel {
|
|
|
112
118
|
private editLines;
|
|
113
119
|
private editSource;
|
|
114
120
|
private isUnclosedBrace;
|
|
121
|
+
private scriptSource;
|
|
122
|
+
private scriptBracketIndex;
|
|
115
123
|
private bracketIndex;
|
|
116
124
|
private deleteCharacter;
|
|
117
125
|
private unindent;
|
package/dist/document.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import { scriptSource } from './script-source.js';
|
|
1
2
|
import { CloserTracker } from './closer-tracker.js';
|
|
2
3
|
import { planBlockComment, planLineComments } from './comments.js';
|
|
3
4
|
import { planEnter } from './enter.js';
|
|
4
5
|
import { planAutoIndent, planJoinLines, planToggleCase, properIndent, spacingBetween } from './line-commands.js';
|
|
5
|
-
import { mapOffset } from './offsets.js';
|
|
6
|
+
import { mapOffset, offsetMapper } from './offsets.js';
|
|
6
7
|
import { planPaste } from './paste.js';
|
|
7
8
|
import { isPlainText, vueRegionAt } from './languages.js';
|
|
8
9
|
import { blockFolds } from './block-folds.js';
|
|
@@ -12,8 +13,8 @@ import { rangeOf, whitespaceOf } from './line-text.js';
|
|
|
12
13
|
import { isQuote, replacesComparison, surround, swapQuotes } from './typing-handlers.js';
|
|
13
14
|
import { scanBrackets } from './brackets.js';
|
|
14
15
|
import { hasSmartSemicolon, isPhp } from './lexical.js';
|
|
15
|
-
import { clampInteger, TextRope } from './rope.js';
|
|
16
|
-
import { findMatches, replacementText, splitsSurrogate } from './search.js';
|
|
16
|
+
import { clampInteger, TextRope, ROPE_CHUNK_SIZE } from './rope.js';
|
|
17
|
+
import { findMatches, findMatchFrom, replacementText, splitsSurrogate } from './search.js';
|
|
17
18
|
import { dedupeFoldingRanges, deriveFoldingRanges, indentationColumn, scanStructure, tabWidth } from './structure.js';
|
|
18
19
|
import { TypingContexts } from './typing-context.js';
|
|
19
20
|
import { isHumpBoundary, isWordBoundary } from './words.js';
|
|
@@ -238,6 +239,7 @@ export class DocumentModel {
|
|
|
238
239
|
occurrencesExhausted = false;
|
|
239
240
|
closers = new CloserTracker();
|
|
240
241
|
bracketCache;
|
|
242
|
+
scriptCache;
|
|
241
243
|
constructor(text = '') {
|
|
242
244
|
this.rope = TextRope.from(text);
|
|
243
245
|
this.textCache = { text };
|
|
@@ -326,21 +328,31 @@ export class DocumentModel {
|
|
|
326
328
|
}
|
|
327
329
|
const unique = this.validateEdits(edits);
|
|
328
330
|
const changed = unique.filter((edit) => changesText(this.rope, edit));
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
331
|
+
// Sparse edits keep structural sharing; dense edits avoid rescanning a leaf for each replacement.
|
|
332
|
+
const rebuild = changed.length >= 256 && changed.length * ROPE_CHUNK_SIZE >= this.rope.length;
|
|
333
|
+
let next = rebuild ? this.rope.rebuild(changed) : this.rope;
|
|
334
|
+
if (!rebuild) {
|
|
335
|
+
for (let index = changed.length - 1; index >= 0; index--) {
|
|
336
|
+
const edit = changed[index];
|
|
337
|
+
next = next.replace(edit.from, edit.to, edit.text);
|
|
338
|
+
}
|
|
333
339
|
}
|
|
334
|
-
const
|
|
335
|
-
|
|
340
|
+
const map = offsetMapper(unique);
|
|
341
|
+
const at = (position) => {
|
|
342
|
+
const line = next.lineBounds(position.line);
|
|
343
|
+
return offsetIn(next, line.start + clampInteger(position.column, line.end - line.start));
|
|
344
|
+
};
|
|
336
345
|
// Several edits can cancel each other out, like moving a character and moving it back.
|
|
337
346
|
const unchanged = next === this.rope || (changed.length > 1 && next.length === this.rope.length && next.equalsRange(this.rope, changed[0].from, changed.at(-1).to));
|
|
338
347
|
if (unchanged) {
|
|
339
348
|
if (options.selections) {
|
|
340
|
-
this.setSelections(
|
|
349
|
+
this.setSelections(options.selections);
|
|
341
350
|
}
|
|
342
351
|
return false;
|
|
343
352
|
}
|
|
353
|
+
const nextSelections = options.selections ??
|
|
354
|
+
options.selectionPositions?.map((selection) => ({ anchor: at(selection.anchor), head: at(selection.head) })) ??
|
|
355
|
+
this.selections.map((selection) => ({ anchor: map(selection.anchor), head: map(selection.head) }));
|
|
344
356
|
const before = this.state();
|
|
345
357
|
const firstLine = this.rope.lineAt(changed[0].from);
|
|
346
358
|
const previous = this.rope;
|
|
@@ -354,11 +366,13 @@ export class DocumentModel {
|
|
|
354
366
|
const after = this.state();
|
|
355
367
|
const changes = changed.map((edit) => ({ from: edit.from, to: edit.to, insertedLength: edit.text.length }));
|
|
356
368
|
const last = this.undoStack.at(-1);
|
|
357
|
-
|
|
369
|
+
const joinsGroup = options.historyGroup &&
|
|
358
370
|
this.groupOpen &&
|
|
359
371
|
last?.group === options.historyGroup &&
|
|
360
372
|
last.after.rope === before.rope &&
|
|
361
|
-
sameSelections(last.after.selections, before.selections)
|
|
373
|
+
sameSelections(last.after.selections, before.selections);
|
|
374
|
+
const joinsPrevious = options.joinPrevious === true && last !== undefined && last.after.rope === before.rope;
|
|
375
|
+
if (last !== undefined && (joinsGroup || joinsPrevious)) {
|
|
362
376
|
last.after = after;
|
|
363
377
|
last.changes.push(changes);
|
|
364
378
|
}
|
|
@@ -368,9 +382,9 @@ export class DocumentModel {
|
|
|
368
382
|
if (this.undoStack.length > historyLimit) {
|
|
369
383
|
this.undoStack.shift();
|
|
370
384
|
}
|
|
371
|
-
this.groupOpen = Boolean(options.historyGroup);
|
|
385
|
+
this.groupOpen = Boolean(options.historyGroup) && !joinsPrevious;
|
|
372
386
|
this.redoStack = [];
|
|
373
|
-
this.emit([changes], options.source ?? 'external', () => editsOf(previous, changed));
|
|
387
|
+
this.emit([changes], options.source ?? 'external', () => (rebuild ? stretchEdits(previous, next, [changes]) : editsOf(previous, changed)), options.typed);
|
|
374
388
|
return true;
|
|
375
389
|
}
|
|
376
390
|
/* Replaces everything as one undoable external edit. */
|
|
@@ -408,11 +422,7 @@ export class DocumentModel {
|
|
|
408
422
|
}
|
|
409
423
|
/* Includes a match that starts at `from`, or ends there when searching backwards. */
|
|
410
424
|
findNext(query, from = this.selections.at(-1).head, options = {}) {
|
|
411
|
-
|
|
412
|
-
const offset = clampInteger(from, this.rope.length);
|
|
413
|
-
const ordered = options.backwards ? matches.reverse() : matches;
|
|
414
|
-
const found = ordered.find((match) => (options.backwards ? match.to <= offset : match.from >= offset));
|
|
415
|
-
return found ?? (options.wrap === false ? null : (ordered[0] ?? null));
|
|
425
|
+
return findMatchFrom(this.getText(), query, this.revision, from, options);
|
|
416
426
|
}
|
|
417
427
|
/* Refuses a match from an earlier revision, or one whose text no longer sits there. */
|
|
418
428
|
replace(match, replacement, options = {}) {
|
|
@@ -433,14 +443,31 @@ export class DocumentModel {
|
|
|
433
443
|
if (options.expectedRevision !== undefined && options.expectedRevision !== this.revision) {
|
|
434
444
|
return 0;
|
|
435
445
|
}
|
|
436
|
-
|
|
446
|
+
return this.replaceMatches(this.find(query, options), replacement, options);
|
|
447
|
+
}
|
|
448
|
+
/* Commits retained search results as one undo step; one stale result refuses the entire batch. */
|
|
449
|
+
replaceMatches(matches, replacement, options = {}) {
|
|
450
|
+
const revision = this.revision;
|
|
451
|
+
if (matches.length === 0 ||
|
|
452
|
+
(options.expectedRevision !== undefined && options.expectedRevision !== revision) ||
|
|
453
|
+
matches.some((match) => match.revision !== revision || this.slice(match.from, match.to) !== match.text)) {
|
|
454
|
+
return 0;
|
|
455
|
+
}
|
|
456
|
+
const ranges = new Set();
|
|
457
|
+
for (const match of matches) {
|
|
458
|
+
const key = `${match.from}:${match.to}`;
|
|
459
|
+
if (ranges.has(key)) {
|
|
460
|
+
throw new RangeError('Search matches must have unique ranges.');
|
|
461
|
+
}
|
|
462
|
+
ranges.add(key);
|
|
463
|
+
}
|
|
437
464
|
const text = this.getText();
|
|
438
465
|
const edits = matches.map((match) => ({
|
|
439
466
|
from: match.from,
|
|
440
467
|
to: match.to,
|
|
441
468
|
text: replacementText(text, match, replacement, options.literal, options.preserveCase)
|
|
442
469
|
}));
|
|
443
|
-
return this.applyEdits(edits, { source: 'command', ...options }) ? matches.length : 0;
|
|
470
|
+
return this.applyEdits(edits, { source: 'command', ...options, expectedRevision: revision }) ? matches.length : 0;
|
|
444
471
|
}
|
|
445
472
|
getFoldingRanges(options = {}) {
|
|
446
473
|
const ranges = options.brackets === false && options.comments === false ? [] : this.structure();
|
|
@@ -661,10 +688,12 @@ export class DocumentModel {
|
|
|
661
688
|
const arrow = text === '-' && input.smartArrow && from === to;
|
|
662
689
|
const needsContext = text.length === 1 && (/[()[\]{}'"`<]/.test(text) || (text === ';' && smartSemicolon) || arrow);
|
|
663
690
|
const context = needsContext ? this.typingContext(from, language) : undefined;
|
|
664
|
-
if (arrow && context?.mode === 'code' && context.word !== 'control-close' && this.endsOperand(from)) {
|
|
691
|
+
if (arrow && context?.mode === 'code' && context.embedded === undefined && context.word !== 'control-close' && this.endsOperand(from)) {
|
|
665
692
|
return { from, to, text, anchor: 1, head: 1, arrow: true };
|
|
666
693
|
}
|
|
667
|
-
const insideQuote = context &&
|
|
694
|
+
const insideQuote = context &&
|
|
695
|
+
(((context.mode === 'quote' || context.mode === 'template') && context.quote === text && !context.escaped) ||
|
|
696
|
+
(context.mode === 'html' && context.markup === 'value' && context.markupQuote === text));
|
|
668
697
|
if (pairing && from === to && after === text && (quote ? insideQuote : context?.mode === 'code' && context.bracket?.close === text)) {
|
|
669
698
|
return { from, to, text: '', anchor: 1, head: 1, skip: true };
|
|
670
699
|
}
|
|
@@ -736,9 +765,13 @@ export class DocumentModel {
|
|
|
736
765
|
return { from: line.start, to: offset, text: indent + closer, anchor: indent.length + 1, head: indent.length + 1 };
|
|
737
766
|
}
|
|
738
767
|
shouldClosePair(offset, opener, after, context, input) {
|
|
768
|
+
if (context?.mode === 'html') {
|
|
769
|
+
return context.markup === 'equals' && (opener === '"' || opener === "'") && (!after || /[\s>/]/u.test(after));
|
|
770
|
+
}
|
|
739
771
|
const interpolation = this.isInterpolation(offset, opener, context);
|
|
740
772
|
const quote = isQuote(opener);
|
|
741
|
-
const
|
|
773
|
+
const scriptEnd = context?.embedded !== undefined && /^<\/script(?=[\s/>]|$)/i.test(this.slice(offset, offset + 9));
|
|
774
|
+
const followedByTerminator = quote ? !identifier.test(after) && after !== opener : !after || scriptEnd || /[\s)\]}>,;:]/u.test(after);
|
|
742
775
|
if (context?.mode !== 'code' && !interpolation) {
|
|
743
776
|
return false;
|
|
744
777
|
}
|
|
@@ -751,14 +784,22 @@ export class DocumentModel {
|
|
|
751
784
|
!this.escaped(offset) &&
|
|
752
785
|
!this.completesTripleQuote(offset, opener, input.language));
|
|
753
786
|
}
|
|
754
|
-
return !this.hasMatch(offset, opener, input.language);
|
|
787
|
+
return !this.hasMatch(offset, opener, input.language, context);
|
|
755
788
|
}
|
|
756
789
|
/* The third quote of a Python docstring opener is typed as it is, since the pair that came before it is the empty string. */
|
|
757
790
|
completesTripleQuote(offset, quote, language) {
|
|
758
791
|
return /^(python|py)$/i.test(language) && quote !== '`' && this.slice(offset - 2, offset) === quote + quote;
|
|
759
792
|
}
|
|
760
793
|
/* Whether the bracket just typed at `offset` would find its closer in the text that already follows it. */
|
|
761
|
-
hasMatch(offset, opener, language) {
|
|
794
|
+
hasMatch(offset, opener, language, context) {
|
|
795
|
+
if (context?.embedded === 'javascript' && context.embedStart !== undefined) {
|
|
796
|
+
const source = this.scriptSource(context.embedStart, matchScanLimit).text;
|
|
797
|
+
if (source === undefined) {
|
|
798
|
+
return false;
|
|
799
|
+
}
|
|
800
|
+
const at = offset - context.embedStart;
|
|
801
|
+
return scanBrackets(source.slice(0, at) + opener + source.slice(at), 'javascript').pairs.has(at);
|
|
802
|
+
}
|
|
762
803
|
if (this.rope.length > matchScanLimit) {
|
|
763
804
|
return false;
|
|
764
805
|
}
|
|
@@ -1277,7 +1318,7 @@ export class DocumentModel {
|
|
|
1277
1318
|
if (sorted.some((current, index) => index > 0 && current.from < sorted[index - 1].to)) {
|
|
1278
1319
|
plans = plan(false);
|
|
1279
1320
|
}
|
|
1280
|
-
return this.applySelectionEdits(plans.map((entry) => ({ from: entry.from, to: entry.to, text: entry.text, anchor: entry.caret, head: entry.caret })), { source: 'command' });
|
|
1321
|
+
return this.applySelectionEdits(plans.map((entry) => ({ from: entry.from, to: entry.to, text: entry.text, anchor: entry.caret, head: entry.caret })), { source: 'command', typed: mode === 'enter' });
|
|
1281
1322
|
}
|
|
1282
1323
|
/* A line of its own above each caret's line, at the indentation of its text. */
|
|
1283
1324
|
startNewLineBefore(unit) {
|
|
@@ -1329,7 +1370,8 @@ export class DocumentModel {
|
|
|
1329
1370
|
* it, which it took from that block. Such a closer sits shallower than the brace's line and leaves an opener before it unmatched.
|
|
1330
1371
|
*/
|
|
1331
1372
|
isUnclosedBrace(offset, language) {
|
|
1332
|
-
const
|
|
1373
|
+
const context = isPhp(language) ? this.typingContext(offset, language) : undefined;
|
|
1374
|
+
const index = context?.embedded === 'javascript' && context.embedStart !== undefined ? this.scriptBracketIndex(context.embedStart) : this.bracketIndex(language);
|
|
1333
1375
|
if (index === undefined) {
|
|
1334
1376
|
return false;
|
|
1335
1377
|
}
|
|
@@ -1343,6 +1385,39 @@ export class DocumentModel {
|
|
|
1343
1385
|
}
|
|
1344
1386
|
return [...index.unmatched].some((at) => at < offset && this.rope.charAt(at) === '{');
|
|
1345
1387
|
}
|
|
1388
|
+
scriptSource(start, limit) {
|
|
1389
|
+
if (this.scriptCache?.revision !== this.revision) {
|
|
1390
|
+
this.scriptCache = { revision: this.revision, sources: new Map() };
|
|
1391
|
+
}
|
|
1392
|
+
const cached = this.scriptCache.sources.get(start);
|
|
1393
|
+
if (cached !== undefined && (cached.text !== undefined || cached.limit >= limit)) {
|
|
1394
|
+
return cached.text !== undefined && cached.text.length > limit ? { limit } : cached;
|
|
1395
|
+
}
|
|
1396
|
+
const end = Math.min(this.rope.length, start + limit + 11);
|
|
1397
|
+
let work = bracketScanLimit;
|
|
1398
|
+
const prepared = scriptSource(this.slice(start, end), start, end === this.rope.length, (at) => {
|
|
1399
|
+
const position = this.positionAt(at);
|
|
1400
|
+
work -= position.column + 1;
|
|
1401
|
+
return work < 0 ? null : this.typingContext(at, 'php');
|
|
1402
|
+
});
|
|
1403
|
+
const result = { limit, text: prepared !== undefined && prepared.length <= limit ? prepared : undefined };
|
|
1404
|
+
this.scriptCache.sources.set(start, result);
|
|
1405
|
+
return result;
|
|
1406
|
+
}
|
|
1407
|
+
scriptBracketIndex(start) {
|
|
1408
|
+
const source = this.scriptSource(start, bracketScanLimit);
|
|
1409
|
+
if (source.text === undefined) {
|
|
1410
|
+
return undefined;
|
|
1411
|
+
}
|
|
1412
|
+
if (source.index === undefined) {
|
|
1413
|
+
const index = scanBrackets(source.text, 'javascript');
|
|
1414
|
+
source.index = {
|
|
1415
|
+
pairs: new Map([...index.pairs].map(([from, to]) => [from + start, to + start])),
|
|
1416
|
+
unmatched: new Set([...index.unmatched].map((at) => at + start))
|
|
1417
|
+
};
|
|
1418
|
+
}
|
|
1419
|
+
return source.index;
|
|
1420
|
+
}
|
|
1346
1421
|
/* Undefined for a document too large to scan on every Enter. */
|
|
1347
1422
|
bracketIndex(language) {
|
|
1348
1423
|
if (this.rope.length > bracketScanLimit) {
|
|
@@ -1418,6 +1493,10 @@ export class DocumentModel {
|
|
|
1418
1493
|
return false;
|
|
1419
1494
|
}
|
|
1420
1495
|
const context = this.typingContext(offset - 1, language);
|
|
1496
|
+
if (context.mode === 'html' && context.markup === 'equals' && (opener === '"' || opener === "'")) {
|
|
1497
|
+
const inside = this.typingContext(offset, language);
|
|
1498
|
+
return inside.mode === 'html' && inside.markup === 'value' && inside.markupQuote === opener;
|
|
1499
|
+
}
|
|
1421
1500
|
return context.mode === 'code' || this.isInterpolation(offset - 1, opener, context);
|
|
1422
1501
|
}
|
|
1423
1502
|
/* Backspace inside the indentation of a line goes back to the previous tab stop, and at the start of a line joins it with the one above. */
|
|
@@ -1819,11 +1898,14 @@ export class DocumentModel {
|
|
|
1819
1898
|
this.emit(changes, 'command', () => stretchEdits(previous, state.rope, changes));
|
|
1820
1899
|
}
|
|
1821
1900
|
/* `contentEdits` is worked out when a listener asks, since most listeners never do and a restore of a large text would otherwise read all of it. */
|
|
1822
|
-
emit(changes, source, contentEdits) {
|
|
1901
|
+
emit(changes, source, contentEdits, typed) {
|
|
1823
1902
|
const snapshot = this.getSnapshot();
|
|
1824
1903
|
if (changes) {
|
|
1825
1904
|
snapshot.changes = changes.map((transaction) => transaction.map((change) => ({ ...change })));
|
|
1826
1905
|
snapshot.source = source;
|
|
1906
|
+
if (typed) {
|
|
1907
|
+
snapshot.typed = true;
|
|
1908
|
+
}
|
|
1827
1909
|
if (contentEdits) {
|
|
1828
1910
|
let edits;
|
|
1829
1911
|
Object.defineProperty(snapshot, 'contentEdits', { get: () => (edits ??= contentEdits()), enumerable: true });
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { EditSource } from './edit-source.ts';
|
|
2
|
+
import { type EditRegion } from './languages.ts';
|
|
3
|
+
export type EditRegions = (line: number, offset?: number) => EditRegion | null;
|
|
4
|
+
export declare function phpLiteralAt(source: EditSource, offset: number): boolean;
|
|
5
|
+
export declare function phpTagAt(source: EditSource, offset: number): boolean;
|
|
6
|
+
export declare function editRegions(source: EditSource, language: string): EditRegions;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { vueRegionAt } from './languages.js';
|
|
2
|
+
import { hasJsx, jsxChildren } from './markup-regions.js';
|
|
3
|
+
const SCAN_LIMIT = 2_000_000;
|
|
4
|
+
export function phpLiteralAt(source, offset) {
|
|
5
|
+
const context = source.context(offset);
|
|
6
|
+
return context.mode === 'heredoc' || context.mode === 'quote' || (context.embedded !== undefined && context.mode === 'template');
|
|
7
|
+
}
|
|
8
|
+
export function phpTagAt(source, offset) {
|
|
9
|
+
const context = source.context(offset);
|
|
10
|
+
return context.mode === 'html' && (context.markup === 'tag' || context.markup === 'equals' || context.markup === 'value');
|
|
11
|
+
}
|
|
12
|
+
function firstContent(source, line) {
|
|
13
|
+
const bounds = source.line(line);
|
|
14
|
+
return bounds.start + bounds.text.length - bounds.text.trimStart().length;
|
|
15
|
+
}
|
|
16
|
+
function mixedPhpLine(source, line) {
|
|
17
|
+
const bounds = source.line(line);
|
|
18
|
+
let work = SCAN_LIMIT;
|
|
19
|
+
for (const match of bounds.text.matchAll(/<\?(?:php\b|=)|\?>/g)) {
|
|
20
|
+
work -= bounds.text.length + 1;
|
|
21
|
+
if (work < 0) {
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
const at = bounds.start + match.index;
|
|
25
|
+
const context = source.context(at);
|
|
26
|
+
const { mode, embedded } = context;
|
|
27
|
+
if (match[0] === '?>') {
|
|
28
|
+
if (mode !== 'html' && embedded === undefined) {
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
else if (mode === 'html' || embedded !== undefined || (mode === 'code' && at === firstContent(source, line))) {
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
export function editRegions(source, language) {
|
|
39
|
+
const id = language.toLowerCase();
|
|
40
|
+
const end = source.line(source.lineCount - 1).end;
|
|
41
|
+
if (id === 'vue') {
|
|
42
|
+
return (line) => (/^<\/?(?:template|script|style)\b/.test(source.line(line).text) ? 'mixed' : vueRegionAt((index) => source.line(index).text, line));
|
|
43
|
+
}
|
|
44
|
+
if (hasJsx(id)) {
|
|
45
|
+
let children;
|
|
46
|
+
return (line, offset) => {
|
|
47
|
+
if (end > SCAN_LIMIT) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
children ??= jsxChildren(source.slice(0, end));
|
|
51
|
+
return children(offset ?? firstContent(source, line)) ? 'jsx' : null;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
if (id === 'php') {
|
|
55
|
+
return (line, offset) => {
|
|
56
|
+
const bounds = source.line(line);
|
|
57
|
+
if (offset === undefined &&
|
|
58
|
+
(phpLiteralAt(source, bounds.start) ||
|
|
59
|
+
phpLiteralAt(source, bounds.end) ||
|
|
60
|
+
phpTagAt(source, bounds.start) ||
|
|
61
|
+
phpTagAt(source, bounds.end) ||
|
|
62
|
+
mixedPhpLine(source, line))) {
|
|
63
|
+
return 'mixed';
|
|
64
|
+
}
|
|
65
|
+
const at = offset ?? firstContent(source, line);
|
|
66
|
+
const context = source.context(at);
|
|
67
|
+
if (phpTagAt(source, at)) {
|
|
68
|
+
return 'mixed';
|
|
69
|
+
}
|
|
70
|
+
if (context.mode !== 'html' && context.embedded === undefined) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
if (context.markup === 'comment' || (offset === undefined && source.context(bounds.end).markup === 'comment')) {
|
|
74
|
+
return 'mixed';
|
|
75
|
+
}
|
|
76
|
+
if (context.markup === 'raw') {
|
|
77
|
+
const closing = /^<\/(script|style|textarea|title)(?=[\s/>]|$)/i.exec(source.slice(at, at + 11));
|
|
78
|
+
// The closing tag belongs to markup, while the preceding raw text keeps its syntax.
|
|
79
|
+
if (closing?.[1]?.toLowerCase() !== context.element) {
|
|
80
|
+
return context.element === 'script' || context.element === 'style' ? context.element : 'mixed';
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return 'template';
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
return () => null;
|
|
87
|
+
}
|