@adecore/editor-core 0.21.1 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -175,7 +175,7 @@ class JsxScanner {
175
175
  /* An element from its `<`, recorded when no element holds it, and the offset after its closing tag; -1 when it is not one. */
176
176
  element(from, outermost) {
177
177
  this.depth++;
178
- const end = this.parse(from);
178
+ const end = this.parseElement(from);
179
179
  this.depth--;
180
180
  if (end >= 0 && outermost && this.depth === 0) {
181
181
  this.ranges.push({ from, to: end });
@@ -189,7 +189,7 @@ class JsxScanner {
189
189
  }
190
190
  return at;
191
191
  }
192
- parse(from) {
192
+ parseElement(from) {
193
193
  const { text } = this;
194
194
  let at = from + 1;
195
195
  while (at < text.length && NAME.test(text[at])) {
@@ -240,7 +240,7 @@ class JsxScanner {
240
240
  at = this.code(at + 1, '}');
241
241
  }
242
242
  else if (value === '<' && START_OF_NAME.test(text[at + 1] ?? '')) {
243
- at = this.parse(at);
243
+ at = this.parseElement(at);
244
244
  }
245
245
  else {
246
246
  return -1;
@@ -264,7 +264,7 @@ class JsxScanner {
264
264
  return closing !== null && closing[1] === name ? at + closing[0].length : -1;
265
265
  }
266
266
  else if (START_OF_NAME.test(text[at + 1] ?? '')) {
267
- at = this.parse(at);
267
+ at = this.parseElement(at);
268
268
  }
269
269
  else {
270
270
  return -1;
@@ -281,6 +281,25 @@ export function jsxRanges(text) {
281
281
  scanner.scan();
282
282
  return scanner.ranges;
283
283
  }
284
+ /* Whether `offset` falls inside one of `ranges`, which are sorted and do not overlap. */
285
+ function insideAny(ranges, offset) {
286
+ let low = 0;
287
+ let high = ranges.length - 1;
288
+ while (low <= high) {
289
+ const middle = (low + high) >> 1;
290
+ const range = ranges[middle];
291
+ if (offset < range.from) {
292
+ high = middle - 1;
293
+ }
294
+ else if (offset >= range.to) {
295
+ low = middle + 1;
296
+ }
297
+ else {
298
+ return true;
299
+ }
300
+ }
301
+ return false;
302
+ }
284
303
  /* Whether a language is one whose scripts can hold JSX. */
285
304
  export function hasJsx(language) {
286
305
  return JSX_LANGUAGES.test(language);
@@ -309,22 +328,7 @@ export function markupGuard(source, language) {
309
328
  return (line) => {
310
329
  const bounds = source.line(line);
311
330
  const start = bounds.start + (/^[\t ]*/.exec(bounds.text)?.[0].length ?? 0);
312
- let low = 0;
313
- let high = ranges.length - 1;
314
- while (low <= high) {
315
- const middle = (low + high) >> 1;
316
- const range = ranges[middle];
317
- if (start < range.from) {
318
- high = middle - 1;
319
- }
320
- else if (start >= range.to) {
321
- low = middle + 1;
322
- }
323
- else {
324
- return true;
325
- }
326
- }
327
- return false;
331
+ return insideAny(ranges, start);
328
332
  };
329
333
  }
330
334
  return () => false;
package/dist/paste.js CHANGED
@@ -1,11 +1,6 @@
1
+ import { indentText, isBlank, whitespaceOf } from './line-text.js';
1
2
  import { mapOffset } from './offsets.js';
2
3
  import { indentationColumn } from './structure.js';
3
- function whitespaceOf(text) {
4
- return /^[\t ]*/.exec(text)[0];
5
- }
6
- function isBlank(text) {
7
- return text.trim() === '';
8
- }
9
4
  /* A line break at the end is the end of the last line, not a line of its own. */
10
5
  function linesOf(text) {
11
6
  return text.replace(/\r?\n$/, '').split(/\r?\n/);
@@ -18,12 +13,6 @@ function perCaretLines(text, carets) {
18
13
  const lines = linesOf(text);
19
14
  return lines.length === carets ? lines : null;
20
15
  }
21
- function indentText(width, options) {
22
- if (options.insertSpaces) {
23
- return ' '.repeat(width);
24
- }
25
- return '\t'.repeat(Math.floor(width / options.tabSize)) + ' '.repeat(width % options.tabSize);
26
- }
27
16
  /*
28
17
  * Where a block of several lines lands, it lands at the indentation of the line it is pasted into:
29
18
  * the pasted lines move together by the difference between that indentation and the least indented
@@ -21,41 +21,34 @@ export function replaceWithCaseRespect(replacement, found) {
21
21
  if (foundTail === '') {
22
22
  return head + replacementTail;
23
23
  }
24
- let replacementLower = true;
25
- let replacementUpper = true;
26
- for (const character of replacementTail) {
27
- if (!letter.test(character)) {
28
- continue;
29
- }
30
- replacementLower &&= lower.test(character);
31
- replacementUpper &&= upper.test(character);
32
- if (!replacementLower && !replacementUpper) {
33
- break;
34
- }
24
+ const replacementCase = letterCase(replacementTail);
25
+ const foundCase = letterCase(foundTail);
26
+ // A tail without letters, like `x2`, follows the first letter of the match.
27
+ const tailUpper = foundCase.hasLetters ? foundCase.upper : upper.test(firstFound);
28
+ const tailLower = foundCase.hasLetters ? foundCase.lower : lower.test(firstFound);
29
+ if (tailUpper && (replacementCase.lower || !replacementCase.upper)) {
30
+ return head + replacementTail.toUpperCase();
31
+ }
32
+ if (tailLower && (replacementCase.lower || replacementCase.upper)) {
33
+ return head + replacementTail.toLowerCase();
35
34
  }
36
- let tailUpper = true;
37
- let tailLower = true;
38
- let tailChecked = false;
39
- for (const character of foundTail) {
35
+ return head + replacementTail;
36
+ }
37
+ /* Whether every letter of `text` is upper case and whether every one is lower case; both hold without letters. */
38
+ function letterCase(text) {
39
+ let allUpper = true;
40
+ let allLower = true;
41
+ let hasLetters = false;
42
+ for (const character of text) {
40
43
  if (!letter.test(character)) {
41
44
  continue;
42
45
  }
43
- tailUpper &&= upper.test(character);
44
- tailLower &&= lower.test(character);
45
- tailChecked = true;
46
- if (!tailUpper && !tailLower) {
46
+ hasLetters = true;
47
+ allUpper &&= upper.test(character);
48
+ allLower &&= lower.test(character);
49
+ if (!allUpper && !allLower) {
47
50
  break;
48
51
  }
49
52
  }
50
- if (!tailChecked) {
51
- tailUpper = letter.test(firstFound) && upper.test(firstFound);
52
- tailLower = letter.test(firstFound) && lower.test(firstFound);
53
- }
54
- if (tailUpper && (replacementLower || !replacementUpper)) {
55
- return head + replacementTail.toUpperCase();
56
- }
57
- if (tailLower && (replacementLower || replacementUpper)) {
58
- return head + replacementTail.toLowerCase();
59
- }
60
- return head + replacementTail;
53
+ return { upper: allUpper, lower: allLower, hasLetters };
61
54
  }
package/dist/structure.js CHANGED
@@ -85,7 +85,7 @@ function indentationFolds(lineCount, getLine, tabSize, minimum) {
85
85
  }
86
86
  /* Ranges that span the same lines collapse into the first, which is the outermost. */
87
87
  export function deriveFoldingRanges(ranges, lineAt, lineCount, getLine, options = {}, slice) {
88
- const minimum = options.minLines === undefined ? 1 : Math.max(1, Number.isFinite(options.minLines) ? Math.trunc(options.minLines) : 1);
88
+ const minimum = Number.isFinite(options.minLines) ? Math.max(1, Math.trunc(options.minLines)) : 1;
89
89
  const result = [];
90
90
  for (const range of ranges) {
91
91
  if (range.kind === 'string' || (range.kind === 'bracket' ? options.brackets === false : options.comments === false)) {
@@ -61,6 +61,11 @@ function scanLiteral(state, text, at, end, offset) {
61
61
  }
62
62
  return at;
63
63
  }
64
+ function openComment(state, mode, start, resume) {
65
+ state.mode = mode;
66
+ state.commentStart = start;
67
+ return resume;
68
+ }
64
69
  function scanCode(state, rules, text, at, end, offset) {
65
70
  const char = text[at];
66
71
  const next = text[at + 1];
@@ -68,29 +73,16 @@ function scanCode(state, rules, text, at, end, offset) {
68
73
  return at;
69
74
  }
70
75
  if (rules.markup && text.startsWith('<!--', at)) {
71
- state.mode = 'html-comment';
72
- state.commentStart = offset + at;
73
- return at + 3;
76
+ return openComment(state, 'html-comment', offset + at, at + 3);
74
77
  }
75
78
  if (rules.hash && char === '#' && !(rules.php && next === '[') && (!rules.hashAtWordStart || startsWord(text, at))) {
76
- state.mode = 'line-comment';
77
- state.commentStart = offset + at;
78
- return at;
79
- }
80
- if (rules.dash && char === '-' && next === '-') {
81
- state.mode = 'line-comment';
82
- state.commentStart = offset + at;
83
- return at + 1;
79
+ return openComment(state, 'line-comment', offset + at, at);
84
80
  }
85
- if (rules.slash && char === '/' && next === '/') {
86
- state.mode = 'line-comment';
87
- state.commentStart = offset + at;
88
- return at + 1;
81
+ if ((rules.dash && char === '-' && next === '-') || (rules.slash && char === '/' && next === '/')) {
82
+ return openComment(state, 'line-comment', offset + at, at + 1);
89
83
  }
90
84
  if (rules.block && char === '/' && next === '*') {
91
- state.mode = 'block-comment';
92
- state.commentStart = offset + at;
93
- return at + 1;
85
+ return openComment(state, 'block-comment', offset + at, at + 1);
94
86
  }
95
87
  if (rules.php && char === '<' && text.startsWith('<<<', at)) {
96
88
  const opener = PHP_HEREDOC.exec(text.slice(at));
package/dist/words.js CHANGED
@@ -5,6 +5,7 @@
5
5
  // `isLowerCaseOrDigit` and `isPunctuation` from JetBrains/intellij-community at
6
6
  // 7184b03af6c5370e79a01ded0df68cf56d7669da, platform/platform-impl/src/com/intellij/openapi/editor/actions/EditorActionUtil.java,
7
7
  // lines 934-982.
8
+ import { splitsSurrogate } from './search.js';
8
9
  // Java's Character.isJavaIdentifierPart, which counts the ignorable control characters as part of a word.
9
10
  // oxlint-disable-next-line no-control-regex
10
11
  const identifierPart = /[\p{L}\p{Nl}\p{Sc}\p{Pc}\p{Nd}\p{Mc}\p{Mn}\p{Cf}\u0000-\u0008\u000e-\u001b\u007f-\u009f]/u;
@@ -59,11 +60,8 @@ export function isWordBoundary(text, offset, camel, isStart) {
59
60
  export function wordBoundary(text, offset, direction, camel = true) {
60
61
  let position = offset + direction;
61
62
  while (position > 0 && position < text.length) {
62
- const before = text.charAt(position - 1);
63
- const after = text.charAt(position);
64
- const splitsPair = /[\ud800-\udbff]/.test(before) && /[\udc00-\udfff]/.test(after);
65
- const splitsLineBreak = before === '\r' && after === '\n';
66
- if (!splitsPair && !splitsLineBreak && isWordBoundary(text, position, camel, direction === -1)) {
63
+ const splitsLineBreak = text.charAt(position - 1) === '\r' && text.charAt(position) === '\n';
64
+ if (!splitsSurrogate(text, position) && !splitsLineBreak && isWordBoundary(text, position, camel, direction === -1)) {
67
65
  return position;
68
66
  }
69
67
  position += direction;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/editor-core",
3
- "version": "0.21.1",
3
+ "version": "0.22.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -15,16 +15,9 @@ const HEREDOC = /<<<[ \t]*(["']?)([A-Za-z_]\w*)\1\s*$/;
15
15
  function markdownFolds(lineCount: number, getLine: (line: number) => DocumentLine): FoldingRange[] {
16
16
  const result: FoldingRange[] = [];
17
17
  const headings: { line: number; level: number }[] = [];
18
- let first = 0;
19
- if (lineCount > 0 && getLine(0).text.trim() === '---') {
20
- for (let line = 1; line < lineCount; line++) {
21
- const text = getLine(line).text.trim();
22
- if (text === '---' || text === '...') {
23
- result.push(rangeOf(0, line, getLine, 'block', 'front-matter'));
24
- first = line + 1;
25
- break;
26
- }
27
- }
18
+ const frontMatter = frontMatterEnd(lineCount, getLine);
19
+ if (frontMatter !== -1) {
20
+ result.push(rangeOf(0, frontMatter, getLine, 'block', 'front-matter'));
28
21
  }
29
22
  let tableStart = -1;
30
23
  const flushTable = (end: number): void => {
@@ -33,19 +26,12 @@ function markdownFolds(lineCount: number, getLine: (line: number) => DocumentLin
33
26
  }
34
27
  tableStart = -1;
35
28
  };
36
- for (let line = first; line < lineCount; line++) {
29
+ for (let line = frontMatter + 1; line < lineCount; line++) {
37
30
  const text = getLine(line).text;
38
31
  const fence = FENCE.exec(text);
39
32
  if (fence !== null) {
40
33
  flushTable(line - 1);
41
- const marker = fence[1]!;
42
- let end = -1;
43
- for (let next = line + 1; next < lineCount && end === -1; next++) {
44
- const closer = FENCE.exec(getLine(next).text);
45
- if (closer !== null && closer[1]![0] === marker[0] && closer[1]!.length >= marker.length && getLine(next).text.trim() === closer[1]) {
46
- end = next;
47
- }
48
- }
34
+ const end = fenceEnd(line, fence[1]!, lineCount, getLine);
49
35
  if (end !== -1) {
50
36
  result.push(rangeOf(line, end, getLine, 'block', 'code-fence'));
51
37
  line = end;
@@ -76,6 +62,31 @@ function markdownFolds(lineCount: number, getLine: (line: number) => DocumentLin
76
62
  return result;
77
63
  }
78
64
 
65
+ /* The line that closes front matter opened on the first line, or -1. */
66
+ function frontMatterEnd(lineCount: number, getLine: (line: number) => DocumentLine): number {
67
+ if (lineCount === 0 || getLine(0).text.trim() !== '---') {
68
+ return -1;
69
+ }
70
+ for (let line = 1; line < lineCount; line++) {
71
+ const text = getLine(line).text.trim();
72
+ if (text === '---' || text === '...') {
73
+ return line;
74
+ }
75
+ }
76
+ return -1;
77
+ }
78
+
79
+ /* The line that closes a fence opened on `line` with `marker`, or -1. */
80
+ function fenceEnd(line: number, marker: string, lineCount: number, getLine: (line: number) => DocumentLine): number {
81
+ for (let next = line + 1; next < lineCount; next++) {
82
+ const closer = FENCE.exec(getLine(next).text)?.[1];
83
+ if (closer !== undefined && closer[0] === marker[0] && closer.length >= marker.length && getLine(next).text.trim() === closer) {
84
+ return next;
85
+ }
86
+ }
87
+ return -1;
88
+ }
89
+
79
90
  /* `<?php ... ?>` blocks that sit between markup, and heredocs and nowdocs. A block that opens the file is the file, not a fold. */
80
91
  function phpFolds(lineCount: number, getLine: (line: number) => DocumentLine): FoldingRange[] {
81
92
  const result: FoldingRange[] = [];
@@ -5,7 +5,7 @@ interface Mark {
5
5
  close: number;
6
6
  }
7
7
 
8
- const limit = 64;
8
+ const maxMarks = 64;
9
9
 
10
10
  /*
11
11
  * The closers the editor inserted by itself and the caret has not left yet, which Tab steps over. A
@@ -17,7 +17,7 @@ export class CloserTracker {
17
17
 
18
18
  add(open: number, close: number): void {
19
19
  this.marks.push({ open, close });
20
- if (this.marks.length > limit) {
20
+ if (this.marks.length > maxMarks) {
21
21
  this.marks.shift();
22
22
  }
23
23
  }
package/src/comments.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { EditPlan, EditSource } from './edit-source.ts';
2
2
  import { type BlockComment, type CommentSyntax, commentSyntax } from './languages.ts';
3
+ import { indentText, isBlank, rangeOf, whitespaceOf } from './line-text.ts';
3
4
  import { mapOffset } from './offsets.ts';
4
5
  import { indentationColumn } from './structure.ts';
5
6
  import type { Selection, TextEdit } from './types.ts';
@@ -26,23 +27,11 @@ interface Geometry {
26
27
  length: number;
27
28
  }
28
29
 
29
- function whitespaceOf(text: string): string {
30
- return /^[\t ]*/.exec(text)![0];
31
- }
32
-
33
- function isBlank(text: string): boolean {
34
- return text.trim() === '';
35
- }
36
-
37
30
  function syntaxOf(source: EditSource, options: CommentOptions, line: number): CommentSyntax {
38
31
  const syntax = commentSyntax(options.language, source.region(line));
39
32
  return options.lineToken === undefined ? syntax : { ...syntax, line: options.lineToken };
40
33
  }
41
34
 
42
- function rangeOf(selection: Selection): { from: number; to: number } {
43
- return { from: Math.min(selection.anchor, selection.head), to: Math.max(selection.anchor, selection.head) };
44
- }
45
-
46
35
  /* The runs of adjacent lines the selections touch. A selection that ends at the start of a line does not touch it. */
47
36
  function blocksOf(source: EditSource, selections: readonly Selection[], span?: CommentOptions['lineSpan']): Block[] {
48
37
  const blocks: Block[] = [];
@@ -80,13 +69,6 @@ function isCommented(text: string, syntax: CommentSyntax, multiline: boolean): b
80
69
  return trimmed.length >= block.open.length + block.close.length && trimmed.startsWith(block.open) && trimmed.endsWith(block.close);
81
70
  }
82
71
 
83
- function indentText(width: number, options: CommentOptions): string {
84
- if (options.insertSpaces) {
85
- return ' '.repeat(width);
86
- }
87
- return '\t'.repeat(Math.floor(width / options.tabSize)) + ' '.repeat(width % options.tabSize);
88
- }
89
-
90
72
  /* The new place of a line after the edits, which never cross a line break. */
91
73
  function geometryOf(source: EditSource, edits: readonly TextEdit[], index: number): Geometry {
92
74
  const bounds = source.line(index);
@@ -162,14 +144,20 @@ export function planLineComments(source: EditSource, selections: readonly Select
162
144
  return { edits, selections: finalSelections(source, selections, blocks, edits, allCommented, placed, lineBased, options.lineSpan) };
163
145
  }
164
146
 
165
- function minimumIndent(source: EditSource, block: Block, syntax: CommentSyntax, options: CommentOptions): number {
147
+ /* The indentation of the least indented non-blank line, infinite when every line is blank. */
148
+ function smallestIndent(source: EditSource, first: number, last: number, tabSize: number): number {
166
149
  let minimum = Number.POSITIVE_INFINITY;
167
- for (let line = block.first; line <= block.last; line++) {
150
+ for (let line = first; line <= last; line++) {
168
151
  const text = source.line(line).text;
169
152
  if (!isBlank(text)) {
170
- minimum = Math.min(minimum, indentationColumn(whitespaceOf(text), options.tabSize));
153
+ minimum = Math.min(minimum, indentationColumn(whitespaceOf(text), tabSize));
171
154
  }
172
155
  }
156
+ return minimum;
157
+ }
158
+
159
+ function minimumIndent(source: EditSource, block: Block, syntax: CommentSyntax, options: CommentOptions): number {
160
+ let minimum = smallestIndent(source, block.first, block.last, options.tabSize);
173
161
  if (block.first > 0) {
174
162
  const previous = source.line(block.first - 1).text;
175
163
  const marker = syntax.line ?? syntax.block?.open;
@@ -394,13 +382,7 @@ function wrapRange(source: EditSource, from: number, to: number, block: BlockCom
394
382
  { from: to, to, text: ` ${block.close}` }
395
383
  ];
396
384
  }
397
- let minimum = Number.POSITIVE_INFINITY;
398
- for (let line = first; line <= last; line++) {
399
- const text = source.line(line).text;
400
- if (!isBlank(text)) {
401
- minimum = Math.min(minimum, indentationColumn(whitespaceOf(text), options.tabSize));
402
- }
403
- }
385
+ const minimum = smallestIndent(source, first, last, options.tabSize);
404
386
  const space = indentText(Number.isFinite(minimum) ? minimum : 0, options);
405
387
  const newline = source.newline(first);
406
388
  const closing = source.charAt(to - 1) === '\n' ? `${space}${block.close}${newline}` : `${newline}${space}${block.close}`;
package/src/document.ts CHANGED
@@ -10,6 +10,7 @@ import { isPlainText, vueRegionAt } from './languages.ts';
10
10
  import { blockFolds } from './block-folds.ts';
11
11
  import { applyFoldHints, markFileHeader } from './fold-roles.ts';
12
12
  import { lexicalFolds } from './lexical-folds.ts';
13
+ import { rangeOf, whitespaceOf } from './line-text.ts';
13
14
  import { isQuote, replacesComparison, surround, swapQuotes } from './typing-handlers.ts';
14
15
  import { scanBrackets } from './brackets.ts';
15
16
  import type { BracketIndex } from './brackets.ts';
@@ -38,7 +39,6 @@ import { isHumpBoundary, isWordBoundary } from './words.ts';
38
39
 
39
40
  export type { DocumentLine, FindMatch, FindNextOptions, FindOptions, FoldingOptions, FoldingRange };
40
41
 
41
- /* A stretch of the text by offsets. */
42
42
  export interface OffsetRange {
43
43
  readonly from: number;
44
44
  readonly to: number;
@@ -154,18 +154,10 @@ const arrowContinues = /^[\p{L}_{]/u;
154
154
  const wordCommand = /^(select|delete)?(word|camel)(Left|Right)$/i;
155
155
  const graphemes = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
156
156
 
157
- function rangeOf(selection: Selection): { from: number; to: number } {
158
- return { from: Math.min(selection.anchor, selection.head), to: Math.max(selection.anchor, selection.head) };
159
- }
160
-
161
157
  function sameSelections(left: readonly Selection[], right: readonly Selection[]): boolean {
162
158
  return left.length === right.length && left.every((selection, index) => selection.anchor === right[index]!.anchor && selection.head === right[index]!.head);
163
159
  }
164
160
 
165
- function indentLength(text: string): number {
166
- return text.match(/^[\t ]*/)?.[0].length ?? 0;
167
- }
168
-
169
161
  function trailingBlankLength(text: string): number {
170
162
  return text.match(/[\t ]*$/)?.[0].length ?? 0;
171
163
  }
@@ -274,13 +266,13 @@ function inverseChanges(changes: readonly DocumentChange[]): DocumentChange[] {
274
266
  });
275
267
  }
276
268
 
277
- /* Runs of consecutive line numbers, from a sorted list. */
278
269
  type LineSpanOf = NonNullable<CommandOptions['lineSpan']>;
279
270
 
280
271
  function range(first: number, last: number): number[] {
281
272
  return Array.from({ length: last - first + 1 }, (_, index) => first + index);
282
273
  }
283
274
 
275
+ /* Runs of consecutive line numbers, from a sorted list. */
284
276
  function blocks(indices: readonly number[]): LineBlock[] {
285
277
  const result: LineBlock[] = [];
286
278
  for (const index of indices) {
@@ -313,7 +305,8 @@ function indentStopLength(prefix: string, tabSize: number): number {
313
305
 
314
306
  function positionIn(rope: TextRope, offset: number): Position {
315
307
  const line = rope.lineAt(offset);
316
- return { line, column: Math.min(offset, rope.lineBounds(line).end) - rope.lineBounds(line).start };
308
+ const bounds = rope.lineBounds(line);
309
+ return { line, column: Math.min(offset, bounds.end) - bounds.start };
317
310
  }
318
311
 
319
312
  /* 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. */
@@ -351,6 +344,15 @@ function stretchEdits(before: TextRope, after: TextRope, transactions: readonly
351
344
  return [{ start: positionIn(before, prefix), end: positionIn(before, oldEnd), text: after.slice(prefix, newEnd) }];
352
345
  }
353
346
 
347
+ /* An occurrence selected with its caret `shift` characters into it, as the caret stood in the selection it repeats. */
348
+ function occurrenceSelection(match: OffsetRange, shift: number): Selection {
349
+ if (shift === 0) {
350
+ return { anchor: match.to, head: match.from };
351
+ }
352
+ const caret = Math.min(match.from + shift, match.to);
353
+ return { anchor: caret === match.to ? match.from : match.to, head: caret };
354
+ }
355
+
354
356
  /*
355
357
  * Text with a selection, undo history and the editing commands of a code editor, and no idea how it
356
358
  * is drawn. Offsets are UTF-16 code units, lines and columns zero-based. The text is a persistent
@@ -557,10 +559,7 @@ export class DocumentModel {
557
559
  }
558
560
 
559
561
  positionAt(offset: number): Position {
560
- const clamped = offsetIn(this.rope, offset);
561
- const line = this.rope.lineAt(clamped);
562
- const bounds = this.rope.lineBounds(line);
563
- return { line, column: Math.min(clamped, bounds.end) - bounds.start };
562
+ return positionIn(this.rope, offsetIn(this.rope, offset));
564
563
  }
565
564
 
566
565
  /* A column past the end of its line lands on the end of the line, before the line break. */
@@ -930,7 +929,7 @@ export class DocumentModel {
930
929
  if (!/^[\t ]*$/.test(before)) {
931
930
  return null;
932
931
  }
933
- const indent = this.getLine(this.rope.lineAt(opener)).text.match(/^[\t ]*/)?.[0] ?? '';
932
+ const indent = whitespaceOf(this.getLine(this.rope.lineAt(opener)).text);
934
933
  if (indent === before) {
935
934
  return null;
936
935
  }
@@ -1341,16 +1340,11 @@ export class DocumentModel {
1341
1340
  return plan !== null && this.applyEdits(plan.edits, { source: 'command', selections: plan.selections });
1342
1341
  }
1343
1342
 
1344
- /* The lines the carets and selections touch. A selection that ends at the start of a line does not touch it. */
1343
+ /* The lines the carets and selections touch, sorted. */
1345
1344
  private selectedLines(span?: LineSpanOf): number[] {
1346
1345
  const indices = new Set<number>();
1347
1346
  for (const selection of this.selections) {
1348
- const { from, to } = rangeOf(selection);
1349
- let first = this.rope.lineAt(from);
1350
- let last = this.rope.lineAt(to);
1351
- if (to > from && this.rope.lineBounds(last).start === to) {
1352
- last--;
1353
- }
1347
+ let { first, last } = this.touchedLines(selection);
1354
1348
  if (span) {
1355
1349
  first = span(first).first;
1356
1350
  last = Math.max(last, span(last).last);
@@ -1362,6 +1356,14 @@ export class DocumentModel {
1362
1356
  return [...indices].sort((left, right) => left - right);
1363
1357
  }
1364
1358
 
1359
+ /* The lines a selection touches; one that ends at the start of a line does not touch it. */
1360
+ private touchedLines(selection: Selection): LineBlock {
1361
+ const { from, to } = rangeOf(selection);
1362
+ const first = this.rope.lineAt(from);
1363
+ const last = this.rope.lineAt(to);
1364
+ return { first, last: last > first && this.rope.lineBounds(last).start === to ? last - 1 : last };
1365
+ }
1366
+
1365
1367
  /* A selection is copied in place, its copy selected; a caret duplicates its line and moves down with it. */
1366
1368
  private duplicate(span?: LineSpanOf): boolean {
1367
1369
  const carets = this.selections.filter((selection) => selection.anchor === selection.head);
@@ -1542,7 +1544,7 @@ export class DocumentModel {
1542
1544
  const index = this.rope.lineAt(selection.head);
1543
1545
  const line = this.rope.lineBounds(index);
1544
1546
  const text = this.getLine(index).text;
1545
- const indent = (text.match(/^[\t ]*/)?.[0] ?? '') + (/^[\t ]*[)\]}]/.test(text) ? unit : '');
1547
+ const indent = whitespaceOf(text) + (/^[\t ]*[)\]}]/.test(text) ? unit : '');
1546
1548
  return { from: line.start, to: line.start, text: indent + this.newlineAt(index), anchor: indent.length, head: indent.length };
1547
1549
  }),
1548
1550
  { source: 'command' }
@@ -1601,7 +1603,7 @@ export class DocumentModel {
1601
1603
  return true;
1602
1604
  }
1603
1605
  const closer = index.pairs.get(offset);
1604
- const indentAt = (at: number): number => this.getLine(this.rope.lineAt(at)).text.match(/^[\t ]*/)![0].length;
1606
+ const indentAt = (at: number): number => whitespaceOf(this.getLine(this.rope.lineAt(at)).text).length;
1605
1607
  if (closer === undefined || indentAt(closer) >= indentAt(offset)) {
1606
1608
  return false;
1607
1609
  }
@@ -1958,7 +1960,7 @@ export class DocumentModel {
1958
1960
  { from: 0, to: this.rope.length }
1959
1961
  ];
1960
1962
  if (first.start === last.start) {
1961
- candidates.push({ from: first.start + indentLength(first.text), to: first.end - trailingBlankLength(first.text) });
1963
+ candidates.push({ from: first.start + whitespaceOf(first.text).length, to: first.end - trailingBlankLength(first.text) });
1962
1964
  }
1963
1965
  for (const range of this.structure()) {
1964
1966
  candidates.push({ from: range.innerFrom, to: range.innerTo }, range);
@@ -2033,10 +2035,7 @@ export class DocumentModel {
2033
2035
  this.rememberOccurrence(wholeWord, notFoundBefore);
2034
2036
  return false;
2035
2037
  }
2036
- const shift = primary.head - from;
2037
- const caret = Math.min(match.from + shift, match.to);
2038
- const added = shift === 0 ? { anchor: match.to, head: match.from } : { anchor: caret === match.to ? match.from : match.to, head: caret };
2039
- const changed = this.changeSelections([...this.selections, added]);
2038
+ const changed = this.changeSelections([...this.selections, occurrenceSelection(match, primary.head - from)]);
2040
2039
  this.rememberOccurrence(wholeWord, false);
2041
2040
  return changed;
2042
2041
  }
@@ -2071,12 +2070,7 @@ export class DocumentModel {
2071
2070
  if (matches.length === 0) {
2072
2071
  return false;
2073
2072
  }
2074
- const changed = this.changeSelections(
2075
- matches.map((match) => {
2076
- const caret = Math.min(match.from + shift, match.to);
2077
- return shift === 0 ? { anchor: match.to, head: match.from } : { anchor: caret === match.to ? match.from : match.to, head: caret };
2078
- })
2079
- );
2073
+ const changed = this.changeSelections(matches.map((match) => occurrenceSelection(match, shift)));
2080
2074
  this.rememberOccurrence(wholeWord, false);
2081
2075
  return changed;
2082
2076
  }
@@ -2086,12 +2080,7 @@ export class DocumentModel {
2086
2080
  const forward = this.selections.at(-1)!.head >= this.selections.at(-1)!.anchor;
2087
2081
  const carets: Selection[] = [];
2088
2082
  for (const selection of this.selections) {
2089
- const { from, to } = rangeOf(selection);
2090
- const first = this.rope.lineAt(from);
2091
- let last = this.rope.lineAt(to);
2092
- if (last > first && to === this.rope.lineBounds(last).start) {
2093
- last--;
2094
- }
2083
+ const { first, last } = this.touchedLines(selection);
2095
2084
  for (let line = first; line <= last; line++) {
2096
2085
  const end = this.rope.lineBounds(line).end;
2097
2086
  carets.push({ anchor: end, head: end });