@adecore/editor-core 0.23.0 → 0.24.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/src/document.ts CHANGED
@@ -14,7 +14,8 @@ import { lexicalFolds } from './lexical-folds.ts';
14
14
  import { rangeOf, whitespaceOf } from './line-text.ts';
15
15
  import { isQuote, replacesComparison, surround, swapQuotes } from './typing-handlers.ts';
16
16
  import { scanBrackets } from './brackets.ts';
17
- import type { BracketIndex } from './brackets.ts';
17
+ import type { BracketIndex, BracketMatch } from './brackets.ts';
18
+ import { BracketTracker } from './bracket-tracker.ts';
18
19
  import { hasSmartSemicolon, isPhp } from './lexical.ts';
19
20
  import { clampInteger, TextRope, ROPE_CHUNK_SIZE } from './rope.ts';
20
21
  import type { DocumentLine } from './rope.ts';
@@ -155,7 +156,7 @@ const identifier = /[\p{L}\p{N}\p{M}\p{Pc}$]/u;
155
156
  /* What can follow `->`: the first letter of a member name, or the `{` of a dynamic one. */
156
157
  const arrowContinues = /^[\p{L}_{]/u;
157
158
  const wordCommand = /^(select|delete)?(word|camel)(Left|Right)$/i;
158
- const graphemes = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
159
+ let graphemes: Intl.Segmenter | undefined;
159
160
 
160
161
  function sameSelections(left: readonly Selection[], right: readonly Selection[]): boolean {
161
162
  return left.length === right.length && left.every((selection, index) => selection.anchor === right[index]!.anchor && selection.head === right[index]!.head);
@@ -178,15 +179,35 @@ function selectionTransaction(plans: readonly SelectionEdit[]): SelectionTransac
178
179
  edits.push(plan);
179
180
  }
180
181
  }
182
+ // What the edits before each one add to the text, so a caret finds its shift by a binary search.
183
+ const shifts = [0];
184
+ for (const edit of edits) {
185
+ shifts.push(shifts.at(-1)! + edit.text.length - (edit.to - edit.from));
186
+ }
181
187
  const selections = plans.map((plan) => {
182
- const delta = edits.filter((edit) => edit.from < plan.from).reduce((sum, edit) => sum + edit.text.length - (edit.to - edit.from), 0);
188
+ let low = 0;
189
+ let high = edits.length;
190
+ while (low < high) {
191
+ const middle = (low + high) >>> 1;
192
+ if (edits[middle]!.from < plan.from) {
193
+ low = middle + 1;
194
+ } else {
195
+ high = middle;
196
+ }
197
+ }
198
+ const delta = shifts[low]!;
183
199
  return { anchor: plan.from + delta + plan.anchor, head: plan.from + delta + plan.head };
184
200
  });
185
201
  return { edits, selections };
186
202
  }
187
203
 
188
204
  function splitsCluster(rope: TextRope, offset: number): boolean {
189
- return splitsSurrogate(rope, offset) || (rope.charAt(offset - 1) === '\r' && rope.charAt(offset) === '\n');
205
+ const before = rope.charAt(offset - 1);
206
+ // One read of the rope settles most offsets, which follow neither a `\r` nor the first half of a surrogate pair.
207
+ if (before === '\r') {
208
+ return rope.charAt(offset) === '\n';
209
+ }
210
+ return /[\ud800-\udbff]/.test(before) && splitsSurrogate(rope, offset);
190
211
  }
191
212
 
192
213
  /* Clamps to the document and moves back off the middle of a surrogate pair or a CRLF. */
@@ -217,6 +238,7 @@ function deletionStep(rope: TextRope, offset: number, direction: -1 | 1): number
217
238
  }
218
239
  const line = rope.getLine(rope.lineAt(offset));
219
240
  let previous = line.start;
