@adecore/editor-core 0.0.1 → 0.17.0-beta.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.
Files changed (80) hide show
  1. package/LICENSE +110 -0
  2. package/LICENSE.apache-2.0.txt +201 -0
  3. package/NOTICE +11 -0
  4. package/README.md +54 -0
  5. package/dist/block-folds.d.ts +3 -0
  6. package/dist/block-folds.js +112 -0
  7. package/dist/brackets.d.ts +5 -0
  8. package/dist/brackets.js +136 -0
  9. package/dist/closer-tracker.d.ts +9 -0
  10. package/dist/closer-tracker.js +46 -0
  11. package/dist/comments.d.ts +14 -0
  12. package/dist/comments.js +344 -0
  13. package/dist/document.d.ts +148 -0
  14. package/dist/document.js +1729 -0
  15. package/dist/edit-source.d.ts +19 -0
  16. package/dist/edit-source.js +1 -0
  17. package/dist/enter.d.ts +17 -0
  18. package/dist/enter.js +387 -0
  19. package/dist/fold-roles.d.ts +6 -0
  20. package/dist/fold-roles.js +142 -0
  21. package/dist/index.d.ts +10 -0
  22. package/dist/index.js +7 -0
  23. package/dist/languages.d.ts +12 -0
  24. package/dist/languages.js +51 -0
  25. package/dist/lexical-folds.d.ts +10 -0
  26. package/dist/lexical-folds.js +116 -0
  27. package/dist/lexical.d.ts +16 -0
  28. package/dist/lexical.js +74 -0
  29. package/dist/line-commands.d.ts +9 -0
  30. package/dist/line-commands.js +306 -0
  31. package/dist/markup-regions.d.ts +8 -0
  32. package/dist/markup-regions.js +331 -0
  33. package/dist/offsets.d.ts +2 -0
  34. package/dist/offsets.js +14 -0
  35. package/dist/paste.d.ts +9 -0
  36. package/dist/paste.js +108 -0
  37. package/dist/preserve-case.d.ts +1 -0
  38. package/dist/preserve-case.js +61 -0
  39. package/dist/rope.d.ts +22 -0
  40. package/dist/rope.js +246 -0
  41. package/dist/search.d.ts +26 -0
  42. package/dist/search.js +85 -0
  43. package/dist/structure.d.ts +50 -0
  44. package/dist/structure.js +124 -0
  45. package/dist/text-span.d.ts +7 -0
  46. package/dist/text-span.js +211 -0
  47. package/dist/types.d.ts +70 -0
  48. package/dist/types.js +1 -0
  49. package/dist/typing-context.d.ts +30 -0
  50. package/dist/typing-context.js +270 -0
  51. package/dist/typing-handlers.d.ts +15 -0
  52. package/dist/typing-handlers.js +125 -0
  53. package/dist/words.d.ts +7 -0
  54. package/dist/words.js +72 -0
  55. package/package.json +34 -3
  56. package/src/block-folds.ts +117 -0
  57. package/src/brackets.ts +159 -0
  58. package/src/closer-tracker.ts +60 -0
  59. package/src/comments.ts +411 -0
  60. package/src/document.ts +2021 -0
  61. package/src/edit-source.ts +25 -0
  62. package/src/enter.ts +452 -0
  63. package/src/fold-roles.ts +166 -0
  64. package/src/index.ts +22 -0
  65. package/src/languages.ts +77 -0
  66. package/src/lexical-folds.ts +138 -0
  67. package/src/lexical.ts +113 -0
  68. package/src/line-commands.ts +356 -0
  69. package/src/markup-regions.ts +333 -0
  70. package/src/offsets.ts +16 -0
  71. package/src/paste.ts +126 -0
  72. package/src/preserve-case.ts +62 -0
  73. package/src/rope.ts +278 -0
  74. package/src/search.ts +119 -0
  75. package/src/structure.ts +223 -0
  76. package/src/text-span.ts +230 -0
  77. package/src/types.ts +141 -0
  78. package/src/typing-context.ts +329 -0
  79. package/src/typing-handlers.ts +143 -0
  80. package/src/words.ts +83 -0
