@adecore/editor-core 0.23.1 → 0.24.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.
@@ -0,0 +1,321 @@
1
+ import {
2
+ type BracketMatch,
3
+ type BracketRules,
4
+ type BracketState,
5
+ type BracketVisitor,
6
+ bracketRules,
7
+ initialBracketState,
8
+ lexBrackets,
9
+ sameBracketState
10
+ } from './brackets.ts';
11
+ import type { TextRope } from './rope.ts';
12
+ import type { DocumentChange } from './types.ts';
13
+
14
+ /* What the tracker reads of the document. */
15
+ export type BracketText = Pick<TextRope, 'length' | 'lineCount' | 'charAt' | 'lineAt' | 'lineBounds' | 'slice'>;
16
+
17
+ /* How much text one stretch between checkpoints holds, which is what an edit makes the tracker lex again. */
18
+ const STRETCH_LENGTH = 4096;
19
+
20
+ /* The lexer's state at the start of a line, and what it went through from there to the next checkpoint. */
21
+ interface Checkpoint {
22
+ line: number;
23
+ state: Readonly<BracketState>;
24
+ /* The fewest brackets open anywhere from this line to the next checkpoint. */
25
+ low: number;
26
+ /* An edit, or a state that turned out different, left `low` and the state of the next checkpoint unproven. */
27
+ stale: boolean;
28
+ }
29
+
30
+ /* The lines an edit replaced: `first` through `oldLast` before it, `first` through `newLast` after it. */
31
+ interface LineEdit {
32
+ first: number;
33
+ oldLast: number;
34
+ newLast: number;
35
+ }
36
+
37
+ interface BracketEvent {
38
+ kind: 'open' | 'close' | 'mismatch';
39
+ depth: number;
40
+ /* The mate, when the lexed stretch already holds it; null when it shows there is none. */
41
+ mate?: number | null;
42
+ }
43
+
44
+ const IGNORE: BracketVisitor = {
45
+ open() {},
46
+ close() {},
47
+ mismatch() {}
48
+ };
49
+
50
+ /*
51
+ * Pairs the bracket at an offset without lexing the document for it. The lexer's state is kept at checkpoints a
52
+ * few kilobytes apart, so an edit lexes only the stretch it is in, and the checkpoints after it count again as
53
+ * soon as the state at one of them comes out the same. Each checkpoint also knows how few brackets were open
54
+ * until the next, which lets a search for a far mate step over whole stretches.
55
+ */
56
+ export class BracketTracker {
57
+ private readonly text: () => BracketText;
58
+ private readonly stretchLength: number;
59
+ private language = '';
60
+ private rules: BracketRules = bracketRules('');
61
+ private checkpoints: Checkpoint[] = [];
62
+ /* The first stale checkpoint: what comes before it is proven, and so is its state. */
63
+ private proven = 0;
64
+ /* How much more the current question may lex. */
65
+ private budget = Number.POSITIVE_INFINITY;
66
+
67
+ constructor(text: () => BracketText, stretchLength = STRETCH_LENGTH) {
68
+ this.text = text;
69
+ this.stretchLength = stretchLength;
70
+ }
71
+
72
+ /* Follows simultaneous changes that turned `before` into `after`, in the coordinates of `before`. */
73
+ edited(before: BracketText, after: BracketText, changes: readonly DocumentChange[]): void {
74
+ if (this.checkpoints.length === 0 || changes.length === 0) {
75
+ return;
76
+ }
77
+ const edits: LineEdit[] = [];
78
+ let delta = 0;
79
+ for (const change of changes) {
80
+ // The line above too, since an edit at the start of a line can turn the `\r` that ends it into a `\r\n`.
81
+ const first = Math.max(0, before.lineAt(change.from) - 1);
82
+ const oldLast = before.lineAt(change.to);
83
+ const newLast = after.lineAt(change.from + delta + change.insertedLength);
84
+ delta += change.insertedLength - (change.to - change.from);
85
+ const previous = edits.at(-1);
86
+ if (previous !== undefined && first <= previous.oldLast) {
87
+ previous.oldLast = oldLast;
88
+ previous.newLast = newLast;
89
+ } else {
90
+ edits.push({ first, oldLast, newLast });
91
+ }
92
+ }
93
+ const kept: Checkpoint[] = [];
94
+ let index = 0;
95
+ let shift = 0;
96
+ for (const checkpoint of this.checkpoints) {
97
+ let dropped = false;
98
+ while (index < edits.length && checkpoint.line > edits[index]!.first) {
99
+ kept.at(-1)!.stale = true;
100
+ if (checkpoint.line <= edits[index]!.oldLast) {
101
+ dropped = true;
102
+ break;
103
+ }
104
+ shift = edits[index]!.newLast - edits[index]!.oldLast;
105
+ index++;
106
+ }
107
+ if (!dropped) {
108
+ checkpoint.line += shift;
109
+ kept.push(checkpoint);
110
+ }
111
+ }
112
+ if (index < edits.length) {
113
+ kept.at(-1)!.stale = true;
114
+ }
115
+ this.checkpoints = kept;
116
+ this.proven = this.nextStale(0);
117
+ }
118
+
119
+ /*
120
+ * The mate of the bracket at `offset`. Undefined where no bracket of the code stands, as in a string or a comment,
121
+ * and null when answering takes lexing more than `limit` characters; the work is kept, so asking again goes on.
122
+ */
123
+ match(offset: number, language: string, limit = Number.POSITIVE_INFINITY): BracketMatch | null | undefined {
124
+ const rope = this.text();
125
+ if (!/^[()[\]{}]$/.test(rope.charAt(offset))) {
126
+ return undefined;
127
+ }
128
+ this.use(language);
129
+ this.budget = limit;
130
+ const line = rope.lineAt(offset);
131
+ if (!this.prove(line)) {
132
+ return null;
133
+ }
134
+ const index = this.stretchOf(line);
135
+ const event = this.eventAt(index, offset);
136
+ if (event === undefined) {
137
+ return undefined;
138
+ }
139
+ if (event.kind === 'mismatch' || event.mate === null) {
140
+ return { mate: undefined };
141
+ }
142
+ if (event.mate !== undefined) {
143
+ return { mate: event.mate };
144
+ }
145
+ const mate = event.kind === 'open' ? this.closerAfter(index, event.depth) : this.openerBefore(index, event.depth);
146
+ return mate === null ? null : { mate };
147
+ }
148
+
149
+ private use(language: string): void {
150
+ if (language === this.language && this.checkpoints.length > 0) {
151
+ return;
152
+ }
153
+ this.language = language;
154
+ this.rules = bracketRules(language);
155
+ this.checkpoints = [{ line: 0, state: initialBracketState(), low: 0, stale: true }];
156
+ this.proven = 0;
157
+ }
158
+
159
+ private nextStale(from: number): number {
160
+ let index = from;
161
+ while (index < this.checkpoints.length && !this.checkpoints[index]!.stale) {
162
+ index++;
163
+ }
164
+ return index;
165
+ }
166
+
167
+ /* Lexes until the stretch that holds `line` is proven, or the budget is spent. */
168
+ private prove(line: number): boolean {
169
+ while (this.proven < this.checkpoints.length && this.checkpoints[this.proven]!.line <= line) {
170
+ if (this.budget <= 0) {
171
+ return false;
172
+ }
173
+ this.relex(this.proven, line);
174
+ }
175
+ return true;
176
+ }
177
+
178
+ /* Lexes the stale stretch at `index` again, cut into new ones, and stops early once it is past `until`. */
179
+ private relex(index: number, until: number): void {
180
+ const rope = this.text();
181
+ const next = this.checkpoints[index + 1];
182
+ const stop = next?.line ?? rope.lineCount;
183
+ const state = { ...this.checkpoints[index]!.state };
184
+ const added: Checkpoint[] = [];
185
+ let current = this.checkpoints[index]!;
186
+ let line = current.line;
187
+ for (;;) {
188
+ const from = rope.lineBounds(line).start;
189
+ const end = Math.min(stop, from + this.stretchLength >= rope.length ? rope.lineCount : Math.max(line + 1, rope.lineAt(from + this.stretchLength)));
190
+ const to = end >= rope.lineCount ? rope.length : rope.lineBounds(end).start;
191
+ let low = state.stack?.depth ?? 0;
192
+ this.budget -= to - from;
193
+ lexBrackets(state, this.rules, rope.slice(from, to), 0, to - from, from, {
194
+ open() {},
195
+ close(_at, depth) {
196
+ low = Math.min(low, depth - 1);
197
+ },
198
+ mismatch() {
199
+ low = 0;
200
+ }
201
+ });
202
+ current.low = low;
203
+ current.stale = false;
204
+ if (end === stop) {
205
+ if (next !== undefined && !sameBracketState(state, next.state)) {
206
+ next.state = state;
207
+ next.stale = true;
208
+ }
209
+ break;
210
+ }
211
+ current = { line: end, state: { ...state }, low: 0, stale: true };
212
+ added.push(current);
213
+ line = end;
214
+ if (end > until || this.budget <= 0) {
215
+ break;
216
+ }
217
+ }
218
+ this.checkpoints.splice(index + 1, 0, ...added);
219
+ this.proven = this.nextStale(index + 1);
220
+ }
221
+
222
+ private stretchOf(line: number): number {
223
+ let low = 0;
224
+ let high = this.checkpoints.length - 1;
225
+ while (low < high) {
226
+ const middle = Math.ceil((low + high) / 2);
227
+ if (this.checkpoints[middle]!.line <= line) {
228
+ low = middle;
229
+ } else {
230
+ high = middle - 1;
231
+ }
232
+ }
233
+ return low;
234
+ }
235
+
236
+ private lexStretch(index: number, visitor: BracketVisitor): void {
237
+ const rope = this.text();
238
+ const from = rope.lineBounds(this.checkpoints[index]!.line).start;
239
+ const next = this.checkpoints[index + 1];
240
+ const to = next === undefined ? rope.length : rope.lineBounds(next.line).start;
241
+ lexBrackets({ ...this.checkpoints[index]!.state }, this.rules, rope.slice(from, to), 0, to - from, from, visitor);
242
+ }
243
+
244
+ /* What the lexer made of the bracket at `offset`, with its mate when the stretch it is in holds that. */
245
+ private eventAt(index: number, offset: number): BracketEvent | undefined {
246
+ let found: BracketEvent | undefined;
247
+ // The openers this stretch opened; the ones from before it are only counted.
248
+ let opened: number[] = [];
249
+ this.lexStretch(index, {
250
+ open(at, depth) {
251
+ opened.push(at);
252
+ if (at === offset) {
253
+ found = { kind: 'open', depth };
254
+ }
255
+ },
256
+ close(at, depth) {
257
+ const opener = opened.pop();
258
+ if (at === offset) {
259
+ found = { kind: 'close', depth, ...(opener === undefined ? {} : { mate: opener }) };
260
+ } else if (found?.kind === 'open' && found.mate === undefined && depth === found.depth) {
261
+ found.mate = at;
262
+ }
263
+ },
264
+ mismatch(at) {
265
+ opened = [];
266
+ if (at === offset) {
267
+ found = { kind: 'mismatch', depth: 0 };
268
+ } else if (found?.kind === 'open' && found.mate === undefined) {
269
+ found.mate = null;
270
+ }
271
+ }
272
+ });
273
+ return found;
274
+ }
275
+
276
+ /* The closer of an opener at `depth` that the stretch at `index` leaves open, undefined when nothing closes it and null when the budget ran out. */
277
+ private closerAfter(index: number, depth: number): number | null | undefined {
278
+ for (let current = index + 1; current < this.checkpoints.length; current++) {
279
+ if (!this.prove(this.checkpoints[current]!.line)) {
280
+ return null;
281
+ }
282
+ if (this.checkpoints[current]!.low >= depth) {
283
+ continue;
284
+ }
285
+ let result: number | false | undefined;
286
+ this.lexStretch(current, {
287
+ open() {},
288
+ close(at, closed) {
289
+ if (result === undefined && closed === depth) {
290
+ result = at;
291
+ }
292
+ },
293
+ mismatch() {
294
+ result ??= false;
295
+ }
296
+ });
297
+ return result === false ? undefined : result;
298
+ }
299
+ return undefined;
300
+ }
301
+
302
+ /* The opener at `depth` that is still open where the stretch at `index` starts. */
303
+ private openerBefore(index: number, depth: number): number | undefined {
304
+ for (let current = index - 1; current >= 0; current--) {
305
+ if (this.checkpoints[current]!.low >= depth) {
306
+ continue;
307
+ }
308
+ let result: number | undefined;
309
+ this.lexStretch(current, {
310
+ ...IGNORE,
311
+ open(at, opened) {
312
+ if (opened === depth) {
313
+ result = at;
314
+ }
315
+ }
316
+ });
317
+ return result;
318
+ }
319
+ return undefined;
320
+ }
321
+ }
package/src/brackets.ts CHANGED
@@ -9,8 +9,8 @@ import {
9
9
  isScript,
10
10
  isWordCharacter,
11
11
  openers,
12
- startsExpression,
13
12
  startsExpressionAfter,
13
+ startsExpressionAt,
14
14
  startsWord
15
15
  } from './lexical.ts';