241
+ graphemes ??= new Intl.Segmenter(undefined, { granularity: 'grapheme' });
220
242
  for (const segment of graphemes.segment(line.text)) {
221
243
  const at = line.start + segment.index;
222
244
  if (direction < 0 && at >= offset) {
@@ -231,33 +253,32 @@ function deletionStep(rope: TextRope, offset: number, direction: -1 | 1): number
231
253
  }
232
254
 
233
255
  /*
234
- * Clamps every selection and merges the ones that overlap or touch, so the result is disjoint. The
235
- * last one stays the primary caret, and a merge that takes it in keeps it last.
256
+ * Clamps every selection and merges the ones that overlap or touch, so the result is disjoint. A merged
257
+ * selection takes the place and the direction of the last added of the ones it holds, so the primary caret stays last.
236
258
  */
237
259
  function normalizeSelections(rope: TextRope, selections: readonly Selection[]): Selection[] {
238
260
  const input = selections.length > 0 ? selections : [{ anchor: 0, head: 0 }];
239
- const result: { selection: Selection; primary: boolean }[] = [];
240
- for (const [position, raw] of input.entries()) {
241
- let selection = { anchor: offsetIn(rope, raw.anchor), head: offsetIn(rope, raw.head) };
242
- let primary = position === input.length - 1;
243
- let { from, to } = rangeOf(selection);
244
- for (let index = result.length - 1; index >= 0; index--) {
245
- const other = rangeOf(result[index]!.selection);
246
- if (from <= other.to && to >= other.from) {
247
- from = Math.min(from, other.from);
248
- to = Math.max(to, other.to);
249
- primary ||= result[index]!.primary;
250
- result.splice(index, 1);
251
- selection = selection.anchor > selection.head ? { anchor: to, head: from } : { anchor: from, head: to };
252
- }
261
+ const clamped = input.map((raw) => {
262
+ const anchor = offsetIn(rope, raw.anchor);
263
+ return { anchor, head: raw.head === raw.anchor ? anchor : offsetIn(rope, raw.head) };
264
+ });
265
+ const order = clamped.map((selection, index) => ({ ...rangeOf(selection), index })).sort((left, right) => left.from - right.from);
266
+ const groups: { from: number; to: number; last: number }[] = [];
267
+ for (const { from, to, index } of order) {
268
+ const group = groups.at(-1);
269
+ if (group !== undefined && from <= group.to) {
270
+ group.to = Math.max(group.to, to);
271
+ group.last = Math.max(group.last, index);
272
+ } else {
273
+ groups.push({ from, to, last: index });
253
274
  }
254
- result.push({ selection, primary });
255
- }
256
- const primary = result.findIndex((entry) => entry.primary);
257
- if (primary !== result.length - 1) {
258
- result.push(...result.splice(primary, 1));
259
275
  }
260
- return result.map((entry) => entry.selection);
276
+ return groups
277
+ .sort((left, right) => left.last - right.last)
278
+ .map((group) => {
279
+ const selection = clamped[group.last]!;
280
+ return selection.anchor > selection.head ? { anchor: group.to, head: group.from } : { anchor: group.from, head: group.to };
281
+ });
261
282
  }
262
283
 
263
284
  function inverseChanges(changes: readonly DocumentChange[]): DocumentChange[] {
@@ -323,6 +344,17 @@ function editsOf(rope: TextRope, edits: readonly TextEdit[]): ContentEdit[] {
323
344
  * either end, and is a little more than the change when two of them are far apart.
324
345
  */
325
346
  function stretchEdits(before: TextRope, after: TextRope, transactions: readonly (readonly DocumentChange[])[]): ContentEdit[] {
347
+ const stretch = stretchOf(before, after, transactions);
348
+ if (stretch === undefined) {
349
+ return [];
350
+ }
351
+ return [
352
+ { start: positionIn(before, stretch.from), end: positionIn(before, stretch.to), text: after.slice(stretch.from, stretch.from + stretch.insertedLength) }
353
+ ];
354
+ }
355
+
356
+ /* The one change in the coordinates of `before` that holds every change of the transactions, or undefined when they change nothing. */
357
+ function stretchOf(before: TextRope, after: TextRope, transactions: readonly (readonly DocumentChange[])[]): DocumentChange | undefined {
326
358
  let length = before.length;
327
359
  let prefix = Number.POSITIVE_INFINITY;
328
360
  let suffix = Number.POSITIVE_INFINITY;
@@ -336,15 +368,15 @@ function stretchEdits(before: TextRope, after: TextRope, transactions: readonly
336
368
  length += delta;
337
369
  }
338
370
  if (!Number.isFinite(prefix)) {
339
- return [];
371
+ return undefined;
340
372
  }
341
373
  const kept = Math.min(Math.max(0, suffix), before.length - prefix, after.length - prefix);
342
374
  const oldEnd = before.length - kept;
343
375
  const newEnd = after.length - kept;
344
376
  if (oldEnd === prefix && newEnd === prefix) {
345
- return [];
377
+ return undefined;
346
378
  }
347
- return [{ start: positionIn(before, prefix), end: positionIn(before, oldEnd), text: after.slice(prefix, newEnd) }];
379
+ return { from: prefix, to: oldEnd, insertedLength: newEnd - prefix };
348
380
  }
349
381
 
350
382
  /* An occurrence selected with its caret `shift` characters into it, as the caret stood in the selection it repeats. */
@@ -382,6 +414,7 @@ export class DocumentModel {
382
414
  occurrencesExhausted = false;
383
415
  private closers = new CloserTracker();
384
416
  private bracketCache?: { revision: number; language: string; index: BracketIndex };
417
+ private bracketTracker = new BracketTracker(() => this.rope);
385
418
  private scriptCache?: { revision: number; sources: Map<number, { limit: number; text?: string; index?: BracketIndex }> };
386
419
 
387
420
  constructor(text = '') {
@@ -417,6 +450,16 @@ export class DocumentModel {
417
450
  return this.rope.slice(from, to);
418
451
  }
419
452
 
453
+ /*
454
+ * Where the bracket at `offset` pairs, as `scanBrackets` pairs it, with no mate for one it leaves unmatched.
455
+ * Undefined where no bracket of the code stands, such as in a string or a comment. An edit lexes only the few
456
+ * kilobytes around it again, so this stays cheap on every keystroke of a large document. With `limit` it lexes
457
+ * at most about that many characters and returns null when that was not enough; asking again goes on from there.
458
+ */
459
+ matchBracket(offset: number, language = 'typescript', limit?: number): BracketMatch | null | undefined {
460
+ return this.bracketTracker.match(offset, language, limit);
461
+ }
462
+
420
463
  getSelections(): readonly Selection[] {
421
464
  return this.selections.map((selection) => ({ ...selection }));
422
465
  }
@@ -516,15 +559,16 @@ export class DocumentModel {
516
559
  const before = this.state();
517
560
  const firstLine = this.rope.lineAt(changed[0]!.from);
518
561
  const previous = this.rope;
562
+ const changes = changed.map((edit) => ({ from: edit.from, to: edit.to, insertedLength: edit.text.length }));
519
563
  this.rope = next;
520
564
  this.invalidate();
521
565
  this.typingContexts.invalidate(firstLine);
566
+ this.bracketTracker.edited(previous, next, changes);
522
567
  this.selections = normalizeSelections(this.rope, nextSelections);
523
568
  this.closers.map(changed);
524
569
  this.closers.prune(this.selections);
525
570
  this.revision++;
526
571
  const after = this.state();
527
- const changes = changed.map((edit) => ({ from: edit.from, to: edit.to, insertedLength: edit.text.length }));
528
572
  const last = this.undoStack.at(-1);
529
573
  const joinsGroup =
530
574
  options.historyGroup &&
@@ -2211,6 +2255,8 @@ export class DocumentModel {
2211
2255
  this.invalidate();
2212
2256
  this.closers.clear();
2213
2257
  this.typingContexts.invalidate();
2258
+ const stretch = stretchOf(previous, state.rope, changes);
2259
+ this.bracketTracker.edited(previous, state.rope, stretch === undefined ? [] : [stretch]);
2214
2260
  this.selections = state.selections.map((selection) => ({ ...selection }));
2215
2261
  this.revision++;
2216
2262
  this.groupOpen = false;
@@ -2237,7 +2283,12 @@ export class DocumentModel {
2237
2283
  }
2238
2284
  }
2239
2285
  for (const listener of this.listeners) {
2240
- listener(snapshot);
2286
+ try {
2287
+ listener(snapshot);
2288
+ } catch (error) {
2289
+ // The text has changed by now, so a failing listener must not throw at the caller or keep the others, such as a language server's sync, from hearing it.
2290
+ console.error('A document listener failed.', error);
2291
+ }
2241
2292
  }
2242
2293
  }
2243
2294
  }
package/src/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { DocumentModel } from './document.ts';
2
2
  export type { DocumentEditOptions, DocumentLine, OffsetRange, ReplaceAllOptions, ReplaceOptions, TypeTextOptions } from './document.ts';
3
- export { scanBrackets, type BracketIndex } from './brackets.ts';
3
+ export { scanBrackets, type BracketIndex, type BracketMatch } from './brackets.ts';
4
4
  export { replaceWithCaseRespect } from './preserve-case.ts';
5
5
  export { findMatches, iterateMatches, findMatchFrom, replacementText, type FindMatch, type FindNextOptions, type FindOptions } from './search.ts';
6
6
  export { indentationColumn } from './structure.ts';
package/src/lexical.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  export const openers: Readonly<Record<string, string>> = { '(': ')', '[': ']', '{': '}' };
2
2
 
3
3
  /* Words after which a `/` starts a regex and a `{` an object or a block, not a division or a statement end. */
4
- const expressionWords = /^(return|throw|case|yield|await|new|delete|typeof|void|in|of|instanceof)$/;
4
+ const expressionWords = new Set(['return', 'throw', 'case', 'yield', 'await', 'new', 'delete', 'typeof', 'void', 'in', 'of', 'instanceof']);
5
+ const expressionInitials = new Set([...expressionWords].map((word) => word[0]));
6
+ const expressionPunctuation = new Set('=,:;!?+-*%&|^~<>');
5
7
 
6
8
  /* Whether a `#` at this index stands where a word would start, after whitespace, a separator or an opener. */
7
9
  export function startsWord(text: string, at: number): boolean {
@@ -100,14 +102,23 @@ export function hasSmartSemicolon(language: string): boolean {
100
102
  }
101
103
 
102
104
  export function isWordCharacter(character: string): boolean {
105
+ const code = character.charCodeAt(0);
106
+ if (code < 128) {
107
+ return (code >= 97 && code <= 122) || (code >= 65 && code <= 90) || (code >= 48 && code <= 57) || code === 95 || code === 36;
108
+ }
103
109
  return /[\p{L}\p{N}_$]/u.test(character);
104
110
  }
105
111
 
106
112
  export function startsExpression(word: string): boolean {
107
- return expressionWords.test(word);
113
+ return expressionWords.has(word);
114
+ }
115
+
116
+ /* `startsExpression` for the word from `from` to `to` in `text`, which cuts the word out only when it could be one. */
117
+ export function startsExpressionAt(text: string, from: number, to: number): boolean {
118
+ return to - from >= 2 && to - from <= 10 && expressionInitials.has(text[from]!) && expressionWords.has(text.slice(from, to));
108
119
  }
109
120
 
110
121
  /* Whether what follows a punctuation character is an expression, so a `/` there is a regex. */
111
122
  export function startsExpressionAfter(character: string): boolean {
112
- return /[=,:;!?+\-*%&|^~<>]/.test(character);
123
+ return expressionPunctuation.has(character);
113
124
  }
package/src/paste.ts CHANGED
@@ -51,7 +51,8 @@ function reindent(source: EditSource, from: number, text: string, options: Paste
51
51
  const measured = widths.filter((width, index): width is number => width !== null && !(anchored && index === 0));
52
52
  const trailingBreak = isBlank(lines.at(-1)!);
53
53
  if (measured.length > 0) {
54
- const delta = target - Math.min(...measured);
54
+ // A reduce, not a spread: a paste of a few hundred thousand lines is more arguments than V8 takes in one call.
55
+ const delta = target - measured.reduce((least, width) => Math.min(least, width));
55
56
  for (const [index, entry] of lines.entries()) {
56
57
  const width = widths[index];
57
58
  if (width === null || width === undefined || (anchored && index === 0)) {
package/src/rope.ts CHANGED
@@ -8,27 +8,56 @@ export interface DocumentLine {
8
8
 
9
9
  /*
10
10
  * A persistent AVL tree of text chunks of at most `ROPE_CHUNK_SIZE` UTF-16 code units. Every node knows
11
- * its length and its LF count, a leaf also where its LFs are, so offsets and line numbers resolve
12
- * without scanning.
11
+ * its length and its line break count, a leaf also where its breaks are, so offsets and line numbers
12
+ * resolve without scanning. A line ends at `\n`, `\r\n` or a lone `\r`, as the language server protocol
13
+ * counts lines, and a break sits at the last code unit of its line ending.
13
14
  */
14
- type Leaf = { readonly text: string; readonly breaks: readonly number[]; readonly length: number; readonly lines: number; readonly height: number };
15
- type Branch = { readonly left: Node; readonly right: Node; readonly length: number; readonly lines: number; readonly height: number };
15
+ type Edges = { readonly startsWithLf: boolean; readonly endsWithCr: boolean };
16
+ type Leaf = Edges & { readonly text: string; readonly breaks: readonly number[]; readonly length: number; readonly lines: number; readonly height: number };
17
+ type Branch = Edges & { readonly left: Node; readonly right: Node; readonly length: number; readonly lines: number; readonly height: number };
16
18
  type Node = Leaf | Branch;
17
19
  export const ROPE_CHUNK_SIZE = 2048;
18
20
 
21
+ const CR = 13;
22
+ const LF = 10;
23
+
24
+ /* A node counts a `\r` it ends with as a break; when the next node starts with `\n`, the two are one CRLF and only the `\n` is. */
25
+ function sharedBreak(left: Node, right: Node): number {
26
+ return left.endsWithCr && right.startsWithLf ? 1 : 0;
27
+ }
28
+
19
29
  function leaf(text: string): Leaf | null {
20
30
  if (!text.length) {
21
31
  return null;
22
32
  }
23
33
  const breaks: number[] = [];
24
- for (let index = text.indexOf('\n'); index !== -1; index = text.indexOf('\n', index + 1)) {
25
- breaks.push(index);
34
+ for (let index = 0; index < text.length; index++) {
35
+ const code = text.charCodeAt(index);
36
+ if (code === LF || (code === CR && text.charCodeAt(index + 1) !== LF)) {
37
+ breaks.push(index);
38
+ }
26
39
  }
27
- return { text, breaks, length: text.length, lines: breaks.length, height: 1 };
40
+ return {
41
+ text,
42
+ breaks,
43
+ length: text.length,
44
+ lines: breaks.length,
45
+ height: 1,
46
+ startsWithLf: text.charCodeAt(0) === LF,
47
+ endsWithCr: text.charCodeAt(text.length - 1) === CR
48
+ };
28
49
  }
29
50
 
30
51
  function branch(left: Node, right: Node): Branch {
31
- return { left, right, length: left.length + right.length, lines: left.lines + right.lines, height: Math.max(left.height, right.height) + 1 };
52
+ return {
53
+ left,
54
+ right,
55
+ length: left.length + right.length,
56
+ lines: left.lines + right.lines - sharedBreak(left, right),
57
+ height: Math.max(left.height, right.height) + 1,
58
+ startsWithLf: left.startsWithLf,
59
+ endsWithCr: right.endsWithCr
60
+ };
32
61
  }
33
62
 
34
63
  function balance(left: Node, right: Node): Node {
@@ -259,7 +288,7 @@ export class TextRope {
259
288
  node = node.left;
260
289
  } else {
261
290
  offset -= node.left.length;
262
- count += node.left.lines;
291
+ count += node.left.lines - sharedBreak(node.left, node.right);
263
292
  node = node.right;
264
293
  }
265
294
  }
@@ -283,7 +312,8 @@ export class TextRope {
283
312
  return { start, end: this.length, next: this.length };
284
313
  }
285
314
  const newline = this.breakAt(index);
286
- return { start, end: newline - (this.charAt(newline - 1) === '\r' ? 1 : 0), next: newline + 1 };
315
+ const crlf = this.charAt(newline) === '\n' && this.charAt(newline - 1) === '\r';
316
+ return { start, end: newline - (crlf ? 1 : 0), next: newline + 1 };
287
317
  }
288
318
 
289
319
  getLine(index: number): DocumentLine {
@@ -295,10 +325,11 @@ export class TextRope {
295
325
  let node = this.root!;
296
326
  let offset = 0;
297
327
  while ('left' in node) {
298
- if (index < node.left.lines) {
328
+ const leftLines = node.left.lines - sharedBreak(node.left, node.right);
329
+ if (index < leftLines) {
299
330
  node = node.left;
300
331
  } else {
301
- index -= node.left.lines;
332
+ index -= leftLines;
302
333
  offset += node.left.length;
303
334
  node = node.right;
304
335
  }
package/src/structure.ts CHANGED
@@ -207,7 +207,10 @@ export function deriveFoldingRanges(
207
207
  }
208
208
  }
209
209
  if (options.indentation) {
210
- result.push(...indentationFolds(lineCount, getLine, tabWidth(options), minimum));
210
+ // One push per fold: a long file has more of them than V8 takes as arguments in one call.
211
+ for (const fold of indentationFolds(lineCount, getLine, tabWidth(options), minimum)) {
212
+ result.push(fold);
213
+ }
211
214
  }
212
215
  result.sort((left, right) => left.startLine - right.startLine || right.endLine - left.endLine);
213
216
  return dedupeFoldingRanges(result);