@@ -0,0 +1,230 @@
1
+ export interface TextSpan {
2
+ /* UTF-16 offsets into the text before the change. */
3
+ readonly start: number;
4
+ readonly end: number;
5
+ /* What stands between them after it. */
6
+ readonly text: string;
7
+ }
8
+
9
+ function isHighSurrogate(code: number): boolean {
10
+ return code >= 0xd800 && code <= 0xdbff;
11
+ }
12
+
13
+ function isLowSurrogate(code: number): boolean {
14
+ return code >= 0xdc00 && code <= 0xdfff;
15
+ }
16
+
17
+ /*
18
+ * The one stretch that differs between two texts, found from both ends. Replacing only that keeps a
19
+ * cursor, a selection and the scroll where they were anywhere outside it, which setting the whole
20
+ * text would not. Null when the two are the same.
21
+ */
22
+ export function changedSpan(before: string, after: string): TextSpan | null {
23
+ if (before === after) {
24
+ return null;
25
+ }
26
+ const limit = Math.min(before.length, after.length);
27
+ let start = 0;
28
+ while (start < limit && before.charCodeAt(start) === after.charCodeAt(start)) {
29
+ start += 1;
30
+ }
31
+ // Never between the halves of a surrogate pair, which would leave a lone half on either side.
32
+ if (start > 0 && isHighSurrogate(before.charCodeAt(start - 1))) {
33
+ start -= 1;
34
+ }
35
+ let tail = 0;
36
+ while (tail < limit - start && before.charCodeAt(before.length - 1 - tail) === after.charCodeAt(after.length - 1 - tail)) {
37
+ tail += 1;
38
+ }
39
+ if (tail > 0 && isLowSurrogate(before.charCodeAt(before.length - tail))) {
40
+ tail -= 1;
41
+ }
42
+ return { start, end: before.length - tail, text: after.slice(start, after.length - tail) };
43
+ }
44
+
45
+ /* A line diff stops at this much work (snake steps and diagonals visited) and falls back to the one span. */
46
+ const DIFF_WORK_LIMIT = 4_000_000;
47
+ /* More edit lines than this is a rewrite, where one span is as good as any. */
48
+ const DIFF_EDIT_LIMIT = 1_000;
49
+ /* Past this much text between the first and last change the intern table costs more than the diff is worth. */
50
+ const DIFF_TEXT_LIMIT = 16 * 1024 * 1024;
51
+
52
+ interface LineHunk {
53
+ readonly beforeFrom: number;
54
+ readonly beforeTo: number;
55
+ readonly afterFrom: number;
56
+ readonly afterTo: number;
57
+ }
58
+
59
+ function splitLines(text: string): string[] {
60
+ const lines: string[] = [];
61
+ let from = 0;
62
+ for (let at = text.indexOf('\n'); at >= 0; at = text.indexOf('\n', from)) {
63
+ lines.push(text.slice(from, at + 1));
64
+ from = at + 1;
65
+ }
66
+ if (from < text.length) {
67
+ lines.push(text.slice(from));
68
+ }
69
+ return lines;
70
+ }
71
+
72
+ /*
73
+ * The shortest edit script between two line sequences (Myers), as the runs of lines that differ.
74
+ * Null once the work or the number of edited lines passes its limit.
75
+ */
76
+ function diffLineHunks(before: Int32Array, after: Int32Array): LineHunk[] | null {
77
+ const rows = before.length;
78
+ const columns = after.length;
79
+ const maxEdits = Math.min(rows + columns, DIFF_EDIT_LIMIT);
80
+ const base = maxEdits + 1;
81
+ const frontier = new Int32Array(2 * base + 2);
82
+ const trace: Int32Array[] = [];
83
+ let work = 0;
84
+ let found = -1;
85
+ for (let edits = 0; edits <= maxEdits && found < 0; edits++) {
86
+ trace.push(frontier.slice());
87
+ for (let diagonal = -edits; diagonal <= edits; diagonal += 2) {
88
+ let x: number;
89
+ if (diagonal === -edits || (diagonal !== edits && frontier[base + diagonal - 1]! < frontier[base + diagonal + 1]!)) {
90
+ x = frontier[base + diagonal + 1]!;
91
+ } else {
92
+ x = frontier[base + diagonal - 1]! + 1;
93
+ }
94
+ let y = x - diagonal;
95
+ while (x < rows && y < columns && before[x] === after[y]) {
96
+ x++;
97
+ y++;
98
+ work++;
99
+ }
100
+ work++;
101
+ frontier[base + diagonal] = x;
102
+ if (x >= rows && y >= columns) {
103
+ found = edits;
104
+ break;
105
+ }
106
+ }
107
+ if (work > DIFF_WORK_LIMIT) {
108
+ return null;
109
+ }
110
+ }
111
+ if (found < 0) {
112
+ return null;
113
+ }
114
+ const operations: { kind: 'delete' | 'insert'; x: number; y: number }[] = [];
115
+ let x = rows;
116
+ let y = columns;
117
+ for (let edits = found; edits > 0; edits--) {
118
+ const previous = trace[edits]!;
119
+ const diagonal = x - y;
120
+ const fromAbove = diagonal === -edits || (diagonal !== edits && previous[base + diagonal - 1]! < previous[base + diagonal + 1]!);
121
+ const previousDiagonal = fromAbove ? diagonal + 1 : diagonal - 1;
122
+ const previousX = previous[base + previousDiagonal]!;
123
+ const previousY = previousX - previousDiagonal;
124
+ while (x > previousX && y > previousY) {
125
+ x--;
126
+ y--;
127
+ }
128
+ if (fromAbove) {
129
+ y--;
130
+ operations.push({ kind: 'insert', x, y });
131
+ } else {
132
+ x--;
133
+ operations.push({ kind: 'delete', x, y });
134
+ }
135
+ }
136
+ operations.reverse();
137
+ const hunks: LineHunk[] = [];
138
+ let open: { beforeFrom: number; afterFrom: number; beforeTo: number; afterTo: number } | null = null;
139
+ for (const operation of operations) {
140
+ if (open !== null && open.beforeTo === operation.x && open.afterTo === operation.y) {
141
+ if (operation.kind === 'delete') {
142
+ open.beforeTo++;
143
+ } else {
144
+ open.afterTo++;
145
+ }
146
+ continue;
147
+ }
148
+ if (open !== null) {
149
+ hunks.push(open);
150
+ }
151
+ open = {
152
+ beforeFrom: operation.x,
153
+ afterFrom: operation.y,
154
+ beforeTo: operation.x + (operation.kind === 'delete' ? 1 : 0),
155
+ afterTo: operation.y + (operation.kind === 'insert' ? 1 : 0)
156
+ };
157
+ }
158
+ if (open !== null) {
159
+ hunks.push(open);
160
+ }
161
+ return hunks;
162
+ }
163
+
164
+ /*
165
+ * The stretches that differ between two texts, found by a line diff with a bounded cost, in the
166
+ * coordinates of `before`. Applied as one batch they keep every cursor, fold, marker and the scroll
167
+ * wherever the text around them stayed, also between two far apart changes, where the one span of
168
+ * `changedSpan` would cover everything in between. A rewrite that passes the bounds is that one span.
169
+ */
170
+ export function changedSpans(before: string, after: string): TextSpan[] {
171
+ const outer = changedSpan(before, after);
172
+ if (outer === null) {
173
+ return [];
174
+ }
175
+ const afterEnd = after.length - (before.length - outer.end);
176
+ if (outer.end - outer.start + (afterEnd - outer.start) > DIFF_TEXT_LIMIT) {
177
+ return [outer];
178
+ }
179
+ // Whole lines on both sides of the change, so the diff compares lines and not their halves.
180
+ const lineStart = outer.start === 0 ? 0 : before.lastIndexOf('\n', outer.start - 1) + 1;
181
+ const lineEnd = (at: string, from: number): number => {
182
+ const newline = at.indexOf('\n', from);
183
+ return newline < 0 ? at.length : newline + 1;
184
+ };
185
+ const beforeEnd = lineEnd(before, outer.end > outer.start ? outer.end - 1 : outer.end);
186
+ const afterLineEnd = beforeEnd - outer.end + afterEnd;
187
+ const beforeLines = splitLines(before.slice(lineStart, beforeEnd));
188
+ const afterLines = splitLines(after.slice(lineStart, afterLineEnd));
189
+ if (beforeLines.length < 2 && afterLines.length < 2) {
190
+ return [outer];
191
+ }
192
+ const ids = new Map<string, number>();
193
+ const intern = (lines: string[]): Int32Array => {
194
+ const result = new Int32Array(lines.length);
195
+ for (let i = 0; i < lines.length; i++) {
196
+ const line = lines[i]!;
197
+ let id = ids.get(line);
198
+ if (id === undefined) {
199
+ id = ids.size;
200
+ ids.set(line, id);
201
+ }
202
+ result[i] = id;
203
+ }
204
+ return result;
205
+ };
206
+ const hunks = diffLineHunks(intern(beforeLines), intern(afterLines));
207
+ if (hunks === null || hunks.length === 0) {
208
+ return [outer];
209
+ }
210
+ const beforeOffsets = lineOffsets(beforeLines, lineStart);
211
+ const afterOffsets = lineOffsets(afterLines, lineStart);
212
+ const spans: TextSpan[] = [];
213
+ for (const hunk of hunks) {
214
+ const beforeFrom = beforeOffsets[hunk.beforeFrom]!;
215
+ const afterFrom = afterOffsets[hunk.afterFrom]!;
216
+ const inner = changedSpan(before.slice(beforeFrom, beforeOffsets[hunk.beforeTo]), after.slice(afterFrom, afterOffsets[hunk.afterTo]));
217
+ if (inner !== null) {
218
+ spans.push({ start: beforeFrom + inner.start, end: beforeFrom + inner.end, text: inner.text });
219
+ }
220
+ }
221
+ return spans;
222
+ }
223
+
224
+ function lineOffsets(lines: readonly string[], base: number): number[] {
225
+ const offsets = [base];
226
+ for (const line of lines) {
227
+ offsets.push(offsets[offsets.length - 1]! + line.length);
228
+ }
229
+ return offsets;
230
+ }
package/src/types.ts ADDED
@@ -0,0 +1,141 @@
1
+ /* Offsets are UTF-16 code units from the start of the document. */
2
+ export interface Selection {
3
+ anchor: number;
4
+ head: number;
5
+ }
6
+
7
+ /* A replacement of `[from, to)`. A batch of edits is simultaneous, in the coordinates of the document before it. */
8
+ export interface TextEdit {
9
+ from: number;
10
+ to: number;
11
+ text: string;
12
+ }
13
+
14
+ /* A replaced range in the coordinates of the document before the change, with the length that took its place. */
15
+ export interface DocumentChange {
16
+ from: number;
17
+ to: number;
18
+ insertedLength: number;
19
+ }
20
+
21
+ /* Zero-based line and UTF-16 column. */
22
+ export interface Position {
23
+ line: number;
24
+ column: number;
25
+ }
26
+
27
+ /* A replacement as a language server's `didChange` wants it: a range of the document as it stands after the edits listed before this one. */
28
+ export interface ContentEdit {
29
+ start: Position;
30
+ end: Position;
31
+ text: string;
32
+ }
33
+
34
+ export interface Disposable {
35
+ dispose(): void;
36
+ }
37
+
38
+ export type ChangeSource = 'input' | 'command' | 'external';
39
+
40
+ export interface EditorSnapshot {
41
+ /* Flattened on first read, so a listener that only needs the selection never pays for it. */
42
+ text: string;
43
+ selections: readonly Selection[];
44
+ revision: number;
45
+ canUndo: boolean;
46
+ canRedo: boolean;
47
+ /* One entry per transaction, present when the text changed. */
48
+ changes?: readonly (readonly DocumentChange[])[];
49
+ /* The same change in order, as a sequence a language server can follow; present when the text changed. */
50
+ contentEdits?: readonly ContentEdit[];
51
+ source?: ChangeSource;
52
+ }
53
+
54
+ export interface EditOptions {
55
+ /* Selections after the edit. Without them the current ones are mapped through the edits. */
56
+ selections?: readonly Selection[];
57
+ source?: ChangeSource;
58
+ /* Edits with the same group join one undo step until a selection change or an undo closes it. */
59
+ historyGroup?: string;
60
+ }
61
+
62
+ export type EditorCommand =
63
+ | 'undo'
64
+ | 'redo'
65
+ | 'selectAll'
66
+ | 'smartHome'
67
+ | 'smartEnd'
68
+ | 'selectSmartHome'
69
+ | 'selectSmartEnd'
70
+ | 'wordLeft'
71
+ | 'wordRight'
72
+ | 'selectWordLeft'
73
+ | 'selectWordRight'
74
+ | 'deleteWordLeft'
75
+ | 'deleteWordRight'
76
+ | 'camelLeft'
77
+ | 'camelRight'
78
+ | 'selectCamelLeft'
79
+ | 'selectCamelRight'
80
+ | 'deleteCamelLeft'
81
+ | 'deleteCamelRight'
82
+ | 'expandSelection'
83
+ | 'shrinkSelection'
84
+ | 'smartBackspace'
85
+ | 'deleteForward'
86
+ | 'duplicateLine'
87
+ | 'deleteLine'
88
+ | 'moveLineUp'
89
+ | 'moveLineDown'
90
+ | 'toggleLineComment'
91
+ | 'toggleBlockComment'
92
+ | 'insertTab'
93
+ | 'indent'
94
+ | 'outdent'
95
+ | 'insertNewline'
96
+ | 'startNewLine'
97
+ | 'startNewLineBefore'
98
+ | 'splitLine'
99
+ | 'joinLines'
100
+ | 'toggleCase'
101
+ | 'autoIndentLines'
102
+ | 'addCaretAbove'
103
+ | 'addCaretBelow'
104
+ | 'selectNextOccurrence'
105
+ | 'unselectOccurrence'
106
+ | 'selectAllOccurrences'
107
+ | 'addCaretPerSelectedLine';
108
+
109
+ export interface CommandOptions {
110
+ /* Clamped to 1 through 16. */
111
+ tabSize?: number;
112
+ insertSpaces?: boolean;
113
+ /* Replaces the line comment marker of the language. */
114
+ commentToken?: string;
115
+ /* Whether the plain word commands stop at camel humps. Off unless true; the explicit camel commands always do. */
116
+ camelCase?: boolean;
117
+ /* Turns pairing off for brackets and quotes at once; the two below turn off one of them. */
118
+ autoClosingPairs?: boolean;
119
+ autoClosingBrackets?: boolean;
120
+ autoClosingQuotes?: boolean;
121
+ /* A bracket or quote typed over a selection wraps it. On unless false. */
122
+ surroundSelection?: boolean;
123
+ /* A language id such as `typescript` or `php`; it decides what counts as a comment or a string. */
124
+ language?: string;
125
+ smartSemicolon?: boolean;
126
+ /* Tab steps over a closer the editor inserted. On unless false. */
127
+ tabOutOfClosers?: boolean;
128
+ /* Enter computes the indentation of the new line, continues comments and closes braces. On unless false. */
129
+ smartEnter?: boolean;
130
+ /* A pasted block moves to the indentation of the line it lands on. On unless false. */
131
+ indentOnPaste?: boolean;
132
+ /* Where the row of the screen an offset is on starts and ends, so Home and End go by wrapped rows. Without it a line is one row. */
133
+ visualLine?: (offset: number) => { start: number; end: number };
134
+ /*
135
+ * The lines that stand on one row of the screen: a collapsed fold is a row, from its first line to its last.
136
+ * Line commands act on the whole row, a word move treats it as one stop and an added caret skips its inside.
137
+ */
138
+ lineSpan?: (line: number) => { first: number; last: number };
139
+ /* Told which lines a move went to (`from` before, `to` after), so a host can take what it keeps by line along. */
140
+ onLinesMoved?: (moves: readonly { from: number; to: number }[]) => void;
141
+ }
@@ -0,0 +1,329 @@
1
+ import {
2
+ hasBlockComments,
3
+ hasDashComments,
4
+ hasHashComments,
5
+ hashNeedsWordStart,
6
+ hasMarkupComments,
7
+ hasSlashComments,
8
+ isPhp,
9
+ isScript,
10
+ isWordCharacter,
11
+ openers,
12
+ startsExpression,
13
+ startsExpressionAfter,
14
+ startsWord
15
+ } from './lexical.ts';
16
+ import type { DocumentLine } from './rope.ts';
17
+
18
+ export interface TypingBracket {
19
+ at: number;
20
+ close: string;
21
+ previous?: TypingBracket;
22
+ /* An `${` whose closer resumes the template text. */
23
+ template?: boolean;
24
+ /* The parenthesis of `if`, `for` and the like. */
25
+ control?: boolean;
26
+ /* A `{` that opens a block, as opposed to an object literal. */
27
+ block?: boolean;
28
+ }
29
+
30
+ /* `html` is the markup of a PHP file around its tags, and `heredoc` the body of a heredoc or nowdoc, which `quote` holds the label of. */
31
+ type Mode = 'code' | 'line-comment' | 'block-comment' | 'html-comment' | 'quote' | 'template' | 'regex' | 'html' | 'heredoc';
32
+
33
+ /* What the lexer knows at one offset. Bracket stacks are immutable, so states share them. */
34
+ export interface TypingContext {
35
+ mode: Mode;
36
+ quote?: string;
37
+ escaped: boolean;
38
+ inClass: boolean;
39
+ bracket?: TypingBracket;
40
+ /* Whether an expression may start here, which makes a `/` a regex. */
41
+ expression: boolean;
42
+ /* The last word, or `control-close` after the parenthesis of a control statement. */
43
+ word: string;
44
+ /* Where the comment the lexer is in began, in the document. */
45
+ commentStart?: number;
46
+ }
47
+
48
+ interface Rules {
49
+ script: boolean;
50
+ php: boolean;
51
+ hash: boolean;
52
+ hashAtWordStart: boolean;
53
+ dash: boolean;
54
+ slash: boolean;
55
+ block: boolean;
56
+ markup: boolean;
57
+ }
58
+
59
+ const PHP_HEREDOC = /^<<<[ \t]*(["']?)([A-Za-z_]\w*)\1[ \t]*$/;
60
+ const PHP_OPEN = /<\?(?:php\b|=)/;
61
+ const controlWords = /^(for|for-await|if|while|switch|catch|with)$/;
62
+ const blockWords = /^(else|try|finally|do)$/;
63
+
64
+ function initial(): TypingContext {
65
+ return { mode: 'code', escaped: false, inClass: false, expression: true, word: '' };
66
+ }
67
+
68
+ function rulesOf(language: string): Rules {
69
+ return {
70
+ script: isScript(language),
71
+ php: isPhp(language),
72
+ hash: hasHashComments(language),
73
+ hashAtWordStart: hashNeedsWordStart(language),
74
+ dash: hasDashComments(language),
75
+ slash: hasSlashComments(language),
76
+ block: hasBlockComments(language),
77
+ markup: hasMarkupComments(language)
78
+ };
79
+ }
80
+
81
+ function scanComment(state: TypingContext, text: string, at: number, end: number): number {
82
+ const close = state.mode === 'block-comment' ? '*/' : '-->';
83
+ if (at + close.length <= end && text.startsWith(close, at)) {
84
+ state.mode = 'code';
85
+ state.commentStart = undefined;
86
+ return at + close.length - 1;
87
+ }
88
+ return at;
89
+ }
90
+
91
+ function scanLiteral(state: TypingContext, text: string, at: number, end: number, offset: number): number {
92
+ const char = text[at];
93
+ if (state.escaped) {
94
+ state.escaped = false;
95
+ } else if (char === '\\') {
96
+ state.escaped = true;
97
+ } else if (state.mode === 'regex') {
98
+ if (char === '[') {
99
+ state.inClass = true;
100
+ } else if (char === ']') {
101
+ state.inClass = false;
102
+ } else if (char === '/' && !state.inClass) {
103
+ state.mode = 'code';
104
+ state.expression = false;
105
+ }
106
+ } else if (state.mode === 'template' && char === '$' && text[at + 1] === '{' && at + 1 < end) {
107
+ state.bracket = { at: offset + at + 1, close: '}', previous: state.bracket, template: true };
108
+ state.mode = 'code';
109
+ state.expression = true;
110
+ state.word = '';
111
+ return at + 1;
112
+ } else if (char === state.quote) {
113
+ state.mode = 'code';
114
+ state.expression = false;
115
+ state.quote = undefined;
116
+ }
117
+ return at;
118
+ }
119
+
120
+ function scanCode(state: TypingContext, rules: Rules, text: string, at: number, end: number, offset: number): number {
121
+ const char = text[at];
122
+ const next = text[at + 1];
123
+
124
+ if (/\s/u.test(char)) {
125
+ return at;
126
+ }
127
+ if (rules.markup && text.startsWith('<!--', at)) {
128
+ state.mode = 'html-comment';
129
+ state.commentStart = offset + at;
130
+ return at + 3;
131
+ }
132
+ if (rules.hash && char === '#' && !(rules.php && next === '[') && (!rules.hashAtWordStart || startsWord(text, at))) {
133
+ state.mode = 'line-comment';
134
+ state.commentStart = offset + at;
135
+ return at;
136
+ }
137
+ if (rules.dash && char === '-' && next === '-') {
138
+ state.mode = 'line-comment';
139
+ state.commentStart = offset + at;
140
+ return at + 1;
141
+ }
142
+ if (rules.slash && char === '/' && next === '/') {
143
+ state.mode = 'line-comment';
144
+ state.commentStart = offset + at;
145
+ return at + 1;
146
+ }
147
+ if (rules.block && char === '/' && next === '*') {
148
+ state.mode = 'block-comment';
149
+ state.commentStart = offset + at;
150
+ return at + 1;
151
+ }
152
+ if (rules.php && char === '<' && text.startsWith('<<<', at)) {
153
+ const opener = PHP_HEREDOC.exec(text.slice(at));
154
+ if (opener !== null) {
155
+ state.mode = 'heredoc';
156
+ state.quote = opener[2];
157
+ return text.length;
158
+ }
159
+ }
160
+ if (rules.php && char === '?' && next === '>') {
161
+ state.mode = 'html';
162
+ return at + 1;
163
+ }
164
+ if (char === '"' || char === "'" || char === '`') {
165
+ state.mode = rules.script && char === '`' ? 'template' : 'quote';
166
+ state.quote = char;
167
+ state.escaped = false;
168
+ state.word = '';
169
+ return at;
170
+ }
171
+ if (rules.script && char === '/' && state.expression) {
172
+ state.mode = 'regex';
173
+ state.inClass = false;
174
+ state.escaped = false;
175
+ return at;
176
+ }
177
+ if ((char === '+' || char === '-') && next === char) {
178
+ state.word = '';
179
+ return at + 1;
180
+ }
181
+ if (openers[char]) {
182
+ state.bracket = {
183
+ at: offset + at,
184
+ close: openers[char],
185
+ previous: state.bracket,
186
+ control: char === '(' && controlWords.test(state.word),
187
+ block: char === '{' && (!state.expression || state.word === 'control-close' || blockWords.test(state.word))
188
+ };
189
+ state.expression = true;
190
+ state.word = '';
191
+ } else if (/[)\]}]/.test(char)) {
192
+ closeBracket(state, char);
193
+ } else if (isWordCharacter(char)) {
194
+ const from = at;
195
+ while (at + 1 < end && isWordCharacter(text[at + 1])) {
196
+ at++;
197
+ }
198
+ const word = text.slice(from, at + 1);
199
+ state.word = word === 'await' && state.word === 'for' ? 'for-await' : word;
200
+ state.expression = startsExpression(word);
201
+ } else {
202
+ state.expression = startsExpressionAfter(char);
203
+ state.word = '';
204
+ }
205
+ return at;
206
+ }
207
+
208
+ function closeBracket(state: TypingContext, char: string): void {
209
+ const bracket = state.bracket;
210
+ if (bracket?.close !== char) {
211
+ state.bracket = undefined;
212
+ state.expression = false;
213
+ return;
214
+ }
215
+ state.bracket = bracket.previous;
216
+ if (bracket.template) {
217
+ state.mode = 'template';
218
+ state.quote = '`';
219
+ }
220
+ state.expression = Boolean(bracket.control || bracket.block);
221
+ state.word = bracket.control ? 'control-close' : '';
222
+ }
223
+
224
+ /* Lexes `text` up to `end`, from the state the line started in. `offset` is where the line starts in the document. */
225
+ function scan(text: string, end: number, start: TypingContext, rules: Rules, offset: number): TypingContext {
226
+ const state = { ...start };
227
+ for (let at = 0; at < end; at++) {
228
+ if (state.mode === 'line-comment') {
229
+ // A PHP line comment ends at its line break and at a closing tag.
230
+ const tag = rules.php ? text.indexOf('?>', at) : -1;
231
+ if (tag < 0 || tag >= end) {
232
+ break;
233
+ }
234
+ state.mode = 'html';
235
+ state.commentStart = undefined;
236
+ at = tag + 1;
237
+ } else if (state.mode === 'html') {
238
+ const open = PHP_OPEN.exec(text.slice(at));
239
+ if (open === null || at + open.index >= end) {
240
+ break;
241
+ }
242
+ state.mode = 'code';
243
+ state.expression = true;
244
+ state.word = '';
245
+ at += open.index + open[0].length - 1;
246
+ } else if (state.mode === 'heredoc') {
247
+ const closing = new RegExp(`^[\\t ]*${state.quote}(?![\\w])`).exec(text);
248
+ if (closing === null) {
249
+ break;
250
+ }
251
+ state.mode = 'code';
252
+ state.quote = undefined;
253
+ state.expression = false;
254
+ at = closing[0].length - 1;
255
+ } else if (state.mode === 'block-comment' || state.mode === 'html-comment') {
256
+ at = scanComment(state, text, at, end);
257
+ } else if (state.mode === 'code') {
258
+ at = scanCode(state, rules, text, at, end, offset);
259
+ } else {
260
+ at = scanLiteral(state, text, at, end, offset);
261
+ }
262
+ }
263
+ return state;
264
+ }
265
+
266
+ function sameState(left: TypingContext, right: TypingContext): boolean {
267
+ return (
268
+ left.mode === right.mode &&
269
+ left.quote === right.quote &&
270
+ left.escaped === right.escaped &&
271
+ left.inClass === right.inClass &&
272
+ left.bracket === right.bracket &&
273
+ left.expression === right.expression &&
274
+ left.word === right.word &&
275
+ left.commentStart === right.commentStart
276
+ );
277
+ }
278
+
279
+ /* The state of the lexer at the start of each line, computed on demand. An edit drops the lines from its own on. */
280
+ export class TypingContexts {
281
+ private readonly line: (index: number) => DocumentLine;
282
+ private states: TypingContext[] = [initial()];
283
+ private language = '';
284
+
285
+ constructor(line: (index: number) => DocumentLine) {
286
+ this.line = line;
287
+ }
288
+
289
+ invalidate(line = 0): void {
290
+ this.states.length = Math.min(this.states.length, Math.max(1, line + 1));
291
+ if (line === 0) {
292
+ this.language = '';
293
+ }
294
+ }
295
+
296
+ /* A PHP file that opens with markup, and not with a tag, starts outside the PHP. */
297
+ private initialState(language: string): TypingContext {
298
+ const state = initial();
299
+ if (language === 'php' && /^\s*<(?![?<])/.test(this.line(0).text)) {
300
+ state.mode = 'html';
301
+ }
302
+ return state;
303
+ }
304
+
305
+ at(line: number, column: number, language: string): TypingContext {
306
+ const id = language.toLowerCase();
307
+ if (this.language !== id) {
308
+ this.language = id;
309
+ this.states = [this.initialState(id)];
310
+ }
311
+ const rules = rulesOf(id);
312
+ while (this.states.length <= line) {
313
+ const previous = this.states.at(-1)!;
314
+ const source = this.line(this.states.length - 1);
315
+ const next = scan(source.text, source.text.length, previous, rules, source.start);
316
+ // Only PHP and Rust strings run past a line break without a trailing backslash.
317
+ if (next.mode === 'line-comment' || next.mode === 'regex' || (next.mode === 'quote' && !/^(php|rust|rs)$/i.test(id) && !next.escaped)) {
318
+ next.mode = 'code';
319
+ next.quote = undefined;
320
+ next.commentStart = undefined;
321
+ next.expression = true;
322
+ }
323
+ next.escaped = false;
324
+ this.states.push(sameState(next, previous) ? previous : next);
325
+ }
326
+ const source = this.line(line);
327
+ return scan(source.text, column, this.states[line], rules, source.start);
328
+ }
329
+ }