16
16
 
@@ -20,140 +20,243 @@ export interface BracketIndex {
20
20
  unmatched: ReadonlySet<number>;
21
21
  }
22
22
 
23
- interface OpenBracket {
24
- at: number;
23
+ /* A bracket of the code and its mate, which an unmatched one has none of. */
24
+ export interface BracketMatch {
25
+ mate: number | undefined;
26
+ }
27
+
28
+ /* An open bracket without its offset, so the state at a line stays the same however far an edit above moves it. */
29
+ export interface OpenBracket {
25
30
  close: string;
26
31
  /* An `${` whose closer resumes the template text. */
27
- template?: boolean;
32
+ template: boolean;
33
+ depth: number;
34
+ previous: OpenBracket | undefined;
28
35
  }
29
36
 
30
- function skipLine(text: string, at: number): number {
31
- const end = text.indexOf('\n', at + 1);
32
- return end < 0 ? text.length : end;
37
+ export interface BracketState {
38
+ mode: 'code' | 'line-comment' | 'block-comment' | 'markup-comment' | 'string' | 'template' | 'regex';
39
+ quote: string;
40
+ /* Inside the character class of a regex, where a `/` does not end it. */
41
+ inClass: boolean;
42
+ /* Whether an expression may start here, which makes a `/` a regex. */
43
+ expression: boolean;
44
+ stack: OpenBracket | undefined;
33
45
  }
34
46
 
35
- function skipComment(text: string, from: number, terminator: string): number {
36
- const end = text.indexOf(terminator, from);
37
- return end < 0 ? text.length : end + terminator.length - 1;
47
+ export interface BracketRules {
48
+ script: boolean;
49
+ hash: boolean;
50
+ hashAtWordStart: boolean;
51
+ dash: boolean;
52
+ phpAttributes: boolean;
53
+ slash: boolean;
54
+ block: boolean;
55
+ markup: boolean;
38
56
  }
39
57
 
40
- function skipString(text: string, at: number, quote: string, endsAtLineBreak: boolean): number {
41
- let cursor = at + 1;
42
- for (; cursor < text.length; cursor++) {
43
- if (text[cursor] === '\\') {
44
- cursor++;
45
- } else if (text[cursor] === quote || (endsAtLineBreak && /[\r\n]/.test(text[cursor]))) {
46
- break;
47
- }
48
- }
49
- return cursor;
58
+ export interface BracketVisitor {
59
+ open(at: number, depth: number): void;
60
+ /* `depth` counts the opener it closes. */
61
+ close(at: number, depth: number): void;
62
+ /* A closer that fits no opener, which gives up every open bracket. */
63
+ mismatch(at: number): void;
64
+ }
65
+
66
+ export function initialBracketState(): BracketState {
67
+ return { mode: 'code', quote: '', inClass: false, expression: true, stack: undefined };
68
+ }
69
+
70
+ export function bracketRules(language: string): BracketRules {
71
+ return {
72
+ script: isScript(language),
73
+ hash: hasHashComments(language),
74
+ hashAtWordStart: hashNeedsWordStart(language),
75
+ dash: hasDashComments(language),
76
+ phpAttributes: isPhp(language),
77
+ slash: hasSlashComments(language),
78
+ block: hasBlockComments(language),
79
+ markup: hasMarkupComments(language)
80
+ };
50
81
  }
51
82
 
52
- function skipRegex(text: string, at: number): number {
53
- let inClass = false;
54
- let cursor = at + 1;
55
- for (; cursor < text.length; cursor++) {
56
- const character = text[cursor];
57
- if (character === '\\') {
58
- cursor++;
59
- } else if (character === '[') {
60
- inClass = true;
61
- } else if (character === ']') {
62
- inClass = false;
63
- } else if ((character === '/' && !inClass) || /[\r\n]/.test(character)) {
64
- break;
83
+ export function sameBracketState(left: BracketState, right: BracketState): boolean {
84
+ if (left.mode !== right.mode || left.quote !== right.quote || left.inClass !== right.inClass || left.expression !== right.expression) {
85
+ return false;
86
+ }
87
+ let one = left.stack;
88
+ let other = right.stack;
89
+ while (one !== other) {
90
+ if (one === undefined || other === undefined || one.close !== other.close || one.template !== other.template || one.depth !== other.depth) {
91
+ return false;
65
92
  }
93
+ one = one.previous;
94
+ other = other.previous;
66
95
  }
67
- return cursor;
96
+ return true;
68
97
  }
69
98
 
70
- /* Lexical pairing for incomplete code; malformed nesting never produces crossing pairs. */
71
- export function scanBrackets(text: string, language = 'typescript'): BracketIndex {
72
- const pairs = new Map<number, number>();
73
- const unmatched = new Set<number>();
74
- const stack: OpenBracket[] = [];
75
- const script = isScript(language);
76
- const hashComments = hasHashComments(language);
77
- const hashAtWordStart = hashNeedsWordStart(language);
78
- const dashComments = hasDashComments(language);
79
- const phpAttributes = isPhp(language);
80
- const slashComments = hasSlashComments(language);
81
- const blockComments = hasBlockComments(language);
82
- const markupComments = hasMarkupComments(language);
83
- let inTemplate = false;
84
- let expectsExpression = true;
99
+ function isSpace(character: string): boolean {
100
+ const code = character.charCodeAt(0);
101
+ return code === 32 || (code >= 9 && code <= 13) || (code > 127 && /\s/.test(character));
102
+ }
85
103
 
86
- for (let at = 0; at < text.length; at++) {
87
- const char = text[at];
88
- const next = text[at + 1];
104
+ function isCloser(character: string): boolean {
105
+ return character === ')' || character === ']' || character === '}';
106
+ }
89
107
 
90
- if (inTemplate) {
91
- if (char === '\\') {
92
- at++;
93
- } else if (char === '`') {
94
- inTemplate = false;
95
- expectsExpression = false;
96
- } else if (char === '$' && next === '{') {
97
- stack.push({ at: ++at, close: '}', template: true });
98
- inTemplate = false;
99
- expectsExpression = true;
108
+ /* Where a comment that runs to `terminator` ends in `text`, or `to` when it goes on past it. */
109
+ function commentEnd(state: BracketState, text: string, at: number, to: number, terminator: string): number {
110
+ const end = text.indexOf(terminator, at);
111
+ if (end < 0 || end + terminator.length > to) {
112
+ return to;
113
+ }
114
+ state.mode = 'code';
115
+ return end + terminator.length - 1;
116
+ }
117
+
118
+ /*
119
+ * Lexes `text` from `from` to `to`, continuing `state`, and reports every bracket of the code at its offset plus
120
+ * `offset`. A piece has to end at a line break or the end of the text, as nothing here looks past one.
121
+ */
122
+ export function lexBrackets(state: BracketState, rules: BracketRules, text: string, from: number, to: number, offset: number, visitor: BracketVisitor): void {
123
+ for (let at = from; at < to; at++) {
124
+ const char = text[at]!;
125
+ if (state.mode !== 'code') {
126
+ switch (state.mode) {
127
+ case 'line-comment': {
128
+ const end = text.indexOf('\n', at);
129
+ if (end < 0 || end >= to) {
130
+ return;
131
+ }
132
+ state.mode = 'code';
133
+ at = end;
134
+ continue;
135
+ }
136
+ case 'block-comment':
137
+ at = commentEnd(state, text, at, to, '*/');
138
+ continue;
139
+ case 'markup-comment':
140
+ at = commentEnd(state, text, at, to, '-->');
141
+ continue;
142
+ case 'string':
143
+ if (char === '\\') {
144
+ at++;
145
+ } else if (char === state.quote || (rules.script && (char === '\n' || char === '\r'))) {
146
+ state.mode = 'code';
147
+ }
148
+ continue;
149
+ case 'template':
150
+ if (char === '\\') {
151
+ at++;
152
+ } else if (char === '`') {
153
+ state.mode = 'code';
154
+ state.expression = false;
155
+ } else if (char === '$' && text[at + 1] === '{') {
156
+ at++;
157
+ const depth = (state.stack?.depth ?? 0) + 1;
158
+ state.stack = { close: '}', template: true, depth, previous: state.stack };
159
+ state.mode = 'code';
160
+ state.expression = true;
161
+ visitor.open(offset + at, depth);
162
+ }
163
+ continue;
164
+ case 'regex':
165
+ if (char === '\\') {
166
+ at++;
167
+ } else if (char === '[') {
168
+ state.inClass = true;
169
+ } else if (char === ']') {
170
+ state.inClass = false;
171
+ } else if ((char === '/' && !state.inClass) || char === '\n' || char === '\r') {
172
+ state.mode = 'code';
173
+ }
174
+ continue;
100
175
  }
101
176
  continue;
102
177
  }
103
178
 
104
- if (/\s/.test(char)) {
179
+ if (isSpace(char)) {
105
180
  continue;
106
181
  }
107
-
108
- if (markupComments && text.startsWith('<!--', at)) {
109
- at = skipComment(text, at + 4, '-->');
182
+ const next = text[at + 1];
183
+ if (rules.markup && char === '<' && text.startsWith('<!--', at)) {
184
+ state.mode = 'markup-comment';
185
+ at += 3;
110
186
  } else if (
111
- (slashComments && char === '/' && next === '/') ||
112
- (dashComments && char === '-' && next === '-') ||
113
- (hashComments && char === '#' && !(phpAttributes && next === '[') && (!hashAtWordStart || startsWord(text, at)))
187
+ (rules.slash && char === '/' && next === '/') ||
188
+ (rules.dash && char === '-' && next === '-') ||
189
+ (rules.hash && char === '#' && !(rules.phpAttributes && next === '[') && (!rules.hashAtWordStart || startsWord(text, at)))
114
190
  ) {
115
- at = skipLine(text, at);
116
- } else if (blockComments && char === '/' && next === '*') {
117
- at = skipComment(text, at + 2, '*/');
191
+ state.mode = 'line-comment';
192
+ } else if (rules.block && char === '/' && next === '*') {
193
+ state.mode = 'block-comment';
194
+ at++;
118
195
  } else if (char === '"' || char === "'") {
119
- at = skipString(text, at, char, script);
120
- expectsExpression = false;
121
- } else if (script && char === '`') {
122
- inTemplate = true;
123
- } else if (script && char === '/' && expectsExpression) {
124
- at = skipRegex(text, at);
125
- expectsExpression = false;
196
+ state.mode = 'string';
197
+ state.quote = char;
198
+ state.expression = false;
199
+ } else if (rules.script && char === '`') {
200
+ state.mode = 'template';
201
+ } else if (rules.script && char === '/' && state.expression) {
202
+ state.mode = 'regex';
203
+ state.inClass = false;
204
+ state.expression = false;
126
205
  } else if (openers[char]) {
127
- stack.push({ at, close: openers[char] });
128
- expectsExpression = true;
129
- } else if (/[)\]}]/.test(char)) {
130
- const open = stack.at(-1);
206
+ const depth = (state.stack?.depth ?? 0) + 1;
207
+ state.stack = { close: openers[char], template: false, depth, previous: state.stack };
208
+ state.expression = true;
209
+ visitor.open(offset + at, depth);
210
+ } else if (isCloser(char)) {
211
+ const open = state.stack;
131
212
  if (open?.close === char) {
132
- stack.pop();
133
- pairs.set(open.at, at);
134
- pairs.set(at, open.at);
135
- inTemplate = open.template === true;
136
- } else {
137
- unmatched.add(at);
138
- // A mismatched closer keeps a distant opener from claiming it later.
139
- for (const abandoned of stack.splice(0)) {
140
- unmatched.add(abandoned.at);
213
+ state.stack = open.previous;
214
+ if (open.template) {
215
+ state.mode = 'template';
141
216
  }
217
+ visitor.close(offset + at, open.depth);
218
+ } else {
219
+ state.stack = undefined;
220
+ visitor.mismatch(offset + at);
142
221
  }
143
- expectsExpression = false;
222
+ state.expression = false;
144
223
  } else if (isWordCharacter(char)) {
145
224
  const start = at;
146
- while (at + 1 < text.length && isWordCharacter(text[at + 1])) {
225
+ while (at + 1 < to && isWordCharacter(text[at + 1]!)) {
147
226
  at++;
148
227
  }
149
- expectsExpression = startsExpression(text.slice(start, at + 1));
228
+ state.expression = startsExpressionAt(text, start, at + 1);
150
229
  } else {
151
- expectsExpression = startsExpressionAfter(char);
230
+ state.expression = startsExpressionAfter(char);
152
231
  }
153
232
  }
233
+ }
154
234
 
155
- for (const open of stack) {
156
- unmatched.add(open.at);
235
+ /* Lexical pairing for incomplete code; malformed nesting never produces crossing pairs. */
236
+ export function scanBrackets(text: string, language = 'typescript'): BracketIndex {
237
+ const pairs = new Map<number, number>();
238
+ const unmatched = new Set<number>();
239
+ let open: number[] = [];
240
+ lexBrackets(initialBracketState(), bracketRules(language), text, 0, text.length, 0, {
241
+ open(at) {
242
+ open.push(at);
243
+ },
244
+ close(at) {
245
+ const opener = open.pop()!;
246
+ pairs.set(opener, at);
247
+ pairs.set(at, opener);
248
+ },
249
+ mismatch(at) {
250
+ unmatched.add(at);
251
+ // A mismatched closer keeps a distant opener from claiming it later.
252
+ for (const abandoned of open) {
253
+ unmatched.add(abandoned);
254
+ }
255
+ open = [];
256
+ }
257
+ });
258
+ for (const at of open) {
259
+ unmatched.add(at);
157
260
  }
158
261
  return { pairs, unmatched };
159
262
  }