@adecore/editor-core 0.22.1 → 0.23.1
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/src/offsets.ts
CHANGED
|
@@ -14,3 +14,27 @@ export function mapOffset(offset: number, edits: readonly TextEdit[], stayBefore
|
|
|
14
14
|
}
|
|
15
15
|
return offset + delta;
|
|
16
16
|
}
|
|
17
|
+
|
|
18
|
+
/* Reuse this index across selections while retaining both endpoint affinities. */
|
|
19
|
+
export function offsetMapper(edits: readonly TextEdit[]): (offset: number, stayBefore?: boolean) => number {
|
|
20
|
+
const deltas = new Float64Array(edits.length + 1);
|
|
21
|
+
for (let index = 0; index < edits.length; index++) {
|
|
22
|
+
const edit = edits[index]!;
|
|
23
|
+
deltas[index + 1] = deltas[index]! + edit.text.length - (edit.to - edit.from);
|
|
24
|
+
}
|
|
25
|
+
return (offset, stayBefore = false) => {
|
|
26
|
+
let low = 0,
|
|
27
|
+
high = edits.length;
|
|
28
|
+
while (low < high) {
|
|
29
|
+
const middle = (low + high) >>> 1;
|
|
30
|
+
const edit = edits[middle]!;
|
|
31
|
+
if (edit.to < offset || (edit.to === offset && !(stayBefore && edit.from === edit.to))) {
|
|
32
|
+
low = middle + 1;
|
|
33
|
+
} else {
|
|
34
|
+
high = middle;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const edit = edits[low];
|
|
38
|
+
return edit !== undefined && edit.from <= offset && offset < edit.to ? edit.from + deltas[low]! + edit.text.length : offset + deltas[low]!;
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { TypingContext } from './typing-context.ts';
|
|
2
|
+
|
|
3
|
+
const TAG = /<(\/?)([a-z][\w:-]*)(?=[\s/>]|$)/iy;
|
|
4
|
+
const PHP = /<\?(?:php\b|=)/y;
|
|
5
|
+
|
|
6
|
+
interface ScriptLexer {
|
|
7
|
+
initial(): TypingContext;
|
|
8
|
+
step(state: TypingContext, text: string, at: number, end: number, offset: number): number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function finishTag(state: TypingContext, selfClosing: boolean, script: ScriptLexer, start: number): void {
|
|
12
|
+
state.markup = !selfClosing && state.element !== undefined ? 'raw' : undefined;
|
|
13
|
+
state.markupQuote = undefined;
|
|
14
|
+
state.embed = state.markup === 'raw' && state.element === 'script' ? script.initial() : undefined;
|
|
15
|
+
if (state.embed !== undefined) {
|
|
16
|
+
state.embed.embedStart = start;
|
|
17
|
+
}
|
|
18
|
+
if (selfClosing) {
|
|
19
|
+
state.element = undefined;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function scanPhpMarkup(state: TypingContext, text: string, from: number, end: number, offset: number, script: ScriptLexer): number {
|
|
24
|
+
for (let at = from; at < end; at++) {
|
|
25
|
+
const char = text[at]!;
|
|
26
|
+
if (char === '<' && text[at + 1] === '?') {
|
|
27
|
+
PHP.lastIndex = at;
|
|
28
|
+
const open = PHP.exec(text);
|
|
29
|
+
if (open !== null) {
|
|
30
|
+
if (state.markup === 'equals') {
|
|
31
|
+
state.markup = 'value';
|
|
32
|
+
}
|
|
33
|
+
state.mode = 'code';
|
|
34
|
+
state.expression = true;
|
|
35
|
+
state.word = '';
|
|
36
|
+
return at + open[0].length - 1;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (state.markup === 'comment') {
|
|
40
|
+
if (text.startsWith('-->', at) && at + 3 <= end) {
|
|
41
|
+
state.markup = undefined;
|
|
42
|
+
at += 2;
|
|
43
|
+
}
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (state.markup === 'value' && state.markupQuote !== undefined) {
|
|
47
|
+
if (char === state.markupQuote) {
|
|
48
|
+
state.markup = 'tag';
|
|
49
|
+
state.markupQuote = undefined;
|
|
50
|
+
}
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (char === '<') {
|
|
54
|
+
if (state.markup !== 'raw' && text.startsWith('<!--', at)) {
|
|
55
|
+
// HTML also closes these two comments immediately, without a later -->.
|
|
56
|
+
const empty = text.startsWith('<!-->', at) ? 5 : text.startsWith('<!--->', at) ? 6 : 0;
|
|
57
|
+
state.markup = empty > 0 && at + empty <= end ? undefined : 'comment';
|
|
58
|
+
state.element = undefined;
|
|
59
|
+
at += (empty || 4) - 1;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
TAG.lastIndex = at;
|
|
63
|
+
const tag = TAG.exec(text);
|
|
64
|
+
const name = tag?.[2]?.toLowerCase();
|
|
65
|
+
if (state.markup === 'raw' && (tag?.[1] !== '/' || name !== state.element)) {
|
|
66
|
+
if (state.embed !== undefined) {
|
|
67
|
+
at = script.step(state.embed, text, at, end, offset);
|
|
68
|
+
}
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
state.embed = undefined;
|
|
72
|
+
state.element = tag?.[1] !== '/' && (name === 'script' || name === 'style' || name === 'textarea' || name === 'title') ? name : undefined;
|
|
73
|
+
state.markup = tag === null ? undefined : 'tag';
|
|
74
|
+
state.markupQuote = undefined;
|
|
75
|
+
if (tag !== null) {
|
|
76
|
+
at += tag[0].length - 1;
|
|
77
|
+
}
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (state.embed !== undefined) {
|
|
81
|
+
at = script.step(state.embed, text, at, end, offset);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (state.markup === undefined || state.markup === 'raw') {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (char === '>') {
|
|
88
|
+
finishTag(state, false, script, offset + at + 1);
|
|
89
|
+
} else if (char === '/' && text[at + 1] === '>' && at + 2 <= end) {
|
|
90
|
+
finishTag(state, true, script, offset + at + 2);
|
|
91
|
+
at++;
|
|
92
|
+
} else if (/\s/u.test(char)) {
|
|
93
|
+
if (state.markup === 'value') {
|
|
94
|
+
state.markup = 'tag';
|
|
95
|
+
}
|
|
96
|
+
} else if (state.markup === 'equals') {
|
|
97
|
+
state.markup = 'value';
|
|
98
|
+
state.markupQuote = char === '"' || char === "'" ? char : undefined;
|
|
99
|
+
} else if (state.markup === 'tag' && char === '=') {
|
|
100
|
+
state.markup = 'equals';
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return end - 1;
|
|
104
|
+
}
|
package/src/rope.ts
CHANGED
|
@@ -7,14 +7,14 @@ export interface DocumentLine {
|
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
/*
|
|
10
|
-
* A persistent AVL tree of text chunks of at most `
|
|
10
|
+
* A persistent AVL tree of text chunks of at most `ROPE_CHUNK_SIZE` UTF-16 code units. Every node knows
|
|
11
11
|
* its length and its LF count, a leaf also where its LFs are, so offsets and line numbers resolve
|
|
12
12
|
* without scanning.
|
|
13
13
|
*/
|
|
14
14
|
type Leaf = { readonly text: string; readonly breaks: readonly number[]; readonly length: number; readonly lines: number; readonly height: number };
|
|
15
15
|
type Branch = { readonly left: Node; readonly right: Node; readonly length: number; readonly lines: number; readonly height: number };
|
|
16
16
|
type Node = Leaf | Branch;
|
|
17
|
-
const
|
|
17
|
+
export const ROPE_CHUNK_SIZE = 2048;
|
|
18
18
|
|
|
19
19
|
function leaf(text: string): Leaf | null {
|
|
20
20
|
if (!text.length) {
|
|
@@ -56,7 +56,7 @@ function join(left: Node | null, right: Node | null): Node | null {
|
|
|
56
56
|
if (!right) {
|
|
57
57
|
return left;
|
|
58
58
|
}
|
|
59
|
-
if ('text' in left && 'text' in right && left.length + right.length <=
|
|
59
|
+
if ('text' in left && 'text' in right && left.length + right.length <= ROPE_CHUNK_SIZE) {
|
|
60
60
|
return leaf(left.text + right.text);
|
|
61
61
|
}
|
|
62
62
|
if (left.height > right.height + 1 && 'left' in left) {
|
|
@@ -88,8 +88,8 @@ function split(node: Node | null, offset: number): [Node | null, Node | null] {
|
|
|
88
88
|
|
|
89
89
|
function build(text: string): Node | null {
|
|
90
90
|
const leaves: Node[] = [];
|
|
91
|
-
for (let offset = 0; offset < text.length; offset +=
|
|
92
|
-
leaves.push(leaf(text.slice(offset, offset +
|
|
91
|
+
for (let offset = 0; offset < text.length; offset += ROPE_CHUNK_SIZE) {
|
|
92
|
+
leaves.push(leaf(text.slice(offset, offset + ROPE_CHUNK_SIZE))!);
|
|
93
93
|
}
|
|
94
94
|
const tree = (from: number, to: number): Node | null => {
|
|
95
95
|
if (from === to) {
|
|
@@ -187,6 +187,36 @@ export class TextRope {
|
|
|
187
187
|
return new TextRope(join(join(before, build(text)), after));
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
+
/* Dense validated batches rebuild once, rather than repeatedly splitting and rescanning leaves. */
|
|
191
|
+
rebuild(edits: readonly { from: number; to: number; text: string }[]): TextRope {
|
|
192
|
+
const chunks = chunksOf(this.root, 0, this.length);
|
|
193
|
+
let chunk = chunks.next();
|
|
194
|
+
let column = 0;
|
|
195
|
+
let offset = 0;
|
|
196
|
+
const pieces: string[] = [];
|
|
197
|
+
const advance = (end: number, keep: boolean): void => {
|
|
198
|
+
while (offset < end && !chunk.done) {
|
|
199
|
+
const count = Math.min(end - offset, chunk.value.length - column);
|
|
200
|
+
if (keep) {
|
|
201
|
+
pieces.push(chunk.value.slice(column, column + count));
|
|
202
|
+
}
|
|
203
|
+
offset += count;
|
|
204
|
+
column += count;
|
|
205
|
+
if (column === chunk.value.length) {
|
|
206
|
+
chunk = chunks.next();
|
|
207
|
+
column = 0;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
for (const edit of edits) {
|
|
212
|
+
advance(edit.from, true);
|
|
213
|
+
pieces.push(edit.text);
|
|
214
|
+
advance(edit.to, false);
|
|
215
|
+
}
|
|
216
|
+
advance(this.length, true);
|
|
217
|
+
return TextRope.from(pieces.join(''));
|
|
218
|
+
}
|
|
219
|
+
|
|
190
220
|
/* Whether both ropes hold the same text in `[from, to)`, which has to be the same range of both. */
|
|
191
221
|
equalsRange(other: TextRope, from: number, to: number): boolean {
|
|
192
222
|
if (this.root === other.root) {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { TypingContext } from './typing-context.ts';
|
|
2
|
+
|
|
3
|
+
export function scriptSource(text: string, start: number, complete: boolean, context: (offset: number) => TypingContext | null): string | undefined {
|
|
4
|
+
const events = /<\?(?:php\b|=)|\?>|<\/script(?=[\s/>]|$)/gi;
|
|
5
|
+
const chunks: string[] = [];
|
|
6
|
+
let cursor = 0;
|
|
7
|
+
let island: number | undefined;
|
|
8
|
+
const finish = (end: number): string => {
|
|
9
|
+
chunks.push(text.slice(cursor, island ?? end));
|
|
10
|
+
if (island !== undefined) {
|
|
11
|
+
chunks.push(' '.repeat(end - island));
|
|
12
|
+
}
|
|
13
|
+
return chunks.join('');
|
|
14
|
+
};
|
|
15
|
+
for (const match of text.matchAll(events)) {
|
|
16
|
+
const state = context(start + match.index);
|
|
17
|
+
if (state === null) {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
const inScript = state.embedded === 'javascript' && state.embedStart === start;
|
|
21
|
+
if (/^<\?(?:php\b|=)/.test(match[0])) {
|
|
22
|
+
if (inScript) {
|
|
23
|
+
island = match.index;
|
|
24
|
+
}
|
|
25
|
+
} else if (match[0] === '?>') {
|
|
26
|
+
if (island !== undefined && state.embed?.embedStart === start && (state.mode === 'code' || state.mode === 'line-comment')) {
|
|
27
|
+
let end = match.index + 2;
|
|
28
|
+
if (text.startsWith('\r\n', end)) {
|
|
29
|
+
end += 2;
|
|
30
|
+
} else if (text[end] === '\n') {
|
|
31
|
+
end++;
|
|
32
|
+
}
|
|
33
|
+
// Spaces preserve document offsets without treating PHP source newlines as script text.
|
|
34
|
+
chunks.push(text.slice(cursor, island), ' '.repeat(end - island));
|
|
35
|
+
cursor = end;
|
|
36
|
+
island = undefined;
|
|
37
|
+
}
|
|
38
|
+
} else if (match[0].startsWith('</') && inScript) {
|
|
39
|
+
return finish(match.index);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return complete ? finish(text.length) : undefined;
|
|
43
|
+
}
|
package/src/search.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { clampInteger } from './rope.ts';
|
|
1
2
|
import { replaceWithCaseRespect } from './preserve-case.ts';
|
|
2
3
|
|
|
3
4
|
export interface FindOptions {
|
|
@@ -40,8 +41,14 @@ function characterBefore(text: string, offset: number): string {
|
|
|
40
41
|
|
|
41
42
|
/* Invalid bounds throw `RangeError`, an invalid expression `SyntaxError`. A zero-width match advances by one code point. */
|
|
42
43
|
export function findMatches(text: string, query: string, revision: number, options: FindOptions = {}): FindMatch[] {
|
|
44
|
+
return Array.from(iterateMatches(text, query, revision, options));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/* Validation and regex execution start when the iterator advances. */
|
|
48
|
+
export function* iterateMatches(text: string, query: string, revision: number, options: FindOptions = {}): Generator<FindMatch, void, unknown> {
|
|
49
|
+
options = { ...options };
|
|
43
50
|
if (!query && !options.regex) {
|
|
44
|
-
return
|
|
51
|
+
return;
|
|
45
52
|
}
|
|
46
53
|
const from = options.from ?? 0;
|
|
47
54
|
const to = options.to ?? text.length;
|
|
@@ -55,8 +62,8 @@ export function findMatches(text: string, query: string, revision: number, optio
|
|
|
55
62
|
const source = options.regex ? query : query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
56
63
|
const expression = new RegExp(source, `gmu${options.caseSensitive ? '' : 'i'}`);
|
|
57
64
|
expression.lastIndex = from;
|
|
58
|
-
|
|
59
|
-
while (
|
|
65
|
+
let count = 0;
|
|
66
|
+
while (count < maximum) {
|
|
60
67
|
const match = expression.exec(text);
|
|
61
68
|
if (!match || match.index > to) {
|
|
62
69
|
break;
|
|
@@ -66,7 +73,8 @@ export function findMatches(text: string, query: string, revision: number, optio
|
|
|
66
73
|
!options.wholeWord ||
|
|
67
74
|
(!wordCharacter.test(characterBefore(text, match.index)) && !wordCharacter.test(String.fromCodePoint(text.codePointAt(end) ?? 0)));
|
|
68
75
|
if (match.index >= from && end <= to && !splitsSurrogate(text, match.index) && !splitsSurrogate(text, end) && wholeWord) {
|
|
69
|
-
|
|
76
|
+
count++;
|
|
77
|
+
yield {
|
|
70
78
|
from: match.index,
|
|
71
79
|
to: end,
|
|
72
80
|
text: match[0],
|
|
@@ -74,23 +82,65 @@ export function findMatches(text: string, query: string, revision: number, optio
|
|
|
74
82
|
groups: match.groups ? { ...match.groups } : undefined,
|
|
75
83
|
regex: options.regex === true,
|
|
76
84
|
revision
|
|
77
|
-
}
|
|
85
|
+
};
|
|
78
86
|
}
|
|
79
87
|
if (!match[0].length) {
|
|
80
88
|
expression.lastIndex = match.index + ((text.codePointAt(match.index) ?? 0) > 0xffff ? 2 : 1);
|
|
81
89
|
}
|
|
82
90
|
}
|
|
83
|
-
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/* Keeps the original match grid; starting RegExp at the caret would create overlapping matches. */
|
|
94
|
+
export function findMatchFrom(text: string, query: string, revision: number, offset: number, options: FindNextOptions = {}): FindMatch | null {
|
|
95
|
+
const position = clampInteger(offset, text.length);
|
|
96
|
+
let first: FindMatch | null = null;
|
|
97
|
+
let last: FindMatch | null = null;
|
|
98
|
+
let previous: FindMatch | null = null;
|
|
99
|
+
for (const match of iterateMatches(text, query, revision, options)) {
|
|
100
|
+
first ??= match;
|
|
101
|
+
last = match;
|
|
102
|
+
if (!options.backwards && match.from >= position) {
|
|
103
|
+
return match;
|
|
104
|
+
}
|
|
105
|
+
if (options.backwards) {
|
|
106
|
+
if (match.to <= position) {
|
|
107
|
+
previous = match;
|
|
108
|
+
} else if (previous !== null || options.wrap === false) {
|
|
109
|
+
return previous;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return options.backwards ? (previous ?? (options.wrap === false ? null : last)) : options.wrap === false ? null : first;
|
|
84
114
|
}
|
|
85
115
|
|
|
86
116
|
/* Expands the JavaScript `$` patterns of a regex replacement, and gives it the case of the match when asked. Literal replacements are returned as they are. */
|
|
87
|
-
export function replacementText(text: string, match: FindMatch, replacement: string, literal = false, preserveCase = false): string {
|
|
88
|
-
|
|
89
|
-
|
|
117
|
+
export function replacementText(text: string, match: FindMatch, replacement: string, literal = false, preserveCase = false, maxCharacters = Infinity): string {
|
|
118
|
+
if (maxCharacters !== Infinity && (!Number.isSafeInteger(maxCharacters) || maxCharacters < 0)) {
|
|
119
|
+
throw new RangeError('The replacement character limit must be nonnegative.');
|
|
120
|
+
}
|
|
121
|
+
const expanded = literal || !match.regex ? replacement : expandPatterns(text, match, replacement, maxCharacters);
|
|
122
|
+
if (expanded.length > maxCharacters) {
|
|
123
|
+
throw new RangeError('Replacement output exceeds its character limit.');
|
|
124
|
+
}
|
|
125
|
+
const result = preserveCase ? replaceWithCaseRespect(expanded, match.text) : expanded;
|
|
126
|
+
if (result.length > maxCharacters) {
|
|
127
|
+
throw new RangeError('Replacement output exceeds its character limit.');
|
|
128
|
+
}
|
|
129
|
+
return result;
|
|
90
130
|
}
|
|
91
131
|
|
|
92
|
-
function expandPatterns(text: string, match: FindMatch, replacement: string): string {
|
|
93
|
-
|
|
132
|
+
function expandPatterns(text: string, match: FindMatch, replacement: string, maxCharacters: number): string {
|
|
133
|
+
const pieces: string[] = [];
|
|
134
|
+
let characters = 0,
|
|
135
|
+
cursor = 0;
|
|
136
|
+
const append = (value: string): void => {
|
|
137
|
+
characters += value.length;
|
|
138
|
+
if (characters > maxCharacters) {
|
|
139
|
+
throw new RangeError('Replacement output exceeds its character limit.');
|
|
140
|
+
}
|
|
141
|
+
pieces.push(value);
|
|
142
|
+
};
|
|
143
|
+
const expand = (token: string, group: string): string => {
|
|
94
144
|
if (group === '$') {
|
|
95
145
|
return '$';
|
|
96
146
|
}
|
|
@@ -115,5 +165,12 @@ function expandPatterns(text: string, match: FindMatch, replacement: string): st
|
|
|
115
165
|
return (match.captures[first - 1] ?? '') + group[1];
|
|
116
166
|
}
|
|
117
167
|
return token;
|
|
118
|
-
}
|
|
168
|
+
};
|
|
169
|
+
for (const found of replacement.matchAll(/\$(\$|&|`|'|\d{1,2}|<[^>]*>)/g)) {
|
|
170
|
+
append(replacement.slice(cursor, found.index));
|
|
171
|
+
append(expand(found[0], found[1]!));
|
|
172
|
+
cursor = found.index + found[0].length;
|
|
173
|
+
}
|
|
174
|
+
append(replacement.slice(cursor));
|
|
175
|
+
return pieces.join('');
|
|
119
176
|
}
|
package/src/types.ts
CHANGED
|
@@ -49,6 +49,8 @@ export interface EditorSnapshot {
|
|
|
49
49
|
/* The same change in order, as a sequence a language server can follow; present when the text changed. */
|
|
50
50
|
contentEdits?: readonly ContentEdit[];
|
|
51
51
|
source?: ChangeSource;
|
|
52
|
+
/* The edit is what a person's typing did, a line break from Enter included, though it ran as a command. */
|
|
53
|
+
typed?: boolean;
|
|
52
54
|
}
|
|
53
55
|
|
|
54
56
|
export interface EditOptions {
|
|
@@ -57,6 +59,10 @@ export interface EditOptions {
|
|
|
57
59
|
source?: ChangeSource;
|
|
58
60
|
/* Edits with the same group join one undo step until a selection change or an undo closes it. */
|
|
59
61
|
historyGroup?: string;
|
|
62
|
+
/* Joins the undo step before it when the document is exactly as that step left it, so one undo takes both back. */
|
|
63
|
+
joinPrevious?: boolean;
|
|
64
|
+
/* Reported on the snapshot: the edit is what a person's typing did, though it ran as a command. */
|
|
65
|
+
typed?: boolean;
|
|
60
66
|
}
|
|
61
67
|
|
|
62
68
|
export type EditorCommand =
|
package/src/typing-context.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
startsExpressionAfter,
|
|
14
14
|
startsWord
|
|
15
15
|
} from './lexical.ts';
|
|
16
|
+
import { scanPhpMarkup } from './php-markup.ts';
|
|
16
17
|
import type { DocumentLine } from './rope.ts';
|
|
17
18
|
|
|
18
19
|
export interface TypingBracket {
|
|
@@ -43,6 +44,15 @@ export interface TypingContext {
|
|
|
43
44
|
word: string;
|
|
44
45
|
/* Where the comment the lexer is in began, in the document. */
|
|
45
46
|
commentStart?: number;
|
|
47
|
+
/* PHP preserves these fields while code inside an attribute or raw block runs. */
|
|
48
|
+
markup?: 'tag' | 'equals' | 'value' | 'comment' | 'raw';
|
|
49
|
+
markupQuote?: '"' | "'";
|
|
50
|
+
element?: 'script' | 'style' | 'textarea' | 'title';
|
|
51
|
+
embed?: TypingContext;
|
|
52
|
+
embedded?: 'javascript';
|
|
53
|
+
embedStart?: number;
|
|
54
|
+
/* Only a scan result keeps this; PHP consumes the newline immediately after a closing tag. */
|
|
55
|
+
phpClose?: number;
|
|
46
56
|
}
|
|
47
57
|
|
|
48
58
|
interface Rules {
|
|
@@ -58,6 +68,12 @@ interface Rules {
|
|
|
58
68
|
|
|
59
69
|
const PHP_HEREDOC = /^<<<[ \t]*(["']?)([A-Za-z_]\w*)\1[ \t]*$/;
|
|
60
70
|
const PHP_OPEN = /<\?(?:php\b|=)/;
|
|
71
|
+
const PHP_INITIAL_LINES = 64;
|
|
72
|
+
const PHP_INITIAL_CHARACTERS = 64 * 1024;
|
|
73
|
+
// Quoted PHP tags within a bare code fragment must not turn its prelude into output.
|
|
74
|
+
const PHP_FRAGMENT_START =
|
|
75
|
+
/^(?:[$'"#]|\/[/*]|(?:abstract|class|const|declare|echo|enum|final|for|foreach|function|if|include(?:_once)?|interface|namespace|print|readonly|require(?:_once)?|return|switch|throw|trait|try|use|while)\b|[A-Za-z_]\w*\s*(?:\(|::))/;
|
|
76
|
+
|
|
61
77
|
const controlWords = /^(for|for-await|if|while|switch|catch|with)$/;
|
|
62
78
|
const blockWords = /^(else|try|finally|do)$/;
|
|
63
79
|
|
|
@@ -151,6 +167,7 @@ function scanCode(state: TypingContext, rules: Rules, text: string, at: number,
|
|
|
151
167
|
}
|
|
152
168
|
}
|
|
153
169
|
if (rules.php && char === '?' && next === '>') {
|
|
170
|
+
state.phpClose = offset + at + 2;
|
|
154
171
|
state.mode = 'html';
|
|
155
172
|
return at + 1;
|
|
156
173
|
}
|
|
@@ -214,9 +231,34 @@ function closeBracket(state: TypingContext, char: string): void {
|
|
|
214
231
|
state.word = bracket.control ? 'control-close' : '';
|
|
215
232
|
}
|
|
216
233
|
|
|
234
|
+
let scriptRules: Rules | undefined;
|
|
235
|
+
|
|
236
|
+
function stepScript(state: TypingContext, text: string, at: number, end: number, offset: number): number {
|
|
237
|
+
if (state.mode === 'line-comment') {
|
|
238
|
+
return at;
|
|
239
|
+
}
|
|
240
|
+
if (state.mode === 'block-comment') {
|
|
241
|
+
return scanComment(state, text, at, end);
|
|
242
|
+
}
|
|
243
|
+
return state.mode === 'code' ? scanCode(state, (scriptRules ??= rulesOf('javascript')), text, at, end, offset) : scanLiteral(state, text, at, end, offset);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const scriptLexer = { initial, step: stepScript };
|
|
247
|
+
|
|
248
|
+
function normalizeLine(state: TypingContext, multilineQuotes: boolean): void {
|
|
249
|
+
if (state.mode === 'line-comment' || state.mode === 'regex' || (state.mode === 'quote' && !multilineQuotes && !state.escaped)) {
|
|
250
|
+
state.mode = 'code';
|
|
251
|
+
state.quote = undefined;
|
|
252
|
+
state.commentStart = undefined;
|
|
253
|
+
state.expression = true;
|
|
254
|
+
}
|
|
255
|
+
state.escaped = false;
|
|
256
|
+
}
|
|
257
|
+
|
|
217
258
|
/* Lexes `text` up to `end`, from the state the line started in. `offset` is where the line starts in the document. */
|
|
218
259
|
function scan(text: string, end: number, start: TypingContext, rules: Rules, offset: number): TypingContext {
|
|
219
|
-
|
|
260
|
+
// An embedded lexer must not mutate the script state cached at the line start.
|
|
261
|
+
const state = { ...start, embed: start.embed === undefined ? undefined : { ...start.embed } };
|
|
220
262
|
for (let at = 0; at < end; at++) {
|
|
221
263
|
if (state.mode === 'line-comment') {
|
|
222
264
|
// A PHP line comment ends at its line break and at a closing tag.
|
|
@@ -224,18 +266,12 @@ function scan(text: string, end: number, start: TypingContext, rules: Rules, off
|
|
|
224
266
|
if (tag < 0 || tag >= end) {
|
|
225
267
|
break;
|
|
226
268
|
}
|
|
269
|
+
state.phpClose = offset + tag + 2;
|
|
227
270
|
state.mode = 'html';
|
|
228
271
|
state.commentStart = undefined;
|
|
229
272
|
at = tag + 1;
|
|
230
273
|
} else if (state.mode === 'html') {
|
|
231
|
-
|
|
232
|
-
if (open === null || at + open.index >= end) {
|
|
233
|
-
break;
|
|
234
|
-
}
|
|
235
|
-
state.mode = 'code';
|
|
236
|
-
state.expression = true;
|
|
237
|
-
state.word = '';
|
|
238
|
-
at += open.index + open[0].length - 1;
|
|
274
|
+
at = scanPhpMarkup(state, text, at, end, offset, scriptLexer);
|
|
239
275
|
} else if (state.mode === 'heredoc') {
|
|
240
276
|
const closing = new RegExp(`^[\\t ]*${state.quote}(?![\\w])`).exec(text);
|
|
241
277
|
if (closing === null) {
|
|
@@ -265,7 +301,13 @@ function sameState(left: TypingContext, right: TypingContext): boolean {
|
|
|
265
301
|
left.bracket === right.bracket &&
|
|
266
302
|
left.expression === right.expression &&
|
|
267
303
|
left.word === right.word &&
|
|
268
|
-
left.commentStart === right.commentStart
|
|
304
|
+
left.commentStart === right.commentStart &&
|
|
305
|
+
left.markup === right.markup &&
|
|
306
|
+
left.markupQuote === right.markupQuote &&
|
|
307
|
+
left.element === right.element &&
|
|
308
|
+
left.phpClose === right.phpClose &&
|
|
309
|
+
left.embedStart === right.embedStart &&
|
|
310
|
+
(left.embed === right.embed || (left.embed !== undefined && right.embed !== undefined && sameState(left.embed, right.embed)))
|
|
269
311
|
);
|
|
270
312
|
}
|
|
271
313
|
|
|
@@ -274,6 +316,7 @@ export class TypingContexts {
|
|
|
274
316
|
private readonly line: (index: number) => DocumentLine;
|
|
275
317
|
private states: TypingContext[] = [initial()];
|
|
276
318
|
private language = '';
|
|
319
|
+
private decidingLine = 0;
|
|
277
320
|
|
|
278
321
|
constructor(line: (index: number) => DocumentLine) {
|
|
279
322
|
this.line = line;
|
|
@@ -281,16 +324,34 @@ export class TypingContexts {
|
|
|
281
324
|
|
|
282
325
|
invalidate(line = 0): void {
|
|
283
326
|
this.states.length = Math.min(this.states.length, Math.max(1, line + 1));
|
|
284
|
-
if (line
|
|
327
|
+
if (line <= this.decidingLine) {
|
|
285
328
|
this.language = '';
|
|
286
329
|
}
|
|
287
330
|
}
|
|
288
331
|
|
|
289
|
-
/* A PHP file that opens with markup, and not with a tag, starts outside the PHP. */
|
|
290
332
|
private initialState(language: string): TypingContext {
|
|
291
333
|
const state = initial();
|
|
292
|
-
|
|
293
|
-
|
|
334
|
+
this.decidingLine = 0;
|
|
335
|
+
if (language !== 'php') {
|
|
336
|
+
return state;
|
|
337
|
+
}
|
|
338
|
+
let remaining = PHP_INITIAL_CHARACTERS;
|
|
339
|
+
for (let line = 0; line < PHP_INITIAL_LINES && remaining > 0; line++) {
|
|
340
|
+
this.decidingLine = line;
|
|
341
|
+
const source = this.line(line);
|
|
342
|
+
const sample = source.text.slice(0, remaining);
|
|
343
|
+
remaining -= sample.length;
|
|
344
|
+
const text = sample.trimStart();
|
|
345
|
+
if (text !== '') {
|
|
346
|
+
const open = PHP_OPEN.exec(text);
|
|
347
|
+
if (/^<(?![?<])/.test(text) || (open !== null && open.index > 0 && !PHP_FRAGMENT_START.test(text))) {
|
|
348
|
+
state.mode = 'html';
|
|
349
|
+
}
|
|
350
|
+
return state;
|
|
351
|
+
}
|
|
352
|
+
if (source.end === source.next) {
|
|
353
|
+
break;
|
|
354
|
+
}
|
|
294
355
|
}
|
|
295
356
|
return state;
|
|
296
357
|
}
|
|
@@ -306,17 +367,19 @@ export class TypingContexts {
|
|
|
306
367
|
const previous = this.states.at(-1)!;
|
|
307
368
|
const source = this.line(this.states.length - 1);
|
|
308
369
|
const next = scan(source.text, source.text.length, previous, rules, source.start);
|
|
309
|
-
//
|
|
310
|
-
if (next.mode === '
|
|
311
|
-
next.
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
370
|
+
// A PHP island's source newlines are not newlines in the emitted script.
|
|
371
|
+
if (next.mode === 'html' && next.embed !== undefined && next.phpClose !== source.end) {
|
|
372
|
+
normalizeLine(next.embed, false);
|
|
373
|
+
}
|
|
374
|
+
next.phpClose = undefined;
|
|
375
|
+
normalizeLine(next, /^(php|rust|rs)$/i.test(id));
|
|
376
|
+
if (next.mode === 'html' && next.markup === 'value' && next.markupQuote === undefined) {
|
|
377
|
+
next.markup = 'tag';
|
|
315
378
|
}
|
|
316
|
-
next.escaped = false;
|
|
317
379
|
this.states.push(sameState(next, previous) ? previous : next);
|
|
318
380
|
}
|
|
319
381
|
const source = this.line(line);
|
|
320
|
-
|
|
382
|
+
const state = scan(source.text, column, this.states[line], rules, source.start);
|
|
383
|
+
return state.mode === 'html' && state.embed !== undefined ? { ...state.embed, markup: 'raw', element: 'script', embedded: 'javascript' } : state;
|
|
321
384
|
}
|
|
322
385
|
}
|