@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.
package/dist/document.js CHANGED
@@ -12,6 +12,7 @@ import { lexicalFolds } from './lexical-folds.js';
12
12
  import { rangeOf, whitespaceOf } from './line-text.js';
13
13
  import { isQuote, replacesComparison, surround, swapQuotes } from './typing-handlers.js';
14
14
  import { scanBrackets } from './brackets.js';
15
+ import { BracketTracker } from './bracket-tracker.js';
15
16
  import { hasSmartSemicolon, isPhp } from './lexical.js';
16
17
  import { clampInteger, TextRope, ROPE_CHUNK_SIZE } from './rope.js';
17
18
  import { findMatches, findMatchFrom, replacementText, splitsSurrogate } from './search.js';
@@ -32,7 +33,7 @@ const identifier = /[\p{L}\p{N}\p{M}\p{Pc}$]/u;
32
33
  /* What can follow `->`: the first letter of a member name, or the `{` of a dynamic one. */
33
34
  const arrowContinues = /^[\p{L}_{]/u;
34
35
  const wordCommand = /^(select|delete)?(word|camel)(Left|Right)$/i;
35
- const graphemes = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
36
+ let graphemes;
36
37
  function sameSelections(left, right) {
37
38
  return left.length === right.length && left.every((selection, index) => selection.anchor === right[index].anchor && selection.head === right[index].head);
38
39
  }
@@ -51,14 +52,35 @@ function selectionTransaction(plans) {
51
52
  edits.push(plan);
52
53
  }
53
54
  }
55
+ // What the edits before each one add to the text, so a caret finds its shift by a binary search.
56
+ const shifts = [0];
57
+ for (const edit of edits) {
58
+ shifts.push(shifts.at(-1) + edit.text.length - (edit.to - edit.from));
59
+ }
54
60
  const selections = plans.map((plan) => {
55
- const delta = edits.filter((edit) => edit.from < plan.from).reduce((sum, edit) => sum + edit.text.length - (edit.to - edit.from), 0);
61
+ let low = 0;
62
+ let high = edits.length;
63
+ while (low < high) {
64
+ const middle = (low + high) >>> 1;
65
+ if (edits[middle].from < plan.from) {
66
+ low = middle + 1;
67
+ }
68
+ else {
69
+ high = middle;
70
+ }
71
+ }
72
+ const delta = shifts[low];
56
73
  return { anchor: plan.from + delta + plan.anchor, head: plan.from + delta + plan.head };
57
74
  });
58
75
  return { edits, selections };
59
76
  }
60
77
  function splitsCluster(rope, offset) {
61
- return splitsSurrogate(rope, offset) || (rope.charAt(offset - 1) === '\r' && rope.charAt(offset) === '\n');
78
+ const before = rope.charAt(offset - 1);
79
+ // One read of the rope settles most offsets, which follow neither a `\r` nor the first half of a surrogate pair.
80
+ if (before === '\r') {
81
+ return rope.charAt(offset) === '\n';
82
+ }
83
+ return /[\ud800-\udbff]/.test(before) && splitsSurrogate(rope, offset);
62
84
  }
63
85
  /* Clamps to the document and moves back off the middle of a surrogate pair or a CRLF. */
