@adecore/editor-core 0.0.1 → 0.17.0-beta.2

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 +40 -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,1729 @@
1
+ import { CloserTracker } from './closer-tracker.js';
2
+ import { planBlockComment, planLineComments } from './comments.js';
3
+ import { planEnter } from './enter.js';
4
+ import { planAutoIndent, planJoinLines, planToggleCase } from './line-commands.js';
5
+ import { mapOffset } from './offsets.js';
6
+ import { planPaste } from './paste.js';
7
+ import { isPlainText, vueRegionAt } from './languages.js';
8
+ import { blockFolds } from './block-folds.js';
9
+ import { applyFoldHints, markFileHeader } from './fold-roles.js';
10
+ import { lexicalFolds } from './lexical-folds.js';
11
+ import { isQuote, replacesComparison, surround, swapQuotes } from './typing-handlers.js';
12
+ import { scanBrackets } from './brackets.js';
13
+ import { hasSmartSemicolon } from './lexical.js';
14
+ import { clampInteger, TextRope } from './rope.js';
15
+ import { findMatches, replacementText, splitsSurrogate } from './search.js';
16
+ import { dedupeFoldingRanges, deriveFoldingRanges, indentationColumn, scanStructure, tabWidth } from './structure.js';
17
+ import { TypingContexts } from './typing-context.js';
18
+ import { isHumpBoundary, isWordBoundary } from './words.js';
19
+ /* The platform's defaults: forward to a word's end and then the start of the next line, backward to a word's start and then the end of the line above. Deleting stops at every edge of a word and at both ends of a line. */
20
+ const MOVE_FORWARD = { word: 'end', line: 'start' };
21
+ const MOVE_BACKWARD = { word: 'start', line: 'end' };
22
+ const DELETE_FORWARD = { word: 'both', line: 'both' };
23
+ const DELETE_BACKWARD = { word: 'start', line: 'both' };
24
+ const historyLimit = 200;
25
+ const bracketScanLimit = 2_000_000;
26
+ /* Typing a bracket reads the document once to see whether it already has a closer, so a very large one is not read. */
27
+ const matchScanLimit = 400_000;
28
+ const pairs = { '(': ')', '[': ']', '{': '}', '"': '"', "'": "'", '`': '`' };
29
+ const identifier = /[\p{L}\p{N}\p{M}\p{Pc}$]/u;
30
+ const wordCommand = /^(select|delete)?(word|camel)(Left|Right)$/i;
31
+ const graphemes = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
32
+ function rangeOf(selection) {
33
+ return { from: Math.min(selection.anchor, selection.head), to: Math.max(selection.anchor, selection.head) };
34
+ }
35
+ function sameSelections(left, right) {
36
+ return left.length === right.length && left.every((selection, index) => selection.anchor === right[index].anchor && selection.head === right[index].head);
37
+ }
38
+ function indentLength(text) {
39
+ return text.match(/^[\t ]*/)?.[0].length ?? 0;
40
+ }
41
+ function trailingBlankLength(text) {
42
+ return text.match(/[\t ]*$/)?.[0].length ?? 0;
43
+ }
44
+ function changesText(rope, edit) {
45
+ return edit.text.length !== edit.to - edit.from || rope.slice(edit.from, edit.to) !== edit.text;
46
+ }
47
+ /* Merges the carets' edits into one simultaneous batch and works out where each caret lands in the result. */
48
+ function selectionTransaction(plans) {
49
+ const edits = [];
50
+ for (const plan of plans.filter((candidate) => !candidate.skip).sort((left, right) => left.from - right.from || left.to - right.to)) {
51
+ const previous = edits.at(-1);
52
+ if (previous?.from !== plan.from || previous.to !== plan.to || previous.text !== plan.text) {
53
+ edits.push(plan);
54
+ }
55
+ }
56
+ const selections = plans.map((plan) => {
57
+ const delta = edits.filter((edit) => edit.from < plan.from).reduce((sum, edit) => sum + edit.text.length - (edit.to - edit.from), 0);
58
+ return { anchor: plan.from + delta + plan.anchor, head: plan.from + delta + plan.head };
59
+ });
60
+ return { edits, selections };
61
+ }
62
+ function splitsCluster(rope, offset) {
63
+ return splitsSurrogate(rope, offset) || (rope.charAt(offset - 1) === '\r' && rope.charAt(offset) === '\n');
64
+ }
65
+ /* Clamps to the document and moves back off the middle of a surrogate pair or a CRLF. */
66
+ function offsetIn(rope, value) {
67
+ const offset = clampInteger(value, rope.length);
68
+ return splitsCluster(rope, offset) ? offset - 1 : offset;
69
+ }
70
+ function step(rope, offset, direction) {
71
+ const next = clampInteger(offset + direction, rope.length);
72
+ return splitsCluster(rope, next) ? next + direction : next;
73
+ }
74
+ function isAsciiOrEmpty(character) {
75
+ return character === '' || character.charCodeAt(0) <= 0x7f;
76
+ }
77
+ /* One backspace or delete: a code point or a CRLF, or a whole grapheme once the text is not plain ASCII. */
78
+ function deletionStep(rope, offset, direction) {
79
+ const scalar = step(rope, offset, direction);
80
+ const current = rope.charAt(direction < 0 ? offset - 1 : offset);
81
+ const following = rope.charAt(direction < 0 ? offset : offset + 1);
82
+ if (current === '\n' || current === '\r') {
83
+ return scalar;
84
+ }
85
+ if (isAsciiOrEmpty(current) && isAsciiOrEmpty(following)) {
86
+ return scalar;
87
+ }
88
+ const line = rope.getLine(rope.lineAt(offset));
89
+ let previous = line.start;
90
+ for (const segment of graphemes.segment(line.text)) {
91
+ const at = line.start + segment.index;
92
+ if (direction < 0 && at >= offset) {
93
+ return previous;
94
+ }
95
+ if (direction > 0 && at > offset) {
96
+ return at;
97
+ }
98
+ previous = at;
99
+ }
100
+ return direction < 0 ? previous : line.end;
101
+ }
102
+ /*
103
+ * Clamps every selection and merges the ones that overlap or touch, so the result is disjoint. The
104
+ * last one stays the primary caret, and a merge that takes it in keeps it last.
105
+ */
106
+ function normalizeSelections(rope, selections) {
107
+ const input = selections.length > 0 ? selections : [{ anchor: 0, head: 0 }];
108
+ const result = [];
109
+ for (const [position, raw] of input.entries()) {
110
+ let selection = { anchor: offsetIn(rope, raw.anchor), head: offsetIn(rope, raw.head) };
111
+ let primary = position === input.length - 1;
112
+ let { from, to } = rangeOf(selection);
113
+ for (let index = result.length - 1; index >= 0; index--) {
114
+ const other = rangeOf(result[index].selection);
115
+ if (from <= other.to && to >= other.from) {
116
+ from = Math.min(from, other.from);
117
+ to = Math.max(to, other.to);
118
+ primary ||= result[index].primary;
119
+ result.splice(index, 1);
120
+ selection = selection.anchor > selection.head ? { anchor: to, head: from } : { anchor: from, head: to };
121
+ }
122
+ }
123
+ result.push({ selection, primary });
124
+ }
125
+ const primary = result.findIndex((entry) => entry.primary);
126
+ if (primary !== result.length - 1) {
127
+ result.push(...result.splice(primary, 1));
128
+ }
129
+ return result.map((entry) => entry.selection);
130
+ }
131
+ function inverseChanges(changes) {
132
+ let delta = 0;
133
+ return changes.map((change) => {
134
+ const from = change.from + delta;
135
+ delta += change.insertedLength - (change.to - change.from);
136
+ return { from, to: from + change.insertedLength, insertedLength: change.to - change.from };
137
+ });
138
+ }
139
+ function range(first, last) {
140
+ return Array.from({ length: last - first + 1 }, (_, index) => first + index);
141
+ }
142
+ function blocks(indices) {
143
+ const result = [];
144
+ for (const index of indices) {
145
+ const previous = result.at(-1);
146
+ if (previous && previous.last + 1 === index) {
147
+ previous.last = index;
148
+ }
149
+ else {
150
+ result.push({ first: index, last: index });
151
+ }
152
+ }
153
+ return result;
154
+ }
155
+ /* How much of the whitespace before a caret to keep so that it ends on the previous tab stop. */
156
+ function indentStopLength(prefix, tabSize) {
157
+ const column = indentationColumn(prefix, tabSize);
158
+ const target = Math.floor((column - 1) / tabSize) * tabSize;
159
+ let width = 0;
160
+ let length = 0;
161
+ while (length < prefix.length) {
162
+ const next = width + (prefix[length] === '\t' ? tabSize - (width % tabSize) : 1);
163
+ if (next > target) {
164
+ break;
165
+ }
166
+ width = next;
167
+ length++;
168
+ }
169
+ return length;
170
+ }
171
+ function positionIn(rope, offset) {
172
+ const line = rope.lineAt(offset);
173
+ return { line, column: Math.min(offset, rope.lineBounds(line).end) - rope.lineBounds(line).start };
174
+ }
175
+ /* Simultaneous edits of `rope` as a sequence, last to first, so each edit's position in `rope` is also its position once the ones after it are applied. */
176
+ function editsOf(rope, edits) {
177
+ return [...edits].reverse().map((edit) => ({ start: positionIn(rope, edit.from), end: positionIn(rope, edit.to), text: edit.text }));
178
+ }
179
+ /*
180
+ * What a history step did, as the one stretch that holds every change. The intermediate texts of its
181
+ * transactions are gone, so the stretch runs from the first changed offset to the last, counted from
182
+ * either end, and is a little more than the change when two of them are far apart.
183
+ */
184
+ function stretchEdits(before, after, transactions) {
185
+ let length = before.length;
186
+ let prefix = Number.POSITIVE_INFINITY;
187
+ let suffix = Number.POSITIVE_INFINITY;
188
+ for (const changes of transactions) {
189
+ let delta = 0;
190
+ for (const change of changes) {
191
+ prefix = Math.min(prefix, change.from);
192
+ suffix = Math.min(suffix, length - change.to);
193
+ delta += change.insertedLength - (change.to - change.from);
194
+ }
195
+ length += delta;
196
+ }
197
+ if (!Number.isFinite(prefix)) {
198
+ return [];
199
+ }
200
+ const kept = Math.min(Math.max(0, suffix), before.length - prefix, after.length - prefix);
201
+ const oldEnd = before.length - kept;
202
+ const newEnd = after.length - kept;
203
+ if (oldEnd === prefix && newEnd === prefix) {
204
+ return [];
205
+ }
206
+ return [{ start: positionIn(before, prefix), end: positionIn(before, oldEnd), text: after.slice(prefix, newEnd) }];
207
+ }
208
+ /*
209
+ * Text with a selection, undo history and the editing commands of a code editor, and no idea how it
210
+ * is drawn. Offsets are UTF-16 code units, lines and columns zero-based. The text is a persistent
211
+ * rope, so history shares what an edit left alone.
212
+ */
213
+ export class DocumentModel {
214
+ rope;
215
+ selections = [{ anchor: 0, head: 0 }];
216
+ revision = 0;
217
+ undoStack = [];
218
+ redoStack = [];
219
+ listeners = new Set();
220
+ groupOpen = false;
221
+ textCache;
222
+ structureCache;
223
+ selectionHistory = [];
224
+ typingContexts = new TypingContexts((line) => this.getLine(line));
225
+ statementClosers = new Map();
226
+ cloneSession;
227
+ occurrenceSession;
228
+ /* The last select next occurrence found no more, which a host may say. */
229
+ occurrencesExhausted = false;
230
+ closers = new CloserTracker();
231
+ bracketCache;
232
+ constructor(text = '') {
233
+ this.rope = TextRope.from(text);
234
+ this.textCache = { text };
235
+ }
236
+ getText() {
237
+ return (this.textCache.text ??= this.rope.slice());
238
+ }
239
+ getLength() {
240
+ return this.rope.length;
241
+ }
242
+ /* Counts up with every text change, undo and redo included, and never goes back to an earlier value. */
243
+ getRevision() {
244
+ return this.revision;
245
+ }
246
+ /* An empty document has one line, and a trailing line break adds an empty last one. */
247
+ getLineCount() {
248
+ return this.rope.lineCount;
249
+ }
250
+ /* Out of range lines are clamped. */
251
+ getLine(line) {
252
+ return this.rope.getLine(line);
253
+ }
254
+ /* Plain UTF-16 substring semantics, so it can cut a surrogate pair in half. */
255
+ slice(from, to) {
256
+ return this.rope.slice(from, to);
257
+ }
258
+ getSelections() {
259
+ return this.selections.map((selection) => ({ ...selection }));
260
+ }
261
+ getSelectionCount() {
262
+ return this.selections.length;
263
+ }
264
+ /* The caret added last, which is where the view scrolls to and what a language feature reads. */
265
+ getPrimary() {
266
+ return { ...this.selections.at(-1) };
267
+ }
268
+ getSnapshot() {
269
+ const rope = this.rope;
270
+ const cache = this.textCache;
271
+ let text;
272
+ return {
273
+ // Bound to this version's rope, so a retained snapshot keeps its own text however the document moves on.
274
+ get text() {
275
+ return (text ??= cache.text ??= rope.slice());
276
+ },
277
+ set text(value) {
278
+ text = value;
279
+ },
280
+ selections: this.getSelections(),
281
+ revision: this.revision,
282
+ canUndo: this.undoStack.length > 0,
283
+ canRedo: this.redoStack.length > 0
284
+ };
285
+ }
286
+ /* Ends the current history group. Listeners hear about it only when the selections changed. */
287
+ setSelections(selections) {
288
+ const normalized = normalizeSelections(this.rope, selections);
289
+ this.groupOpen = false;
290
+ this.occurrenceSession = undefined;
291
+ this.cloneSession = undefined;
292
+ this.selectionHistory = [];
293
+ this.statementClosers.clear();
294
+ if (sameSelections(this.selections, normalized)) {
295
+ return;
296
+ }
297
+ this.selections = normalized;
298
+ this.closers.prune(this.selections);
299
+ this.emit();
300
+ }
301
+ subscribe(listener) {
302
+ this.listeners.add(listener);
303
+ return {
304
+ dispose: () => {
305
+ this.listeners.delete(listener);
306
+ }
307
+ };
308
+ }
309
+ /*
310
+ * Applies simultaneous edits in the coordinates of the current text. Invalid or overlapping edits
311
+ * throw before anything changes. Returns whether the text changed, which a no-op never does.
312
+ */
313
+ applyEdits(edits, options = {}) {
314
+ if (options.expectedRevision !== undefined && options.expectedRevision !== this.revision) {
315
+ return false;
316
+ }
317
+ const unique = this.validateEdits(edits);
318
+ const changed = unique.filter((edit) => changesText(this.rope, edit));
319
+ let next = this.rope;
320
+ for (let index = changed.length - 1; index >= 0; index--) {
321
+ const edit = changed[index];
322
+ next = next.replace(edit.from, edit.to, edit.text);
323
+ }
324
+ const nextSelections = options.selections ??
325
+ this.selections.map((selection) => ({ anchor: mapOffset(selection.anchor, unique), head: mapOffset(selection.head, unique) }));
326
+ // Several edits can cancel each other out, like moving a character and moving it back.
327
+ const unchanged = next === this.rope || (changed.length > 1 && next.length === this.rope.length && next.equalsRange(this.rope, changed[0].from, changed.at(-1).to));
328
+ if (unchanged) {
329
+ if (options.selections) {
330
+ this.setSelections(nextSelections);
331
+ }
332
+ return false;
333
+ }
334
+ const before = this.state();
335
+ const firstLine = this.rope.lineAt(changed[0].from);
336
+ const previous = this.rope;
337
+ this.rope = next;
338
+ this.invalidate();
339
+ this.typingContexts.invalidate(firstLine);
340
+ this.selections = normalizeSelections(this.rope, nextSelections);
341
+ this.closers.map(changed);
342
+ this.closers.prune(this.selections);
343
+ this.revision++;
344
+ const after = this.state();
345
+ const changes = changed.map((edit) => ({ from: edit.from, to: edit.to, insertedLength: edit.text.length }));
346
+ const last = this.undoStack.at(-1);
347
+ if (options.historyGroup &&
348
+ this.groupOpen &&
349
+ last?.group === options.historyGroup &&
350
+ last.after.rope === before.rope &&
351
+ sameSelections(last.after.selections, before.selections)) {
352
+ last.after = after;
353
+ last.changes.push(changes);
354
+ }
355
+ else {
356
+ this.undoStack.push({ before, after, group: options.historyGroup, changes: [changes] });
357
+ }
358
+ if (this.undoStack.length > historyLimit) {
359
+ this.undoStack.shift();
360
+ }
361
+ this.groupOpen = Boolean(options.historyGroup);
362
+ this.redoStack = [];
363
+ this.emit([changes], options.source ?? 'external', () => editsOf(previous, changed));
364
+ return true;
365
+ }
366
+ /* Replaces everything as one undoable external edit. */
367
+ setText(text) {
368
+ this.applyEdits([{ from: 0, to: this.rope.length, text }], { source: 'external' });
369
+ }
370
+ undo() {
371
+ const entry = this.undoStack.pop();
372
+ if (!entry) {
373
+ return false;
374
+ }
375
+ this.redoStack.push(entry);
376
+ this.restore(entry.before, [...entry.changes].reverse().map(inverseChanges));
377
+ return true;
378
+ }
379
+ redo() {
380
+ const entry = this.redoStack.pop();
381
+ if (!entry) {
382
+ return false;
383
+ }
384
+ this.undoStack.push(entry);
385
+ this.restore(entry.after, entry.changes);
386
+ return true;
387
+ }
388
+ positionAt(offset) {
389
+ const clamped = offsetIn(this.rope, offset);
390
+ const line = this.rope.lineAt(clamped);
391
+ const bounds = this.rope.lineBounds(line);
392
+ return { line, column: Math.min(clamped, bounds.end) - bounds.start };
393
+ }
394
+ /* A column past the end of its line lands on the end of the line, before the line break. */
395
+ offsetAt(position) {
396
+ const line = this.rope.lineBounds(position.line);
397
+ return offsetIn(this.rope, line.start + clampInteger(position.column, line.end - line.start));
398
+ }
399
+ find(query, options = {}) {
400
+ return findMatches(this.getText(), query, this.revision, options);
401
+ }
402
+ /* Includes a match that starts at `from`, or ends there when searching backwards. */
403
+ findNext(query, from = this.selections.at(-1).head, options = {}) {
404
+ const matches = this.find(query, options);
405
+ const offset = clampInteger(from, this.rope.length);
406
+ const ordered = options.backwards ? matches.reverse() : matches;
407
+ const found = ordered.find((match) => (options.backwards ? match.to <= offset : match.from >= offset));
408
+ return found ?? (options.wrap === false ? null : (ordered[0] ?? null));
409
+ }
410
+ /* Refuses a match from an earlier revision, or one whose text no longer sits there. */
411
+ replace(match, replacement, options = {}) {
412
+ if (match.revision !== this.revision || (options.expectedRevision !== undefined && options.expectedRevision !== this.revision)) {
413
+ return false;
414
+ }
415
+ if (this.slice(match.from, match.to) !== match.text) {
416
+ return false;
417
+ }
418
+ return this.applyEdits([{ from: match.from, to: match.to, text: replacementText(this.getText(), match, replacement, options.literal, options.preserveCase) }], {
419
+ source: 'command',
420
+ ...options,
421
+ expectedRevision: match.revision
422
+ });
423
+ }
424
+ /* One undo step. Returns the number of replacements, 0 when nothing changed. */
425
+ replaceAll(query, replacement, options = {}) {
426
+ if (options.expectedRevision !== undefined && options.expectedRevision !== this.revision) {
427
+ return 0;
428
+ }
429
+ const matches = this.find(query, options);
430
+ const text = this.getText();
431
+ const edits = matches.map((match) => ({
432
+ from: match.from,
433
+ to: match.to,
434
+ text: replacementText(text, match, replacement, options.literal, options.preserveCase)
435
+ }));
436
+ return this.applyEdits(edits, { source: 'command', ...options }) ? matches.length : 0;
437
+ }
438
+ getFoldingRanges(options = {}) {
439
+ const ranges = options.brackets === false && options.comments === false ? [] : this.structure();
440
+ const lineAt = (offset) => this.rope.lineAt(offset);
441
+ const getLine = (line) => this.getLine(line);
442
+ const slice = (from, to) => this.slice(from, to);
443
+ const derived = deriveFoldingRanges(ranges, lineAt, this.getLineCount(), getLine, options, slice);
444
+ if (options.language === undefined) {
445
+ return derived;
446
+ }
447
+ const minimum = options.minLines === undefined ? 1 : Math.max(1, Math.trunc(options.minLines) || 1);
448
+ // Listed first, so a fold that spans the same lines as a bracket pair is the import list and not the pair.
449
+ const merged = [
450
+ ...lexicalFolds(this.getLineCount(), getLine, options.language, options),
451
+ ...blockFolds(this.getLineCount(), getLine, options.language),
452
+ ...derived
453
+ ].filter((range) => range.endLine - range.startLine >= minimum);
454
+ merged.sort((left, right) => left.startLine - right.startLine || right.endLine - left.endLine);
455
+ const unique = dedupeFoldingRanges(merged);
456
+ markFileHeader(unique, getLine, this.getLineCount(), options.language);
457
+ return options.hints === undefined ? unique : applyFoldHints(unique, options.hints, lineAt, getLine, slice, minimum);
458
+ }
459
+ /*
460
+ * Types `text` at every caret as one keystroke would: it wraps a selection in a bracket or quote,
461
+ * closes an opener, types over a closer that is already there and, in TypeScript, JavaScript and
462
+ * PHP, moves a `;` to the end of a statement. Comments and string contents are left alone.
463
+ * Returns whether anything changed, a swallowed closer included.
464
+ */
465
+ typeText(text, options = {}) {
466
+ if (options.expectedRevision !== undefined && options.expectedRevision !== this.revision) {
467
+ return false;
468
+ }
469
+ if (!text) {
470
+ return false;
471
+ }
472
+ const language = options.language ?? 'typescript';
473
+ const single = text.length === 1;
474
+ const pairing = options.autoClosingPairs !== false && single && !isPlainText(language);
475
+ const input = {
476
+ text,
477
+ language,
478
+ brackets: pairing && options.autoClosingBrackets !== false,
479
+ quotes: pairing && options.autoClosingQuotes !== false,
480
+ surround: options.surroundSelection !== false && single,
481
+ smartSemicolon: options.smartSemicolon !== false && hasSmartSemicolon(language),
482
+ dedent: options.smartEnter !== false
483
+ };
484
+ const plans = this.selections.map((selection) => this.planTyping(selection, input));
485
+ const transaction = selectionTransaction(plans);
486
+ const textChanges = transaction.edits.some((edit) => changesText(this.rope, edit));
487
+ const appliedRevision = this.revision + Number(textChanges);
488
+ const changed = this.applySelectionEdits(plans, { source: 'input', historyGroup: options.historyGroup ?? 'typing', expectedRevision: options.expectedRevision }, transaction);
489
+ this.statementClosers.clear();
490
+ if (this.revision === appliedRevision) {
491
+ this.rememberStatementClosers(plans, transaction, language);
492
+ this.rememberPairs(plans, transaction);
493
+ }
494
+ return changed || plans.some((plan) => plan.consumed);
495
+ }
496
+ /*
497
+ * Pastes `text` at every selection. With as many lines as carets each caret takes one; `wholeLines`
498
+ * marks text copied from a bare caret, which goes above the lines of bare carets.
499
+ */
500
+ paste(text, options = {}) {
501
+ const tabSize = tabWidth(options);
502
+ const plan = planPaste(this.editSource(options.language ?? 'typescript'), this.selections, text.replace(/\r\n|\r|\n/g, this.newlineAt(0)), {
503
+ tabSize,
504
+ insertSpaces: options.insertSpaces !== false,
505
+ wholeLines: options.wholeLines === true,
506
+ reindent: options.indentOnPaste !== false
507
+ });
508
+ return this.applyEdits(plan.edits, { source: 'input', selections: plan.selections });
509
+ }
510
+ /* Runs a command on every caret. Returns whether the text or the selection changed. */
511
+ execute(command, options = {}) {
512
+ if (command === 'undo') {
513
+ return this.undo();
514
+ }
515
+ if (command === 'redo') {
516
+ return this.redo();
517
+ }
518
+ if (command === 'selectAll') {
519
+ return this.changeSelections([{ anchor: 0, head: this.rope.length }]);
520
+ }
521
+ if (command === 'expandSelection') {
522
+ return this.expandSelection();
523
+ }
524
+ if (command === 'shrinkSelection') {
525
+ return this.shrinkSelection();
526
+ }
527
+ if (command === 'smartHome' || command === 'smartEnd' || command === 'selectSmartHome' || command === 'selectSmartEnd') {
528
+ return this.moveToLineEdge(command, tabWidth(options), options.visualLine);
529
+ }
530
+ const wordMatch = wordCommand.exec(command);
531
+ if (wordMatch) {
532
+ const camel = wordMatch[2].toLowerCase() === 'camel' || options.camelCase === true;
533
+ return this.moveByWord(wordMatch[1], wordMatch[3] === 'Left' ? -1 : 1, camel, options.lineSpan);
534
+ }
535
+ if (command === 'addCaretAbove' || command === 'addCaretBelow') {
536
+ return this.addCaret(command === 'addCaretAbove' ? -1 : 1, options.lineSpan);
537
+ }
538
+ if (command === 'selectNextOccurrence') {
539
+ return this.selectNextOccurrence();
540
+ }
541
+ if (command === 'unselectOccurrence') {
542
+ return this.unselectOccurrence();
543
+ }
544
+ if (command === 'selectAllOccurrences') {
545
+ return this.selectAllOccurrences();
546
+ }
547
+ if (command === 'addCaretPerSelectedLine') {
548
+ return this.addCaretPerSelectedLine();
549
+ }
550
+ const tabSize = tabWidth(options);
551
+ const language = options.language ?? 'typescript';
552
+ if (command === 'smartBackspace' || command === 'deleteForward') {
553
+ const pairing = options.autoClosingPairs !== false && !isPlainText(language);
554
+ return this.deleteCharacter(command === 'smartBackspace', tabSize, { brackets: pairing && options.autoClosingBrackets !== false, quotes: pairing && options.autoClosingQuotes !== false }, language);
555
+ }
556
+ const unit = options.insertSpaces === false ? '\t' : ' '.repeat(tabSize);
557
+ const enter = { language, unit, tabSize, smart: options.smartEnter !== false, reach: true };
558
+ if (command === 'insertNewline' || command === 'startNewLine' || command === 'splitLine') {
559
+ return this.insertNewline(enter, command === 'insertNewline' ? 'enter' : command === 'startNewLine' ? 'below' : 'split');
560
+ }
561
+ if (command === 'startNewLineBefore') {
562
+ return this.startNewLineBefore(unit);
563
+ }
564
+ if (command === 'joinLines' || command === 'toggleCase' || command === 'autoIndentLines') {
565
+ return this.editLines(command, { language, unit });
566
+ }
567
+ if (command === 'insertTab') {
568
+ return this.insertTab(tabSize, options);
569
+ }
570
+ if (command === 'toggleLineComment' || command === 'toggleBlockComment') {
571
+ return this.toggleComment(command, tabSize, options, language);
572
+ }
573
+ if (command === 'indent' || command === 'outdent') {
574
+ return this.changeLineIndentation(command, tabSize, options);
575
+ }
576
+ if (command === 'duplicateLine') {
577
+ return this.duplicate(options.lineSpan);
578
+ }
579
+ if (command === 'deleteLine') {
580
+ return this.deleteLines(this.selectedLines(options.lineSpan));
581
+ }
582
+ if (command === 'moveLineUp' || command === 'moveLineDown') {
583
+ return this.moveLines(this.selectedLines(options.lineSpan), command === 'moveLineUp' ? -1 : 1, options);
584
+ }
585
+ return false;
586
+ }
587
+ /* Sorted, with duplicates dropped. Throws before anything changes if an edit is invalid. */
588
+ validateEdits(edits) {
589
+ const sorted = [...edits].sort((left, right) => left.from - right.from || left.to - right.to);
590
+ const unique = [];
591
+ for (const edit of sorted) {
592
+ if (!Number.isInteger(edit.from) || !Number.isInteger(edit.to) || edit.from < 0 || edit.to < edit.from || edit.to > this.rope.length) {
593
+ throw new RangeError('Edit offsets must be ordered integers inside the document.');
594
+ }
595
+ if (typeof edit.text !== 'string') {
596
+ throw new TypeError('Edit text must be a string.');
597
+ }
598
+ if (splitsSurrogate(this.rope, edit.from) || splitsSurrogate(this.rope, edit.to)) {
599
+ throw new RangeError('Edit boundaries cannot split a UTF-16 surrogate pair.');
600
+ }
601
+ const previous = unique.at(-1);
602
+ if (previous && edit.from === previous.from && edit.to === previous.to && edit.text === previous.text) {
603
+ continue;
604
+ }
605
+ if (previous && (edit.from < previous.to || (edit.from === previous.from && edit.to === previous.to))) {
606
+ throw new RangeError('Simultaneous edits must not overlap.');
607
+ }
608
+ unique.push({ ...edit });
609
+ }
610
+ return unique;
611
+ }
612
+ planTyping(selection, input) {
613
+ const { text, language, smartSemicolon } = input;
614
+ const { from, to } = rangeOf(selection);
615
+ const after = this.rope.charAt(to);
616
+ const quote = isQuote(text);
617
+ const pairing = quote ? input.quotes : input.brackets;
618
+ const pendingClosers = this.statementClosers.get(from);
619
+ if (input.brackets && smartSemicolon && from === to && pendingClosers?.language === language && pendingClosers.text.startsWith(text)) {
620
+ return { from, to, text: '', anchor: 0, head: 0, skip: true, consumed: true, completedClosers: pendingClosers.text.slice(1) };
621
+ }
622
+ const needsContext = text.length === 1 && (/[()[\]{}'"`<]/.test(text) || (text === ';' && smartSemicolon));
623
+ const context = needsContext ? this.typingContext(from, language) : undefined;
624
+ const insideQuote = context && (context.mode === 'quote' || context.mode === 'template') && context.quote === text && !context.escaped;
625
+ if (pairing && from === to && after === text && (quote ? insideQuote : context?.mode === 'code' && context.bracket?.close === text)) {
626
+ return { from, to, text: '', anchor: 1, head: 1, skip: true };
627
+ }
628
+ if (text === ';' && from === to && smartSemicolon && context?.mode === 'code') {
629
+ const end = this.semicolonTarget(from, context);
630
+ if (end !== undefined) {
631
+ const existing = this.rope.charAt(end) === ';';
632
+ return {
633
+ from: end,
634
+ to: end,
635
+ text: existing ? '' : ';',
636
+ anchor: 1,
637
+ head: 1,
638
+ skip: existing,
639
+ completedClosers: this.slice(from, end).replace(/[^)\]]/g, '')
640
+ };
641
+ }
642
+ }
643
+ if (from !== to && input.surround && (pairs[text] !== undefined || text === '<')) {
644
+ const wrapped = this.surroundSelection(selection, input, context);
645
+ if (wrapped) {
646
+ return wrapped;
647
+ }
648
+ }
649
+ if (pairing && pairs[text] !== undefined && from === to && this.shouldClosePair(from, text, after, context, input)) {
650
+ return { from, to, text: text + pairs[text], anchor: 1, head: 1, paired: true };
651
+ }
652
+ if (input.dedent && from === to && /[)\]}]/.test(text) && context?.mode === 'code' && context.bracket?.close === text) {
653
+ const dedented = this.dedentCloser(from, text, context.bracket.at);
654
+ if (dedented) {
655
+ return dedented;
656
+ }
657
+ }
658
+ return { from, to, text, anchor: text.length, head: text.length };
659
+ }
660
+ /* A delimiter over a selection wraps it, or swaps the quotes of the string it is the quote of. */
661
+ surroundSelection(selection, input, context) {
662
+ const { text, language } = input;
663
+ const { from, to } = rangeOf(selection);
664
+ const selected = this.slice(from, to);
665
+ if (input.quotes && isQuote(text) && selected.length === 1) {
666
+ const swapped = swapQuotes(this.editSource(language), from, text);
667
+ if (swapped) {
668
+ return { from: swapped.from, to: swapped.to, text: swapped.text, anchor: swapped.caret, head: swapped.caret };
669
+ }
670
+ }
671
+ const inCommentOrString = context !== undefined && context.mode !== 'code';
672
+ if (replacesComparison(text, selected, inCommentOrString)) {
673
+ return null;
674
+ }
675
+ const wrapped = surround(selected, text);
676
+ const reversed = selection.anchor > selection.head;
677
+ const start = 1;
678
+ const end = 1 + wrapped.inner.length;
679
+ return { from, to, text: wrapped.text, anchor: reversed ? end : start, head: reversed ? start : end };
680
+ }
681
+ /* A closer alone on its line goes back to the indentation of the line its opener is on. */
682
+ dedentCloser(offset, closer, opener) {
683
+ const index = this.rope.lineAt(offset);
684
+ const line = this.rope.lineBounds(index);
685
+ const before = this.slice(line.start, offset);
686
+ if (!/^[\t ]*$/.test(before)) {
687
+ return null;
688
+ }
689
+ const indent = this.getLine(this.rope.lineAt(opener)).text.match(/^[\t ]*/)?.[0] ?? '';
690
+ if (indent === before) {
691
+ return null;
692
+ }
693
+ return { from: line.start, to: offset, text: indent + closer, anchor: indent.length + 1, head: indent.length + 1 };
694
+ }
695
+ shouldClosePair(offset, opener, after, context, input) {
696
+ const interpolation = this.isInterpolation(offset, opener, context);
697
+ const quote = isQuote(opener);
698
+ const followedByTerminator = quote ? !identifier.test(after) && after !== opener : !after || /[\s)\]}>,;:]/u.test(after);
699
+ if (context?.mode !== 'code' && !interpolation) {
700
+ return false;
701
+ }
702
+ if (!followedByTerminator && !(interpolation && after === '`')) {
703
+ return false;
704
+ }
705
+ if (quote) {
706
+ // A quote after a word is an apostrophe or the end of a string, not the start of one.
707
+ return (!identifier.test(this.slice(step(this.rope, offset, -1), offset)) &&
708
+ !this.escaped(offset) &&
709
+ !this.completesTripleQuote(offset, opener, input.language));
710
+ }
711
+ return !this.hasMatch(offset, opener, input.language);
712
+ }
713
+ /* The third quote of a Python docstring opener is typed as it is, since the pair that came before it is the empty string. */
714
+ completesTripleQuote(offset, quote, language) {
715
+ return /^(python|py)$/i.test(language) && quote !== '`' && this.slice(offset - 2, offset) === quote + quote;
716
+ }
717
+ /* Whether the bracket just typed at `offset` would find its closer in the text that already follows it. */
718
+ hasMatch(offset, opener, language) {
719
+ if (this.rope.length > matchScanLimit) {
720
+ return false;
721
+ }
722
+ const text = this.getText();
723
+ const scanned = scanBrackets(text.slice(0, offset) + opener + text.slice(offset), language);
724
+ return scanned.pairs.has(offset);
725
+ }
726
+ /* Remembers the closers a smart semicolon stepped over for the caret that did it, so typing them again is absorbed. */
727
+ rememberStatementClosers(plans, transaction, language) {
728
+ for (const [index, selection] of transaction.selections.entries()) {
729
+ const closers = plans[index]?.completedClosers;
730
+ if (!closers) {
731
+ continue;
732
+ }
733
+ const caretIsThere = this.selections.some((current) => current.anchor === selection.anchor && current.head === selection.head);
734
+ if (caretIsThere && closers.length > (this.statementClosers.get(selection.head)?.text.length ?? 0)) {
735
+ this.statementClosers.set(selection.head, { text: closers, language });
736
+ }
737
+ }
738
+ }
739
+ rememberPairs(plans, transaction) {
740
+ for (const [index, selection] of transaction.selections.entries()) {
741
+ if (plans[index]?.paired) {
742
+ this.closers.add(selection.head - 1, selection.head);
743
+ }
744
+ }
745
+ }
746
+ /*
747
+ * Tab without a selection: up to the next tab stop, or over a closer the editor inserted. With a
748
+ * selection it indents the lines, as it does in front of other text.
749
+ */
750
+ insertTab(tabSize, options) {
751
+ if (this.selections.some((selection) => selection.anchor !== selection.head)) {
752
+ return this.changeLineIndentation('indent', tabSize, options);
753
+ }
754
+ const tabOut = options.tabOutOfClosers !== false;
755
+ // A caret further along a line stands where the spaces of the carets before it moved it to.
756
+ const moved = new Map();
757
+ const widths = new Map();
758
+ for (const selection of [...this.selections].sort((left, right) => left.head - right.head)) {
759
+ if (tabOut && this.closers.isCloserAt(selection.head)) {
760
+ continue;
761
+ }
762
+ const line = this.rope.lineAt(selection.head);
763
+ const column = this.columnOf(selection.head, tabSize) + (moved.get(line) ?? 0);
764
+ const width = tabSize - (column % tabSize);
765
+ widths.set(selection, width);
766
+ moved.set(line, (moved.get(line) ?? 0) + width);
767
+ }
768
+ return this.applySelectionEdits(this.selections.map((selection) => {
769
+ const offset = selection.head;
770
+ if (tabOut && this.closers.isCloserAt(offset)) {
771
+ return { from: offset, to: offset, text: '', anchor: 1, head: 1, skip: true };
772
+ }
773
+ const text = options.insertSpaces === false ? '\t' : ' '.repeat(widths.get(selection));
774
+ return { from: offset, to: offset, text, anchor: text.length, head: text.length };
775
+ }), { source: 'command' });
776
+ }
777
+ /* The visual column of an offset, with tabs going to the next tab stop. */
778
+ columnOf(offset, tabSize) {
779
+ return indentationColumn(this.slice(this.rope.lineBounds(this.rope.lineAt(offset)).start, offset), tabSize);
780
+ }
781
+ moveToLineEdge(command, tabSize, visualLine) {
782
+ const home = command.endsWith('Home');
783
+ const select = command.startsWith('select');
784
+ return this.changeSelections(this.selections.map((selection) => {
785
+ const head = home ? this.homeOffset(selection.head, tabSize, visualLine) : this.endOffset(selection.head, visualLine);
786
+ return { anchor: select ? selection.anchor : head, head };
787
+ }));
788
+ }
789
+ /* The first character on a line that is not a space or a tab, or -1 when there is none. */
790
+ firstNonSpace(line) {
791
+ const found = line.text.search(/[^ \t]/);
792
+ return found === -1 ? -1 : line.start + found;
793
+ }
794
+ /*
795
+ * Home. On a wrapped row it goes to the row's start, and from there to the first character of the line.
796
+ * On the line itself it alternates between the first character and the start: from the start to the
797
+ * first character (or, on a blank line, to where the nearest line above begins), from anywhere in the
798
+ * text to the first character, and from the first character or inside the indentation to the start.
799
+ */
800
+ homeOffset(offset, tabSize, visualLine) {
801
+ const line = this.getLine(this.rope.lineAt(offset));
802
+ const row = visualLine?.(offset) ?? line;
803
+ if (row.start > line.start) {
804
+ return offset > row.start ? row.start : Math.max(this.firstNonSpace(line), line.start);
805
+ }
806
+ const indent = this.firstNonSpace(line);
807
+ if (offset === line.start) {
808
+ return this.offsetAtColumn(line, this.smartIndentColumn(line.start, tabSize), tabSize);
809
+ }
810
+ return indent !== -1 && indent < offset ? indent : line.start;
811
+ }
812
+ /* The visual column of the first character of this line, or of the nearest line above that has one. */
813
+ smartIndentColumn(offset, tabSize) {
814
+ for (let index = this.rope.lineAt(offset); index >= 0; index--) {
815
+ const line = this.getLine(index);
816
+ const indent = this.firstNonSpace(line);
817
+ if (indent !== -1) {
818
+ return indentationColumn(line.text.slice(0, indent - line.start), tabSize);
819
+ }
820
+ }
821
+ return 0;
822
+ }
823
+ /* The offset on a line at a visual column, with a tab as wide as the stop it goes to; a line that is shorter ends first. */
824
+ offsetAtColumn(line, column, tabSize) {
825
+ if (column <= 0) {
826
+ return line.start;
827
+ }
828
+ let width = 0;
829
+ for (let index = 0; index < line.text.length && width < column; index++) {
830
+ width += line.text[index] === '\t' ? tabSize - (width % tabSize) : 1;
831
+ if (width >= column) {
832
+ return line.start + index + 1;
833
+ }
834
+ }
835
+ return line.end;
836
+ }
837
+ /*
838
+ * End. On a wrapped row it goes to the end of the row's text and then to the row's end; from the row's
839
+ * end it goes on to the end of the line's text and then to the line's end. Trailing spaces are skipped on
840
+ * the way, and a stop at the end of the text sends the next one past them.
841
+ */
842
+ endOffset(offset, visualLine) {
843
+ const line = this.getLine(this.rope.lineAt(offset));
844
+ const row = visualLine?.(offset) ?? line;
845
+ let end = row.end;
846
+ if (offset === row.end && offset < this.rope.length) {
847
+ end = line.end;
848
+ }
849
+ let target = end;
850
+ for (let at = end - 1; at >= line.start; at--) {
851
+ if (at > line.start && visualLine !== undefined && visualLine(at).start === at) {
852
+ target = end;
853
+ break;
854
+ }
855
+ const character = this.rope.charAt(at);
856
+ if (character !== ' ' && character !== '\t') {
857
+ break;
858
+ }
859
+ target = at;
860
+ }
861
+ return target === end || target === offset ? end : target;
862
+ }
863
+ moveByWord(mode, direction, camel, span) {
864
+ if (mode === 'delete') {
865
+ const stops = direction === 1 ? DELETE_FORWARD : DELETE_BACKWARD;
866
+ return this.deleteRanges(this.selections.map((selection) => {
867
+ const head = selection.head === selection.anchor ? this.caretStop(selection.head, direction, stops, camel) : selection.anchor;
868
+ return { from: Math.min(selection.head, head), to: Math.max(selection.head, head), text: '' };
869
+ }));
870
+ }
871
+ const stops = direction === 1 ? MOVE_FORWARD : MOVE_BACKWARD;
872
+ return this.changeSelections(this.selections.map((selection) => {
873
+ const head = this.foldedStop(selection.head, direction, this.caretStop(selection.head, direction, stops, camel), span);
874
+ return { anchor: mode === 'select' ? selection.anchor : head, head };
875
+ }));
876
+ }
877
+ /*
878
+ * The next stop of a word move: a word edge as `stops` says, but never past the line stop, so a line
879
+ * break is a stop of its own and an empty line is one. Nowhere to go leaves the offset where it is.
880
+ */
881
+ caretStop(offset, direction, stops, camel) {
882
+ const line = this.rope.lineAt(offset);
883
+ const edge = direction === 1 ? this.nextLineStop(line, offset, stops.line) : this.previousLineStop(line, offset, stops.line);
884
+ if (offset === edge) {
885
+ return offset;
886
+ }
887
+ const isStop = (position) => ((stops.word !== 'start' && isWordBoundary(this.rope, position, camel, false)) ||
888
+ (stops.word !== 'end' && isWordBoundary(this.rope, position, camel, true))) &&
889
+ !splitsCluster(this.rope, position);
890
+ let position = offset + direction;
891
+ while (direction === 1 ? position < edge : position > edge) {
892
+ if (isStop(position)) {
893
+ break;
894
+ }
895
+ position += direction;
896
+ }
897
+ return position;
898
+ }
899
+ /*
900
+ * A collapsed fold is one stop of a word move, as the platform has it: from the end of the first line
901
+ * the caret goes past the fold, and a stop inside it is the end of the first line. The stop after the
902
+ * fold is the start of the line that follows it, since the end of its last line is hidden.
903
+ */
904
+ foldedStop(offset, direction, stop, span) {
905
+ if (span === undefined) {
906
+ return stop;
907
+ }
908
+ const fold = (position) => {
909
+ const { first, last } = span(this.rope.lineAt(position));
910
+ if (last === first) {
911
+ return null;
912
+ }
913
+ return { from: this.rope.lineBounds(first).end, to: this.rope.lineBounds(last).end, next: this.rope.lineBounds(last).next };
914
+ };
915
+ const current = direction === 1 ? fold(offset) : fold(offset - 1);
916
+ if (direction === 1 && current !== null && current.from <= offset && offset < current.to) {
917
+ return current.next > current.to ? current.next : offset;
918
+ }
919
+ if (direction === -1 && current !== null && current.from <= offset - 1 && offset - 1 < current.to) {
920
+ return current.from;
921
+ }
922
+ const landing = fold(stop);
923
+ return landing !== null && landing.from < stop && stop <= landing.to ? landing.from : stop;
924
+ }
925
+ nextLineStop(line, offset, stop) {
926
+ if (line + 1 >= this.rope.lineCount) {
927
+ return this.rope.length;
928
+ }
929
+ const atEnd = offset === this.rope.lineBounds(line).end;
930
+ if (!atEnd && stop !== 'start') {
931
+ return this.rope.lineBounds(line).end;
932
+ }
933
+ return stop === 'end' ? this.rope.lineBounds(line + 1).end : this.rope.lineBounds(line + 1).start;
934
+ }
935
+ previousLineStop(line, offset, stop) {
936
+ if (line === 0) {
937
+ return 0;
938
+ }
939
+ const atStart = offset === this.rope.lineBounds(line).start;
940
+ if (!atStart && stop !== 'end') {
941
+ return this.rope.lineBounds(line).start;
942
+ }
943
+ return stop === 'start' ? this.rope.lineBounds(line - 1).start : this.rope.lineBounds(line - 1).end;
944
+ }
945
+ /*
946
+ * A caret, and the selection with it, on the line above or below each caret, at the column the caret
947
+ * came from even when a line in between is shorter. Pressed again the other way it takes back the carets
948
+ * it added, one step at a time, before it adds any on that side.
949
+ */
950
+ addCaret(direction, span) {
951
+ const session = this.cloneSession;
952
+ const frames = session !== undefined && session.signature === this.occurrenceSignature() ? session.frames : [];
953
+ const top = frames.at(-1);
954
+ if (top !== undefined && top.direction !== direction) {
955
+ const taken = new Set(top.carets.map((clone) => `${clone.selection.anchor}:${clone.selection.head}`));
956
+ const kept = this.selections.filter((selection) => !taken.has(`${selection.anchor}:${selection.head}`));
957
+ frames.pop();
958
+ const changed = this.changeSelections(kept.length > 0 ? kept : this.selections.slice(0, 1));
959
+ this.cloneSession = { signature: this.occurrenceSignature(), frames };
960
+ return changed;
961
+ }
962
+ const sources = top === undefined ? this.selections.map((selection) => this.cloneSource(selection)) : top.carets;
963
+ const clones = [];
964
+ for (const source of sources) {
965
+ const clone = this.cloneOnto(source, direction, [...this.selections, ...clones.map((added) => added.selection)], span);
966
+ if (clone !== null) {
967
+ clones.push(clone);
968
+ }
969
+ }
970
+ if (clones.length === 0) {
971
+ return false;
972
+ }
973
+ const changed = this.changeSelections([...this.selections, ...clones.map((clone) => clone.selection)]);
974
+ frames.push({ direction, carets: clones });
975
+ this.cloneSession = { signature: this.occurrenceSignature(), frames };
976
+ return changed;
977
+ }
978
+ cloneSource(selection) {
979
+ const { from, to } = rangeOf(selection);
980
+ return {
981
+ selection,
982
+ caretColumn: this.positionAt(selection.head).column,
983
+ startColumn: this.positionAt(from).column,
984
+ endColumn: this.positionAt(to).column
985
+ };
986
+ }
987
+ /* The clone of a caret on a neighboring line, or on the next one that can hold its selection; null at the end of the text or on top of another caret. */
988
+ cloneOnto(source, direction, existing, span) {
989
+ const { selection } = source;
990
+ const { from, to } = rangeOf(selection);
991
+ const startLine = this.rope.lineAt(from);
992
+ const endLine = this.rope.lineAt(to);
993
+ const caretLine = this.rope.lineAt(selection.head);
994
+ for (let shift = direction;; shift += direction) {
995
+ const line = caretLine + shift;
996
+ if (line < 0 || line >= this.getLineCount()) {
997
+ return null;
998
+ }
999
+ if (span !== undefined && span(line).first !== line) {
1000
+ continue;
1001
+ }
1002
+ const start = this.offsetAt({ line: startLine + shift, column: source.startColumn });
1003
+ const end = this.offsetAt({ line: endLine + shift, column: source.endColumn });
1004
+ if (from !== to && start === end) {
1005
+ continue;
1006
+ }
1007
+ const caret = this.offsetAt({ line, column: source.caretColumn });
1008
+ const cloned = from === to ? { anchor: caret, head: caret } : selection.head === to ? { anchor: start, head: end } : { anchor: end, head: start };
1009
+ const range = rangeOf(cloned);
1010
+ if (existing.some((other) => {
1011
+ const taken = rangeOf(other);
1012
+ return range.from === range.to && taken.from === taken.to ? range.from === taken.from : range.from < taken.to && range.to > taken.from;
1013
+ })) {
1014
+ return null;
1015
+ }
1016
+ return { ...source, selection: cloned };
1017
+ }
1018
+ }
1019
+ changeLineIndentation(command, tabSize, options) {
1020
+ const indentation = options.insertSpaces === false ? '\t' : ' '.repeat(tabSize);
1021
+ const edits = this.selectedLines().map((index) => {
1022
+ const line = this.getLine(index);
1023
+ if (command === 'indent') {
1024
+ return { from: line.start, to: line.start, text: indentation };
1025
+ }
1026
+ const length = line.text.startsWith('\t') ? 1 : Math.min(line.text.match(/^ */)?.[0].length ?? 0, tabSize);
1027
+ return { from: line.start, to: line.start + length, text: '' };
1028
+ });
1029
+ return this.applyEdits(edits, { source: 'command' });
1030
+ }
1031
+ toggleComment(command, tabSize, options, language) {
1032
+ const commentOptions = { language, tabSize, insertSpaces: options.insertSpaces !== false, lineToken: options.commentToken, lineSpan: options.lineSpan };
1033
+ const source = this.editSource(language);
1034
+ const plan = command === 'toggleLineComment'
1035
+ ? planLineComments(source, this.selections, commentOptions)
1036
+ : planBlockComment(source, this.selections, commentOptions);
1037
+ return plan !== null && this.applyEdits(plan.edits, { source: 'command', selections: plan.selections });
1038
+ }
1039
+ /* The lines the carets and selections touch. A selection that ends at the start of a line does not touch it. */
1040
+ selectedLines(span) {
1041
+ const indices = new Set();
1042
+ for (const selection of this.selections) {
1043
+ const { from, to } = rangeOf(selection);
1044
+ let first = this.rope.lineAt(from);
1045
+ let last = this.rope.lineAt(to);
1046
+ if (to > from && this.rope.lineBounds(last).start === to) {
1047
+ last--;
1048
+ }
1049
+ if (span) {
1050
+ first = span(first).first;
1051
+ last = Math.max(last, span(last).last);
1052
+ }
1053
+ for (let line = first; line <= last; line++) {
1054
+ indices.add(line);
1055
+ }
1056
+ }
1057
+ return [...indices].sort((left, right) => left - right);
1058
+ }
1059
+ /* A selection is copied in place, its copy selected; a caret duplicates its line and moves down with it. */
1060
+ duplicate(span) {
1061
+ const carets = this.selections.filter((selection) => selection.anchor === selection.head);
1062
+ const lines = new Set();
1063
+ for (const caret of carets) {
1064
+ const line = this.rope.lineAt(caret.head);
1065
+ const { first, last } = span ? span(line) : { first: line, last: line };
1066
+ for (let index = first; index <= last; index++) {
1067
+ lines.add(index);
1068
+ }
1069
+ }
1070
+ const groups = blocks([...lines].sort((left, right) => left - right));
1071
+ const copies = [];
1072
+ for (const selection of this.selections) {
1073
+ const { from, to } = rangeOf(selection);
1074
+ if (from !== to) {
1075
+ const text = this.slice(from, to);
1076
+ copies.push({ at: to, text, select: (shift) => [{ anchor: to + shift, head: to + shift + text.length }] });
1077
+ }
1078
+ }
1079
+ for (const group of groups) {
1080
+ const first = this.rope.lineBounds(group.first);
1081
+ const last = this.rope.lineBounds(group.last);
1082
+ const text = last.next > last.end ? this.slice(first.start, last.next) : this.newlineAt(group.last) + this.slice(first.start, last.end);
1083
+ const riders = carets.filter((caret) => {
1084
+ const line = this.rope.lineAt(caret.head);
1085
+ return line >= group.first && line <= group.last;
1086
+ });
1087
+ copies.push({
1088
+ at: last.next,
1089
+ text,
1090
+ select: (shift) => riders.map((caret) => ({ anchor: caret.anchor + shift + text.length, head: caret.head + shift + text.length }))
1091
+ });
1092
+ }
1093
+ copies.sort((left, right) => left.at - right.at);
1094
+ const edits = [];
1095
+ const selections = [];
1096
+ let shift = 0;
1097
+ for (const copy of copies) {
1098
+ selections.push(...copy.select(shift));
1099
+ const previous = edits.at(-1);
1100
+ if (previous?.from === copy.at) {
1101
+ previous.text += copy.text;
1102
+ }
1103
+ else {
1104
+ edits.push({ from: copy.at, to: copy.at, text: copy.text });
1105
+ }
1106
+ shift += copy.text.length;
1107
+ }
1108
+ return this.applyEdits(edits, { source: 'command', selections });
1109
+ }
1110
+ /* The next line takes the caret in at the same column, or the one above when there is none. */
1111
+ deleteLines(indices) {
1112
+ const groups = blocks(indices);
1113
+ const columns = new Map();
1114
+ for (const selection of this.selections) {
1115
+ columns.set(this.rope.lineAt(selection.head), this.positionAt(selection.head).column);
1116
+ }
1117
+ const edits = groups.map((block) => {
1118
+ let from = this.rope.lineBounds(block.first).start;
1119
+ const to = this.rope.lineBounds(block.last).next;
1120
+ // The last line has no break of its own, so the one before it goes too.
1121
+ if (block.last === this.getLineCount() - 1 && block.first > 0) {
1122
+ from = this.rope.lineBounds(block.first - 1).end;
1123
+ }
1124
+ return { from, to, text: '' };
1125
+ });
1126
+ const selections = [];
1127
+ let removed = 0;
1128
+ for (const [position, block] of groups.entries()) {
1129
+ const edit = edits[position];
1130
+ const column = Math.max(0, ...[...columns].filter(([line]) => line >= block.first && line <= block.last).map(([, value]) => value));
1131
+ const target = block.last + 1 < this.getLineCount() ? block.last + 1 : block.first - 1;
1132
+ if (target >= 0) {
1133
+ const bounds = this.rope.lineBounds(target);
1134
+ const start = bounds.start - (block.last + 1 < this.getLineCount() ? edit.to - edit.from : 0) - removed;
1135
+ const caret = start + Math.min(column, bounds.end - bounds.start);
1136
+ selections.push({ anchor: caret, head: caret });
1137
+ }
1138
+ else {
1139
+ selections.push({ anchor: 0, head: 0 });
1140
+ }
1141
+ removed += edit.to - edit.from;
1142
+ }
1143
+ return this.applyEdits(edits, { source: 'command', selections });
1144
+ }
1145
+ moveLines(indices, direction, options) {
1146
+ const span = options.lineSpan ?? ((line) => ({ first: line, last: line }));
1147
+ const groups = blocks(indices).filter((block) => (direction === -1 ? block.first > 0 : block.last < this.getLineCount() - 1));
1148
+ if (groups.length === 0) {
1149
+ return false;
1150
+ }
1151
+ const targets = new Map();
1152
+ const moves = [];
1153
+ const edits = groups.map((block) => {
1154
+ const own = range(block.first, block.last);
1155
+ // The line it swaps with is a whole row of the screen, so a collapsed fold is never cut in two.
1156
+ const neighbor = direction === -1 ? span(block.first - 1) : span(block.last + 1);
1157
+ const other = range(neighbor.first, neighbor.last);
1158
+ const first = direction === -1 ? neighbor.first : block.first;
1159
+ const last = direction === 1 ? neighbor.last : block.last;
1160
+ const order = direction === -1 ? [...own, ...other] : [...other, ...own];
1161
+ let at = this.rope.lineBounds(first).start;
1162
+ const from = at;
1163
+ const parts = [];
1164
+ for (const [index, original] of order.entries()) {
1165
+ const line = this.getLine(original);
1166
+ // Each line keeps the line break of the slot it moves into, so mixed LF and CRLF stay where they were.
1167
+ const slot = this.rope.lineBounds(first + index);
1168
+ const ending = this.slice(slot.end, slot.next);
1169
+ parts.push(line.text, ending);
1170
+ targets.set(original, { start: at, end: at + line.text.length, next: at + line.text.length + ending.length });
1171
+ if (original !== first + index) {
1172
+ moves.push({ from: original, to: first + index });
1173
+ }
1174
+ at += line.text.length + ending.length;
1175
+ }
1176
+ return { from, to: this.rope.lineBounds(last).next, text: parts.join('') };
1177
+ });
1178
+ const mapOffsetThroughMove = (offset, from, to) => {
1179
+ const line = this.rope.lineAt(offset);
1180
+ if (to > from && offset === to && line > 0 && this.rope.lineBounds(line).start === to) {
1181
+ return (targets.get(line - 1) ?? this.rope.lineBounds(line - 1)).next;
1182
+ }
1183
+ const target = targets.get(line);
1184
+ return target ? Math.min(target.end, target.start + offset - this.rope.lineBounds(line).start) : offset;
1185
+ };
1186
+ const selections = this.selections.map((selection) => {
1187
+ const { from, to } = rangeOf(selection);
1188
+ return { anchor: mapOffsetThroughMove(selection.anchor, from, to), head: mapOffsetThroughMove(selection.head, from, to) };
1189
+ });
1190
+ const changed = this.applyEdits(edits, { source: 'command', selections });
1191
+ if (changed) {
1192
+ options.onLinesMoved?.(moves);
1193
+ }
1194
+ return changed;
1195
+ }
1196
+ /* Enter, or the same Enter from the end of the line (`below`) or with the caret left in front of the break (`split`). */
1197
+ insertNewline(options, mode) {
1198
+ const source = this.editSource(options.language);
1199
+ const plan = (reach) => this.selections.map((selection) => {
1200
+ const { from, to } = rangeOf(selection);
1201
+ if (mode === 'below') {
1202
+ const end = this.rope.lineBounds(this.rope.lineAt(selection.head)).end;
1203
+ return planEnter(source, end, end, { ...options, reach });
1204
+ }
1205
+ const before = this.slice(this.rope.lineBounds(this.rope.lineAt(from)).start, from);
1206
+ if (mode === 'split' && /^[\t ]*$/.test(before)) {
1207
+ const start = from - before.length;
1208
+ return { from: start, to: start, text: before + this.newlineAt(this.rope.lineAt(from)), caret: before.length };
1209
+ }
1210
+ const planned = planEnter(source, from, to, { ...options, reach });
1211
+ return mode === 'split' ? { ...planned, caret: from - planned.from } : planned;
1212
+ });
1213
+ let plans = plan(true);
1214
+ const sorted = [...plans].sort((left, right) => left.from - right.from);
1215
+ if (sorted.some((current, index) => index > 0 && current.from < sorted[index - 1].to)) {
1216
+ plans = plan(false);
1217
+ }
1218
+ return this.applySelectionEdits(plans.map((entry) => ({ from: entry.from, to: entry.to, text: entry.text, anchor: entry.caret, head: entry.caret })), { source: 'command' });
1219
+ }
1220
+ /* A line of its own above each caret's line, at the indentation of its text. */
1221
+ startNewLineBefore(unit) {
1222
+ return this.applySelectionEdits(this.selections.map((selection) => {
1223
+ const index = this.rope.lineAt(selection.head);
1224
+ const line = this.rope.lineBounds(index);
1225
+ const text = this.getLine(index).text;
1226
+ const indent = (text.match(/^[\t ]*/)?.[0] ?? '') + (/^[\t ]*[)\]}]/.test(text) ? unit : '');
1227
+ return { from: line.start, to: line.start, text: indent + this.newlineAt(index), anchor: indent.length, head: indent.length };
1228
+ }), { source: 'command' });
1229
+ }
1230
+ editLines(command, options) {
1231
+ const source = this.editSource(options.language);
1232
+ const plan = command === 'joinLines'
1233
+ ? planJoinLines(source, this.selections, options)
1234
+ : command === 'toggleCase'
1235
+ ? planToggleCase(source, this.selections)
1236
+ : planAutoIndent(source, this.selectedLines(), options);
1237
+ if (plan === null) {
1238
+ return false;
1239
+ }
1240
+ const edits = [...plan.edits].sort((left, right) => left.from - right.from);
1241
+ // Without a plan of its own the selections keep their start in front of the indentation that goes in at it.
1242
+ const selections = plan.selections.length > 0
1243
+ ? plan.selections
1244
+ : this.selections.map((selection) => {
1245
+ const { from, to } = rangeOf(selection);
1246
+ const start = mapOffset(from, edits, from !== to);
1247
+ const end = mapOffset(to, edits);
1248
+ return selection.anchor <= selection.head ? { anchor: start, head: end } : { anchor: end, head: start };
1249
+ });
1250
+ return this.applyEdits(plan.edits, { source: 'command', selections });
1251
+ }
1252
+ editSource(language) {
1253
+ return {
1254
+ lineCount: this.getLineCount(),
1255
+ slice: (from, to) => this.slice(from, to),
1256
+ charAt: (offset) => this.rope.charAt(offset),
1257
+ lineAt: (offset) => this.rope.lineAt(offset),
1258
+ line: (index) => this.getLine(index),
1259
+ newline: (index) => this.newlineAt(index),
1260
+ context: (offset) => this.typingContext(offset, language),
1261
+ region: (line) => (/^vue$/i.test(language) ? vueRegionAt((index) => this.getLine(index).text, line) : null),
1262
+ unmatchedBrace: (offset) => this.bracketIndex(language)?.unmatched.has(offset) ?? false
1263
+ };
1264
+ }
1265
+ /* Undefined for a document too large to scan on every Enter. */
1266
+ bracketIndex(language) {
1267
+ if (this.rope.length > bracketScanLimit) {
1268
+ return undefined;
1269
+ }
1270
+ const cache = this.bracketCache;
1271
+ if (cache?.revision === this.revision && cache.language === language) {
1272
+ return cache.index;
1273
+ }
1274
+ const index = scanBrackets(this.getText(), language);
1275
+ this.bracketCache = { revision: this.revision, language, index };
1276
+ return index;
1277
+ }
1278
+ deleteCharacter(backwards, tabSize, pairing, language) {
1279
+ return this.deleteRanges(this.selections.map((selection) => {
1280
+ let { from, to } = rangeOf(selection);
1281
+ if (from === to) {
1282
+ if (!backwards) {
1283
+ to = deletionStep(this.rope, to, 1);
1284
+ }
1285
+ else if ((isQuote(this.rope.charAt(from - 1)) ? pairing.quotes : pairing.brackets) && this.isEmptyPair(from, language)) {
1286
+ from--;
1287
+ to++;
1288
+ }
1289
+ else {
1290
+ from = this.backspaceStart(from, tabSize);
1291
+ }
1292
+ }
1293
+ return { from, to, text: '' };
1294
+ }));
1295
+ }
1296
+ /* Whether the caret sits between an opener and its own closer, which backspace removes together. */
1297
+ isEmptyPair(offset, language) {
1298
+ const opener = this.rope.charAt(offset - 1);
1299
+ const closer = this.rope.charAt(offset);
1300
+ if (!closer || pairs[opener] !== closer || this.escaped(offset - 1)) {
1301
+ return false;
1302
+ }
1303
+ const context = this.typingContext(offset - 1, language);
1304
+ return context.mode === 'code' || this.isInterpolation(offset - 1, opener, context);
1305
+ }
1306
+ /* Backspace inside the indentation of a line goes back to the previous tab stop, and at the start of a line joins it with the one above. */
1307
+ backspaceStart(offset, tabSize) {
1308
+ const line = this.rope.lineBounds(this.rope.lineAt(offset));
1309
+ if (offset === line.start && line.start > 0) {
1310
+ // Joining with the line above also takes the whitespace that trails it.
1311
+ const previous = this.getLine(this.rope.lineAt(offset) - 1);
1312
+ return previous.end - trailingBlankLength(previous.text);
1313
+ }
1314
+ const prefix = this.slice(line.start, offset);
1315
+ if (prefix.length > 0 && /^[\t ]+$/.test(prefix)) {
1316
+ return line.start + indentStopLength(prefix, tabSize);
1317
+ }
1318
+ return deletionStep(this.rope, offset, -1);
1319
+ }
1320
+ deleteRanges(ranges) {
1321
+ ranges.sort((left, right) => left.from - right.from);
1322
+ const merged = [];
1323
+ for (const range of ranges) {
1324
+ const previous = merged.at(-1);
1325
+ if (previous && range.from <= previous.to) {
1326
+ previous.to = Math.max(previous.to, range.to);
1327
+ }
1328
+ else {
1329
+ merged.push({ ...range });
1330
+ }
1331
+ }
1332
+ return this.applyEdits(merged, { source: 'command' });
1333
+ }
1334
+ applySelectionEdits(plans, options, transaction = selectionTransaction(plans)) {
1335
+ const before = this.getSelections();
1336
+ const changed = this.applyEdits(transaction.edits, { ...options, selections: transaction.selections });
1337
+ return changed || !sameSelections(before, this.selections);
1338
+ }
1339
+ /* The line break of a line, or the document's first one for the last line, which has none. */
1340
+ newlineAt(index) {
1341
+ const line = this.rope.lineBounds(index);
1342
+ if (line.next > line.end) {
1343
+ return this.slice(line.end, line.next);
1344
+ }
1345
+ const first = this.rope.lineBounds(0);
1346
+ return first.next > first.end ? this.slice(first.end, first.next) : '\n';
1347
+ }
1348
+ /* Whether the character at `offset` is preceded by an odd number of backslashes. */
1349
+ escaped(offset) {
1350
+ let count = 0;
1351
+ while (offset > 0 && this.rope.charAt(--offset) === '\\') {
1352
+ count++;
1353
+ }
1354
+ return count % 2 === 1;
1355
+ }
1356
+ typingContext(offset, language) {
1357
+ const position = this.positionAt(offset);
1358
+ return this.typingContexts.at(position.line, position.column, language);
1359
+ }
1360
+ isInterpolation(offset, character, context) {
1361
+ return character === '{' && this.rope.charAt(offset - 1) === '$' && context?.mode === 'template' && !context.escaped && !this.escaped(offset - 1);
1362
+ }
1363
+ /*
1364
+ * Where a `;` typed at `from` belongs: after the closing brackets of the call or array that ends
1365
+ * the statement. Undefined when it is an ordinary `;`, as in a `for` header or mid-expression.
1366
+ */
1367
+ semicolonTarget(from, context) {
1368
+ for (let bracket = context.bracket; bracket; bracket = bracket.previous) {
1369
+ if (bracket.control) {
1370
+ return undefined;
1371
+ }
1372
+ }
1373
+ if (this.rope.charAt(from) === ';') {
1374
+ return from;
1375
+ }
1376
+ const line = this.getLine(this.rope.lineAt(from));
1377
+ const prefix = this.slice(line.start, from).trimEnd();
1378
+ if (!prefix || /[([,=:+\-*/%&|!?]$/.test(prefix)) {
1379
+ return undefined;
1380
+ }
1381
+ let target = from;
1382
+ let bracket = context.bracket;
1383
+ while (target < line.end) {
1384
+ const character = this.rope.charAt(target);
1385
+ if (character === ' ' || character === '\t') {
1386
+ target++;
1387
+ continue;
1388
+ }
1389
+ if ((character !== ')' && character !== ']') || bracket?.close !== character) {
1390
+ break;
1391
+ }
1392
+ target++;
1393
+ bracket = bracket.previous;
1394
+ }
1395
+ if (target === from || !/[)\]]/.test(this.slice(from, target))) {
1396
+ return undefined;
1397
+ }
1398
+ const after = this.rope.charAt(target);
1399
+ if (target !== line.end && after !== ';' && after !== '}' && this.slice(target, target + 2) !== '//') {
1400
+ return undefined;
1401
+ }
1402
+ // Whitespace after the expression stays after its terminator.
1403
+ if (after !== ';') {
1404
+ while (target > from && /[\t ]/.test(this.rope.charAt(target - 1))) {
1405
+ target--;
1406
+ }
1407
+ }
1408
+ return target;
1409
+ }
1410
+ /*
1411
+ * What a double click selects at an offset: the identifier around it, a hump of it when `camel`,
1412
+ * and on anything else the text of its line. On an empty line it is the whole document, as the
1413
+ * platform's select-word action has it.
1414
+ */
1415
+ wordSelectionAt(offset, camel = false) {
1416
+ const length = this.rope.length;
1417
+ const at = offset >= length ? length - 1 : offset;
1418
+ if (at < 0) {
1419
+ return null;
1420
+ }
1421
+ const word = this.identifierRunAt(at, camel);
1422
+ if (word !== null) {
1423
+ return word;
1424
+ }
1425
+ const line = this.getLine(this.rope.lineAt(at));
1426
+ return line.start === line.end ? { from: 0, to: length } : { from: line.start, to: line.end };
1427
+ }
1428
+ /* The identifier, or its camel hump, that an offset is in or at the end of; null when it is on something else. */
1429
+ identifierRunAt(offset, camel) {
1430
+ const length = this.rope.length;
1431
+ const isWord = (at) => at >= 0 && at < length && identifier.test(this.slice(at, step(this.rope, at, 1)));
1432
+ let at = offset;
1433
+ if (at > 0 && !isWord(at) && isWord(at - 1)) {
1434
+ at--;
1435
+ }
1436
+ if (!isWord(at)) {
1437
+ return null;
1438
+ }
1439
+ if (camel) {
1440
+ let start = at;
1441
+ let end = at + 1;
1442
+ while (start > 0 && isWord(start - 1) && !isHumpBoundary(this.rope, start, true)) {
1443
+ start--;
1444
+ }
1445
+ while (end < length && isWord(end) && !isHumpBoundary(this.rope, end, false)) {
1446
+ end++;
1447
+ }
1448
+ if (start + 1 < end) {
1449
+ return { from: start, to: end };
1450
+ }
1451
+ }
1452
+ const word = this.wordRange(at);
1453
+ return word.from === word.to ? null : word;
1454
+ }
1455
+ /* Where the word before an offset starts on this line or the one above, as a drag over words follows it. */
1456
+ wordStartBefore(offset, camel = false) {
1457
+ if (offset === 0) {
1458
+ return 0;
1459
+ }
1460
+ const line = this.rope.lineAt(offset);
1461
+ const minimum = line > 0 ? this.rope.lineBounds(line - 1).end : 0;
1462
+ let found = offset - 1;
1463
+ for (; found > minimum; found--) {
1464
+ if (isWordBoundary(this.rope, found, camel, true) || this.isBetweenBrackets(found)) {
1465
+ break;
1466
+ }
1467
+ }
1468
+ return found;
1469
+ }
1470
+ /* Where the word after an offset ends on this line or the one below. */
1471
+ wordEndAfter(offset, camel = false) {
1472
+ if (offset >= this.rope.length - 1) {
1473
+ return offset;
1474
+ }
1475
+ const line = this.rope.lineAt(offset);
1476
+ let found = offset + 1;
1477
+ let maximum = this.rope.lineBounds(line).end;
1478
+ if (found > maximum) {
1479
+ if (line + 1 >= this.rope.lineCount) {
1480
+ return offset;
1481
+ }
1482
+ maximum = this.rope.lineBounds(line + 1).end;
1483
+ }
1484
+ for (; found < maximum; found++) {
1485
+ if (isWordBoundary(this.rope, found, camel, false) || this.isBetweenBrackets(found)) {
1486
+ break;
1487
+ }
1488
+ }
1489
+ return found;
1490
+ }
1491
+ isBetweenBrackets(offset) {
1492
+ return offset > 0 && offset < this.rope.length && /[()[\]<>{}]/.test(this.rope.charAt(offset)) && /[()[\]<>{}]/.test(this.rope.charAt(offset - 1));
1493
+ }
1494
+ wordRange(offset) {
1495
+ let from = offset;
1496
+ let to = offset;
1497
+ while (from > 0) {
1498
+ const previous = step(this.rope, from, -1);
1499
+ if (!identifier.test(this.slice(previous, from))) {
1500
+ break;
1501
+ }
1502
+ from = previous;
1503
+ }
1504
+ while (to < this.rope.length) {
1505
+ const next = step(this.rope, to, 1);
1506
+ if (!identifier.test(this.slice(to, next))) {
1507
+ break;
1508
+ }
1509
+ to = next;
1510
+ }
1511
+ return { from, to };
1512
+ }
1513
+ /* Grows each selection to the smallest word, line, bracket or string around it that is larger than it. */
1514
+ expandSelection(given = []) {
1515
+ const selections = this.selections.map((selection, index) => {
1516
+ const { from, to } = rangeOf(selection);
1517
+ const candidate = this.largerRange(given[index] ?? null, from, to) ?? this.lexicalExpansion(selection, from, to);
1518
+ if (!candidate) {
1519
+ return selection;
1520
+ }
1521
+ return selection.anchor > selection.head ? { anchor: candidate.to, head: candidate.from } : { anchor: candidate.from, head: candidate.to };
1522
+ });
1523
+ const normalized = normalizeSelections(this.rope, selections);
1524
+ if (sameSelections(this.selections, normalized)) {
1525
+ return false;
1526
+ }
1527
+ this.selectionHistory.push(this.selections.map((selection) => ({ ...selection })));
1528
+ this.selections = normalized;
1529
+ this.groupOpen = false;
1530
+ this.emit();
1531
+ return true;
1532
+ }
1533
+ /*
1534
+ * Grows each selection to the range a language server gave for it, by offsets in the text, and Shrink
1535
+ * Selection walks back. A selection without a range, or one that does not hold it and more, grows
1536
+ * by the same rule as `expandSelection`.
1537
+ */
1538
+ expandSelectionTo(ranges) {
1539
+ return this.expandSelection(ranges);
1540
+ }
1541
+ /* The range when it holds the stretch and is larger than it, else null. */
1542
+ largerRange(range, from, to) {
1543
+ return range !== null && range.from <= from && range.to >= to && (range.from < from || range.to > to) ? range : null;
1544
+ }
1545
+ lexicalExpansion(selection, from, to) {
1546
+ const first = this.getLine(this.rope.lineAt(from));
1547
+ const last = this.getLine(this.rope.lineAt(to));
1548
+ const candidates = [
1549
+ this.wordRange(selection.head),
1550
+ { from: first.start, to: last.end },
1551
+ { from: first.start, to: last.next },
1552
+ { from: 0, to: this.rope.length }
1553
+ ];
1554
+ if (first.start === last.start) {
1555
+ candidates.push({ from: first.start + indentLength(first.text), to: first.end - trailingBlankLength(first.text) });
1556
+ }
1557
+ for (const range of this.structure()) {
1558
+ candidates.push({ from: range.innerFrom, to: range.innerTo }, range);
1559
+ }
1560
+ return candidates
1561
+ .filter((range) => range.from <= from && range.to >= to && (range.from < from || range.to > to))
1562
+ .sort((left, right) => left.to - left.from - (right.to - right.from))[0];
1563
+ }
1564
+ shrinkSelection() {
1565
+ const previous = this.selectionHistory.pop();
1566
+ if (!previous) {
1567
+ return false;
1568
+ }
1569
+ this.selections = previous;
1570
+ this.groupOpen = false;
1571
+ this.emit();
1572
+ return true;
1573
+ }
1574
+ /* Whether the selections are as the last occurrence command left them, which makes the next one a repeat of it. */
1575
+ occurrenceSignature() {
1576
+ return this.selections.map((selection) => `${selection.anchor}:${selection.head}`).join(',');
1577
+ }
1578
+ repeatedOccurrence() {
1579
+ const session = this.occurrenceSession;
1580
+ return session !== undefined && session.signature === this.occurrenceSignature() ? session : null;
1581
+ }
1582
+ rememberOccurrence(wholeWord, notFound) {
1583
+ this.occurrenceSession = { wholeWord, notFound, signature: this.occurrenceSignature() };
1584
+ this.occurrencesExhausted = notFound;
1585
+ }
1586
+ /* Whether a range touches a selection that is already there. */
1587
+ overlapsSelection(from, to) {
1588
+ return this.selections.some((selection) => {
1589
+ const range = rangeOf(selection);
1590
+ return from === to && range.from === range.to ? from === range.from : from < range.to && to > range.from;
1591
+ });
1592
+ }
1593
+ /*
1594
+ * A caret on the next occurrence of what is selected, a word to begin with, whose later occurrences have to be
1595
+ * whole words too. An occurrence that is already a selection is left, and so is the end of the text: the next
1596
+ * press starts again from its beginning.
1597
+ */
1598
+ selectNextOccurrence() {
1599
+ const repeated = this.repeatedOccurrence();
1600
+ const notFoundBefore = repeated?.notFound === true;
1601
+ const wholeWord = repeated?.wholeWord === true;
1602
+ const primary = this.selections.at(-1);
1603
+ const { from, to } = rangeOf(primary);
1604
+ if (from === to) {
1605
+ const word = this.identifierRunAt(primary.head, false);
1606
+ if (word === null) {
1607
+ return false;
1608
+ }
1609
+ const changed = this.changeSelections([...this.selections.slice(0, -1), { anchor: word.from, head: word.to }]);
1610
+ this.rememberOccurrence(true, false);
1611
+ return changed;
1612
+ }
1613
+ const needle = this.slice(from, to);
1614
+ const start = notFoundBefore ? 0 : to;
1615
+ const match = this.find(needle, { caseSensitive: true, wholeWord }).find((candidate) => candidate.from >= start);
1616
+ if (match === undefined) {
1617
+ this.rememberOccurrence(wholeWord, true);
1618
+ return false;
1619
+ }
1620
+ if (this.overlapsSelection(match.from, match.to)) {
1621
+ this.rememberOccurrence(wholeWord, notFoundBefore);
1622
+ return false;
1623
+ }
1624
+ const shift = primary.head - from;
1625
+ const caret = Math.min(match.from + shift, match.to);
1626
+ const added = shift === 0 ? { anchor: match.to, head: match.from } : { anchor: caret === match.to ? match.from : match.to, head: caret };
1627
+ const changed = this.changeSelections([...this.selections, added]);
1628
+ this.rememberOccurrence(wholeWord, false);
1629
+ return changed;
1630
+ }
1631
+ /* Takes the newest caret away, or the selection when there is one caret. */
1632
+ unselectOccurrence() {
1633
+ const changed = this.selections.length > 1
1634
+ ? this.changeSelections(this.selections.slice(0, -1))
1635
+ : this.changeSelections([{ anchor: this.selections[0].head, head: this.selections[0].head }]);
1636
+ this.occurrenceSession = undefined;
1637
+ this.occurrencesExhausted = false;
1638
+ return changed;
1639
+ }
1640
+ /* A caret on every occurrence of the selected text, or of the word at the caret as a whole word, the last one primary. */
1641
+ selectAllOccurrences() {
1642
+ const primary = this.selections.at(-1);
1643
+ let { from, to } = rangeOf(primary);
1644
+ let wholeWord = false;
1645
+ if (from === to) {
1646
+ const word = this.identifierRunAt(primary.head, false);
1647
+ if (word === null) {
1648
+ return false;
1649
+ }
1650
+ ({ from, to } = word);
1651
+ wholeWord = true;
1652
+ }
1653
+ // The word that was only a caret gets selected forward, so its caret is at its end.
1654
+ const shift = wholeWord ? to - from : primary.head - from;
1655
+ const matches = this.find(this.slice(from, to), { caseSensitive: true, wholeWord });
1656
+ if (matches.length === 0) {
1657
+ return false;
1658
+ }
1659
+ const changed = this.changeSelections(matches.map((match) => {
1660
+ const caret = Math.min(match.from + shift, match.to);
1661
+ return shift === 0 ? { anchor: match.to, head: match.from } : { anchor: caret === match.to ? match.from : match.to, head: caret };
1662
+ }));
1663
+ this.rememberOccurrence(wholeWord, false);
1664
+ return changed;
1665
+ }
1666
+ /* The selected lines become a caret at the end of each, which is where the platform puts them. */
1667
+ addCaretPerSelectedLine() {
1668
+ const forward = this.selections.at(-1).head >= this.selections.at(-1).anchor;
1669
+ const carets = [];
1670
+ for (const selection of this.selections) {
1671
+ const { from, to } = rangeOf(selection);
1672
+ const first = this.rope.lineAt(from);
1673
+ let last = this.rope.lineAt(to);
1674
+ if (last > first && to === this.rope.lineBounds(last).start) {
1675
+ last--;
1676
+ }
1677
+ for (let line = first; line <= last; line++) {
1678
+ const end = this.rope.lineBounds(line).end;
1679
+ carets.push({ anchor: end, head: end });
1680
+ }
1681
+ }
1682
+ // The primary caret is the one at the end the selection was dragged to.
1683
+ const primary = forward ? carets.pop() : carets.shift();
1684
+ return this.changeSelections(primary === undefined ? carets : [...carets, primary]);
1685
+ }
1686
+ changeSelections(selections) {
1687
+ const before = this.getSelections();
1688
+ this.setSelections(selections);
1689
+ return !sameSelections(before, this.selections);
1690
+ }
1691
+ structure() {
1692
+ return (this.structureCache ??= scanStructure(this.getText()));
1693
+ }
1694
+ state() {
1695
+ return { rope: this.rope, selections: this.selections.map((selection) => ({ ...selection })) };
1696
+ }
1697
+ invalidate() {
1698
+ this.textCache = {};
1699
+ this.structureCache = undefined;
1700
+ this.selectionHistory = [];
1701
+ this.statementClosers.clear();
1702
+ }
1703
+ restore(state, changes) {
1704
+ const previous = this.rope;
1705
+ this.rope = state.rope;
1706
+ this.invalidate();
1707
+ this.closers.clear();
1708
+ this.typingContexts.invalidate();
1709
+ this.selections = state.selections.map((selection) => ({ ...selection }));
1710
+ this.revision++;
1711
+ this.groupOpen = false;
1712
+ this.emit(changes, 'command', () => stretchEdits(previous, state.rope, changes));
1713
+ }
1714
+ /* `contentEdits` is worked out when a listener asks, since most listeners never do and a restore of a large text would otherwise read all of it. */
1715
+ emit(changes, source, contentEdits) {
1716
+ const snapshot = this.getSnapshot();
1717
+ if (changes) {
1718
+ snapshot.changes = changes.map((transaction) => transaction.map((change) => ({ ...change })));
1719
+ snapshot.source = source;
1720
+ if (contentEdits) {
1721
+ let edits;
1722
+ Object.defineProperty(snapshot, 'contentEdits', { get: () => (edits ??= contentEdits()), enumerable: true });
1723
+ }
1724
+ }
1725
+ for (const listener of this.listeners) {
1726
+ listener(snapshot);
1727
+ }
1728
+ }
1729
+ }