64
86
  function offsetIn(rope, value) {
@@ -85,6 +107,7 @@ function deletionStep(rope, offset, direction) {
85
107
  }
86
108
  const line = rope.getLine(rope.lineAt(offset));
87
109
  let previous = line.start;
110
+ graphemes ??= new Intl.Segmenter(undefined, { granularity: 'grapheme' });
88
111
  for (const segment of graphemes.segment(line.text)) {
89
112
  const at = line.start + segment.index;
90
113
  if (direction < 0 && at >= offset) {
@@ -98,33 +121,33 @@ function deletionStep(rope, offset, direction) {
98
121
  return direction < 0 ? previous : line.end;
99
122
  }
100
123
  /*
101
- * Clamps every selection and merges the ones that overlap or touch, so the result is disjoint. The
102
- * last one stays the primary caret, and a merge that takes it in keeps it last.
124
+ * Clamps every selection and merges the ones that overlap or touch, so the result is disjoint. A merged
125
+ * selection takes the place and the direction of the last added of the ones it holds, so the primary caret stays last.
103
126
  */
104
127
  function normalizeSelections(rope, selections) {
105
128
  const input = selections.length > 0 ? selections : [{ anchor: 0, head: 0 }];
106
- const result = [];
107
- for (const [position, raw] of input.entries()) {
108
- let selection = { anchor: offsetIn(rope, raw.anchor), head: offsetIn(rope, raw.head) };
109
- let primary = position === input.length - 1;
110
- let { from, to } = rangeOf(selection);
111
- for (let index = result.length - 1; index >= 0; index--) {
112
- const other = rangeOf(result[index].selection);
113
- if (from <= other.to && to >= other.from) {
114
- from = Math.min(from, other.from);
115
- to = Math.max(to, other.to);
116
- primary ||= result[index].primary;
117
- result.splice(index, 1);
118
- selection = selection.anchor > selection.head ? { anchor: to, head: from } : { anchor: from, head: to };
119
- }
120
- }
121
- result.push({ selection, primary });
122
- }
123
- const primary = result.findIndex((entry) => entry.primary);
124
- if (primary !== result.length - 1) {
125
- result.push(...result.splice(primary, 1));
126
- }
127
- return result.map((entry) => entry.selection);
129
+ const clamped = input.map((raw) => {
130
+ const anchor = offsetIn(rope, raw.anchor);
131
+ return { anchor, head: raw.head === raw.anchor ? anchor : offsetIn(rope, raw.head) };
132
+ });
133
+ const order = clamped.map((selection, index) => ({ ...rangeOf(selection), index })).sort((left, right) => left.from - right.from);
134
+ const groups = [];
135
+ for (const { from, to, index } of order) {
136
+ const group = groups.at(-1);
137
+ if (group !== undefined && from <= group.to) {
138
+ group.to = Math.max(group.to, to);
139
+ group.last = Math.max(group.last, index);
140
+ }
141
+ else {
142
+ groups.push({ from, to, last: index });
143
+ }
144
+ }
145
+ return groups
146
+ .sort((left, right) => left.last - right.last)
147
+ .map((group) => {
148
+ const selection = clamped[group.last];
149
+ return selection.anchor > selection.head ? { anchor: group.to, head: group.from } : { anchor: group.from, head: group.to };
150
+ });
128
151
  }
129
152
  function inverseChanges(changes) {
130
153
  let delta = 0;
@@ -182,6 +205,16 @@ function editsOf(rope, edits) {
182
205
  * either end, and is a little more than the change when two of them are far apart.
183
206
  */
184
207
  function stretchEdits(before, after, transactions) {
208
+ const stretch = stretchOf(before, after, transactions);
209
+ if (stretch === undefined) {
210
+ return [];
211
+ }
212
+ return [
213
+ { start: positionIn(before, stretch.from), end: positionIn(before, stretch.to), text: after.slice(stretch.from, stretch.from + stretch.insertedLength) }
214
+ ];
215
+ }
216
+ /* The one change in the coordinates of `before` that holds every change of the transactions, or undefined when they change nothing. */
217
+ function stretchOf(before, after, transactions) {
185
218
  let length = before.length;
186
219
  let prefix = Number.POSITIVE_INFINITY;
187
220
  let suffix = Number.POSITIVE_INFINITY;
@@ -195,15 +228,15 @@ function stretchEdits(before, after, transactions) {
195
228
  length += delta;
196
229
  }
197
230
  if (!Number.isFinite(prefix)) {
198
- return [];
231
+ return undefined;
199
232
  }
200
233
  const kept = Math.min(Math.max(0, suffix), before.length - prefix, after.length - prefix);
201
234
  const oldEnd = before.length - kept;
202
235
  const newEnd = after.length - kept;
203
236
  if (oldEnd === prefix && newEnd === prefix) {
204
- return [];
237
+ return undefined;
205
238
  }
206
- return [{ start: positionIn(before, prefix), end: positionIn(before, oldEnd), text: after.slice(prefix, newEnd) }];
239
+ return { from: prefix, to: oldEnd, insertedLength: newEnd - prefix };
207
240
  }
208
241
  /* An occurrence selected with its caret `shift` characters into it, as the caret stood in the selection it repeats. */
209
242
  function occurrenceSelection(match, shift) {
@@ -239,6 +272,7 @@ export class DocumentModel {
239
272
  occurrencesExhausted = false;
240
273
  closers = new CloserTracker();
241
274
  bracketCache;
275
+ bracketTracker = new BracketTracker(() => this.rope);
242
276
  scriptCache;
243
277
  constructor(text = '') {
244
278
  this.rope = TextRope.from(text);
@@ -266,6 +300,15 @@ export class DocumentModel {
266
300
  slice(from, to) {
267
301
  return this.rope.slice(from, to);
268
302
  }
303
+ /*
304
+ * Where the bracket at `offset` pairs, as `scanBrackets` pairs it, with no mate for one it leaves unmatched.
305
+ * Undefined where no bracket of the code stands, such as in a string or a comment. An edit lexes only the few
306
+ * kilobytes around it again, so this stays cheap on every keystroke of a large document. With `limit` it lexes
307
+ * at most about that many characters and returns null when that was not enough; asking again goes on from there.
308
+ */
309
+ matchBracket(offset, language = 'typescript', limit) {
310
+ return this.bracketTracker.match(offset, language, limit);
311
+ }
269
312
  getSelections() {
270
313
  return this.selections.map((selection) => ({ ...selection }));
271
314
  }
@@ -356,15 +399,16 @@ export class DocumentModel {
356
399
  const before = this.state();
357
400
  const firstLine = this.rope.lineAt(changed[0].from);
358
401
  const previous = this.rope;
402
+ const changes = changed.map((edit) => ({ from: edit.from, to: edit.to, insertedLength: edit.text.length }));
359
403
  this.rope = next;
360
404
  this.invalidate();
361
405
  this.typingContexts.invalidate(firstLine);
406
+ this.bracketTracker.edited(previous, next, changes);
362
407
  this.selections = normalizeSelections(this.rope, nextSelections);
363
408
  this.closers.map(changed);
364
409
  this.closers.prune(this.selections);
365
410
  this.revision++;
366
411
  const after = this.state();
367
- const changes = changed.map((edit) => ({ from: edit.from, to: edit.to, insertedLength: edit.text.length }));
368
412
  const last = this.undoStack.at(-1);
369
413
  const joinsGroup = options.historyGroup &&
370
414
  this.groupOpen &&
@@ -1892,6 +1936,8 @@ export class DocumentModel {
1892
1936
  this.invalidate();
1893
1937
  this.closers.clear();
1894
1938
  this.typingContexts.invalidate();
1939
+ const stretch = stretchOf(previous, state.rope, changes);
1940
+ this.bracketTracker.edited(previous, state.rope, stretch === undefined ? [] : [stretch]);
1895
1941
  this.selections = state.selections.map((selection) => ({ ...selection }));
1896
1942
  this.revision++;
1897
1943
  this.groupOpen = false;
@@ -1912,7 +1958,13 @@ export class DocumentModel {
1912
1958
  }
1913
1959
  }
1914
1960
  for (const listener of this.listeners) {
1915
- listener(snapshot);
1961
+ try {
1962
+ listener(snapshot);
1963
+ }
1964
+ catch (error) {
1965
+ // 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.
1966
+ console.error('A document listener failed.', error);
1967
+ }
1916
1968
  }
1917
1969
  }
1918
1970
  }
package/dist/index.d.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/dist/lexical.d.ts CHANGED
@@ -13,4 +13,5 @@ export declare function indentsByBrackets(language: string): boolean;
13
13
  export declare function hasSmartSemicolon(language: string): boolean;
14
14
  export declare function isWordCharacter(character: string): boolean;
15
15
  export declare function startsExpression(word: string): boolean;
16
+ export declare function startsExpressionAt(text: string, from: number, to: number): boolean;
16
17
  export declare function startsExpressionAfter(character: string): boolean;
package/dist/lexical.js CHANGED
@@ -1,6 +1,8 @@
1
1
  export const openers = { '(': ')', '[': ']', '{': '}' };
2
2
  /* Words after which a `/` starts a regex and a `{` an object or a block, not a division or a statement end. */
3
- const expressionWords = /^(return|throw|case|yield|await|new|delete|typeof|void|in|of|instanceof)$/;
3
+ const expressionWords = new Set(['return', 'throw', 'case', 'yield', 'await', 'new', 'delete', 'typeof', 'void', 'in', 'of', 'instanceof']);
4
+ const expressionInitials = new Set([...expressionWords].map((word) => word[0]));
5
+ const expressionPunctuation = new Set('=,:;!?+-*%&|^~<>');
4
6
  /* Whether a `#` at this index stands where a word would start, after whitespace, a separator or an opener. */
5
7
  export function startsWord(text, at) {
6
8
  return at === 0 || /[\s;|&(]/.test(text[at - 1]);
@@ -63,12 +65,20 @@ export function hasSmartSemicolon(language) {
63
65
  return /^(typescript|javascript|typescriptreact|javascriptreact|tsx|jsx|ts|js|php)$/i.test(language);
64
66
  }
65
67
  export function isWordCharacter(character) {
68
+ const code = character.charCodeAt(0);
69
+ if (code < 128) {
70
+ return (code >= 97 && code <= 122) || (code >= 65 && code <= 90) || (code >= 48 && code <= 57) || code === 95 || code === 36;
71
+ }
66
72
  return /[\p{L}\p{N}_$]/u.test(character);
67
73
  }
68
74
  export function startsExpression(word) {
69
- return expressionWords.test(word);
75
+ return expressionWords.has(word);
76
+ }
77
+ /* `startsExpression` for the word from `from` to `to` in `text`, which cuts the word out only when it could be one. */
78
+ export function startsExpressionAt(text, from, to) {
79
+ return to - from >= 2 && to - from <= 10 && expressionInitials.has(text[from]) && expressionWords.has(text.slice(from, to));
70
80
  }
71
81
  /* Whether what follows a punctuation character is an expression, so a `/` there is a regex. */
72
82
  export function startsExpressionAfter(character) {
73
- return /[=,:;!?+\-*%&|^~<>]/.test(character);
83
+ return expressionPunctuation.has(character);
74
84
  }
package/dist/paste.js CHANGED
@@ -37,7 +37,8 @@ function reindent(source, from, text, options) {
37
37
  const measured = widths.filter((width, index) => width !== null && !(anchored && index === 0));
38
38
  const trailingBreak = isBlank(lines.at(-1));
39
39
  if (measured.length > 0) {
40
- const delta = target - Math.min(...measured);
40
+ // A reduce, not a spread: a paste of a few hundred thousand lines is more arguments than V8 takes in one call.
41
+ const delta = target - measured.reduce((least, width) => Math.min(least, width));
41
42
  for (const [index, entry] of lines.entries()) {
42
43
  const width = widths[index];
43
44
  if (width === null || width === undefined || (anchored && index === 0)) {
package/dist/rope.js CHANGED
@@ -1,16 +1,41 @@
1
1
  export const ROPE_CHUNK_SIZE = 2048;
2
+ const CR = 13;
3
+ const LF = 10;
4
+ /* 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. */
5
+ function sharedBreak(left, right) {
6
+ return left.endsWithCr && right.startsWithLf ? 1 : 0;
7
+ }
2
8
  function leaf(text) {
3
9
  if (!text.length) {
4
10
  return null;
5
11
  }
6
12
  const breaks = [];
7
- for (let index = text.indexOf('\n'); index !== -1; index = text.indexOf('\n', index + 1)) {
8
- breaks.push(index);
13
+ for (let index = 0; index < text.length; index++) {
14
+ const code = text.charCodeAt(index);
15
+ if (code === LF || (code === CR && text.charCodeAt(index + 1) !== LF)) {
16
+ breaks.push(index);
17
+ }
9
18
  }
10
- return { text, breaks, length: text.length, lines: breaks.length, height: 1 };
19
+ return {
20
+ text,
21
+ breaks,
22
+ length: text.length,
23
+ lines: breaks.length,
24
+ height: 1,
25
+ startsWithLf: text.charCodeAt(0) === LF,
26
+ endsWithCr: text.charCodeAt(text.length - 1) === CR
27
+ };
11
28
  }
12
29
  function branch(left, right) {
13
- return { left, right, length: left.length + right.length, lines: left.lines + right.lines, height: Math.max(left.height, right.height) + 1 };
30
+ return {
31
+ left,
32
+ right,
33
+ length: left.length + right.length,
34
+ lines: left.lines + right.lines - sharedBreak(left, right),
35
+ height: Math.max(left.height, right.height) + 1,
36
+ startsWithLf: left.startsWithLf,
37
+ endsWithCr: right.endsWithCr
38
+ };
14
39
  }
15
40
  function balance(left, right) {
16
41
  if (left.height > right.height + 1 && 'left' in left) {
@@ -227,7 +252,7 @@ export class TextRope {
227
252
  }
228
253
  else {
229
254
  offset -= node.left.length;
230
- count += node.left.lines;
255
+ count += node.left.lines - sharedBreak(node.left, node.right);
231
256
  node = node.right;
232
257
  }
233
258
  }
@@ -251,7 +276,8 @@ export class TextRope {
251
276
  return { start, end: this.length, next: this.length };
252
277
  }
253
278
  const newline = this.breakAt(index);
254
- return { start, end: newline - (this.charAt(newline - 1) === '\r' ? 1 : 0), next: newline + 1 };
279
+ const crlf = this.charAt(newline) === '\n' && this.charAt(newline - 1) === '\r';
280
+ return { start, end: newline - (crlf ? 1 : 0), next: newline + 1 };
255
281
  }
256
282
  getLine(index) {
257
283
  const bounds = this.lineBounds(index);
@@ -261,11 +287,12 @@ export class TextRope {
261
287
  let node = this.root;
262
288
  let offset = 0;
263
289
  while ('left' in node) {
264
- if (index < node.left.lines) {
290
+ const leftLines = node.left.lines - sharedBreak(node.left, node.right);
291
+ if (index < leftLines) {
265
292
  node = node.left;
266
293
  }
267
294
  else {
268
- index -= node.left.lines;
295
+ index -= leftLines;
269
296
  offset += node.left.length;
270
297
  node = node.right;
271
298
  }
package/dist/structure.js CHANGED
@@ -103,7 +103,10 @@ export function deriveFoldingRanges(ranges, lineAt, lineCount, getLine, options
103
103
  }
104
104
  }
105
105
  if (options.indentation) {
106
- result.push(...indentationFolds(lineCount, getLine, tabWidth(options), minimum));
106
+ // One push per fold: a long file has more of them than V8 takes as arguments in one call.
107
+ for (const fold of indentationFolds(lineCount, getLine, tabWidth(options), minimum)) {
108
+ result.push(fold);
109
+ }
107
110
  }
108
111
  result.sort((left, right) => left.startLine - right.startLine || right.endLine - left.endLine);
109
112
  return dedupeFoldingRanges(result);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/editor-core",
3
- "version": "0.23.1",
3
+ "version": "0.24.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {