@adecore/editor-core 0.41.1 → 0.43.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.
@@ -0,0 +1,8 @@
1
+ import type { EditSource } from './edit-source.ts';
2
+ import type { Selection } from './types.ts';
3
+ export interface CodeBlockOptions {
4
+ tabSize: number;
5
+ guides: boolean;
6
+ mate(offset: number): number | undefined;
7
+ }
8
+ export declare function planCodeBlock(source: EditSource, selections: readonly Selection[], edge: 'start' | 'end', extend: boolean, options: CodeBlockOptions): Selection[];
@@ -0,0 +1,59 @@
1
+ import { isBlank, textStart } from './line-text.js';
2
+ import { enclosingBraces, indentGuideAt, visualWidth } from './statements.js';
3
+ function guideTarget(source, head, edge, options) {
4
+ const line = source.lineAt(head);
5
+ const info = source.line(line);
6
+ const column = visualWidth(info.text.slice(0, head - info.start), options.tabSize);
7
+ const guide = indentGuideAt(source, line, column, options.tabSize);
8
+ if (guide === null) {
9
+ return null;
10
+ }
11
+ if (edge === 'start') {
12
+ return guide.startLine === line ? null : textStart(source.line(guide.startLine));
13
+ }
14
+ if (guide.endLine >= source.lineCount) {
15
+ return source.line(source.lineCount - 1).end;
16
+ }
17
+ const last = source.line(guide.endLine);
18
+ return isBlank(last.text) ? last.end : textStart(last);
19
+ }
20
+ function braceTarget(source, head, edge, options) {
21
+ if (edge === 'end') {
22
+ if (source.charAt(head) === '{') {
23
+ const mate = options.mate(head);
24
+ if (mate !== undefined && mate > head) {
25
+ return mate + 1;
26
+ }
27
+ }
28
+ for (const brace of enclosingBraces(source, head)) {
29
+ const mate = options.mate(brace);
30
+ // A caret already in front of the closer goes on to the block around it.
31
+ if (mate !== undefined && mate > head) {
32
+ return mate;
33
+ }
34
+ }
35
+ return null;
36
+ }
37
+ if (head > 0 && source.charAt(head - 1) === '}') {
38
+ const mate = options.mate(head - 1);
39
+ if (mate !== undefined && mate < head) {
40
+ return mate;
41
+ }
42
+ }
43
+ for (const brace of enclosingBraces(source, head)) {
44
+ if (brace + 1 < head) {
45
+ return brace + 1;
46
+ }
47
+ }
48
+ return null;
49
+ }
50
+ /* Where each caret goes for Code Block Start or End; a caret with no block around it stays. The selection extends from its anchor when `extend` is set. */
51
+ export function planCodeBlock(source, selections, edge, extend, options) {
52
+ return selections.map((selection) => {
53
+ const target = (options.guides ? guideTarget(source, selection.head, edge, options) : null) ?? braceTarget(source, selection.head, edge, options);
54
+ if (target === null) {
55
+ return selection;
56
+ }
57
+ return { anchor: extend ? selection.anchor : target, head: target };
58
+ });
59
+ }
@@ -0,0 +1,8 @@
1
+ import type { EditPlan, EditSource } from './edit-source.ts';
2
+ import type { Selection } from './types.ts';
3
+ export interface CompleteStatementOptions {
4
+ language: string;
5
+ unit: string;
6
+ tabSize: number;
7
+ }
8
+ export declare function planCompleteStatement(source: EditSource, selections: readonly Selection[], options: CompleteStatementOptions): EditPlan;
@@ -0,0 +1,157 @@
1
+ import { continues, planEnter } from './enter.js';
2
+ import { isBraced, statementTerminator } from './lexical.js';
3
+ import { whitespaceOf } from './line-text.js';
4
+ import { BODIES, DECLARATION, declarationBody, METHOD, NOT_METHODS, statementStart } from './statements.js';
5
+ const CONTROL = /^(?:else\s+if|elseif|if|for|foreach|while|switch|catch|with)\b/;
6
+ const PYTHON_BLOCK = /^(?:async\s+)?(?:if|elif|else|for|while|def|class|try|except|finally|with|match|case)\b/;
7
+ function startNewLine(source, line, options) {
8
+ const end = source.line(line).end;
9
+ return planEnter(source, end, end, { ...options, smart: true, reach: false });
10
+ }
11
+ /* The offset a statement on this line ends at: before a trailing comment and the whitespace in front of it. Null where the line is not code. */
12
+ function statementEnd(source, line, caret) {
13
+ const info = source.line(line);
14
+ if (/comment/.test(source.context(caret).mode)) {
15
+ return null;
16
+ }
17
+ const atEnd = source.context(info.end);
18
+ let end = info.end;
19
+ if (atEnd.mode === 'line-comment' && atEnd.commentStart !== undefined) {
20
+ end = atEnd.commentStart;
21
+ }
22
+ while (end > info.start && /\s/.test(source.charAt(end - 1))) {
23
+ end--;
24
+ }
25
+ if (end === info.start) {
26
+ return null;
27
+ }
28
+ const mode = source.context(end).mode;
29
+ return mode === 'code' || mode === 'quote' ? end : null;
30
+ }
31
+ /* The parenthesis of a control statement closes at the end of the text: `if (a)`, and no body after it. */
32
+ function endsWithCondition(head) {
33
+ const open = head.indexOf('(');
34
+ if (open < 0) {
35
+ return false;
36
+ }
37
+ let depth = 0;
38
+ for (let i = open; i < head.length; i++) {
39
+ depth += head[i] === '(' ? 1 : head[i] === ')' ? -1 : 0;
40
+ if (depth === 0) {
41
+ return i === head.length - 1;
42
+ }
43
+ }
44
+ return false;
45
+ }
46
+ function completeBraced(source, lineIndex, caret, options) {
47
+ const line = source.line(lineIndex);
48
+ const end = statementEnd(source, lineIndex, caret);
49
+ if (end === null || (source.region(lineIndex) ?? 'script') !== 'script') {
50
+ return startNewLine(source, lineIndex, options);
51
+ }
52
+ const context = source.context(end);
53
+ const first = statementStart(source, lineIndex, options.language);
54
+ const firstStart = source.line(first).start;
55
+ const startsWithBrace = /^\s*\}/.test(line.text);
56
+ let inline = context.mode === 'quote' ? (context.quote ?? '') : '';
57
+ let open = context.bracket;
58
+ for (; open !== undefined && open.template !== true && !(open.close === '}' && open.block === true) && open.at >= firstStart; open = open.previous) {
59
+ inline += open.close;
60
+ }
61
+ const tail = source.slice(end, line.end);
62
+ const newline = source.newline(lineIndex);
63
+ const indent = whitespaceOf(startsWithBrace ? line.text : source.line(first).text);
64
+ const body = indent + options.unit;
65
+ const written = (startsWithBrace ? source.slice(line.start, end) : source.slice(firstStart, end)) + inline;
66
+ const head = (startsWithBrace ? written.trim().slice(1) : written).trim();
67
+ const nothing = () => startNewLine(source, lineIndex, options);
68
+ const replaceTail = (text, caretAt) => ({ from: end, to: line.end, text, caret: caretAt });
69
+ // A block with its closer on a line of its own and the caret on the line between.
70
+ const block = (opener, closer = '}') => {
71
+ const opened = `${inline}${opener}${tail}${newline}${body}`;
72
+ return replaceTail(`${opened}${newline}${indent}${closer}`, opened.length);
73
+ };
74
+ if (inline === '' && head.endsWith('{}') && source.context(end - 1).bracket?.block === true) {
75
+ return { from: end - 1, to: line.end, text: `${newline}${body}${newline}${indent}}${tail}`, caret: newline.length + body.length };
76
+ }
77
+ if (inline === '' && head.endsWith('{')) {
78
+ if (tail === '') {
79
+ return startNewLine(source, lineIndex, options);
80
+ }
81
+ const opened = `${tail}${newline}${body}`;
82
+ return replaceTail(source.unclosedBrace(end - 1) ? `${opened}${newline}${indent}}` : opened, opened.length);
83
+ }
84
+ const control = CONTROL.exec(head);
85
+ const condition = control === null ? null : head.slice(control[0].length).trim();
86
+ if (control !== null && condition === '') {
87
+ return replaceTail(`${inline} () {${tail}${newline}${indent}}`, inline.length + 2);
88
+ }
89
+ if (control !== null && !(control[0] === 'while' && startsWithBrace) && condition.startsWith('(') && endsWithCondition(condition)) {
90
+ return block(' {');
91
+ }
92
+ if (/^(?:else|try|finally|do)$/.test(head)) {
93
+ return block(' {', head === 'do' ? '} while ();' : '}');
94
+ }
95
+ if ((/^case\b/.test(head) && head.length > 4 && !head.endsWith(':')) || head === 'default') {
96
+ const text = `${inline}:${tail}${newline}${body}`;
97
+ return replaceTail(text, text.length);
98
+ }
99
+ const declaring = declarationBody(source, end);
100
+ const declaration = DECLARATION.exec(head)?.[1];
101
+ const method = declaration === undefined && declaring === 'other' ? METHOD.exec(head)?.[1] : undefined;
102
+ if (declaring !== 'interface' && (declaration !== undefined || (method !== undefined && !NOT_METHODS.has(method)))) {
103
+ const bodiless = /^(?:\w+\s+)*?(?:abstract|declare)\s/.test(head) && !BODIES.has(declaration ?? '');
104
+ if (!bodiless) {
105
+ return block(' {');
106
+ }
107
+ }
108
+ const terminator = statementTerminator(options.language);
109
+ const last = head.at(-1);
110
+ const closedBlock = inline === '' && last === '}' && source.context(end - 1).bracket?.block === true;
111
+ const inExpression = open !== undefined && !(open.close === '}' && open.block === true);
112
+ const goesOn = inline === '' && continues(source.slice(line.start, end), context, options.language);
113
+ const ends = terminator !== null && head !== '' && !/^(?:@|#\[)/.test(head) && !';{,:'.includes(last) && !closedBlock && !inExpression && !goesOn;
114
+ const added = inline + (ends ? terminator : '');
115
+ return added === '' ? nothing() : { from: end, to: end, text: added, caret: added.length };
116
+ }
117
+ function completePython(source, lineIndex, caret, options) {
118
+ const line = source.line(lineIndex);
119
+ const end = statementEnd(source, lineIndex, caret);
120
+ if (end === null || source.context(end).mode !== 'code') {
121
+ return startNewLine(source, lineIndex, options);
122
+ }
123
+ let inline = '';
124
+ for (let open = source.context(end).bracket; open !== undefined; open = open.previous) {
125
+ inline += open.close;
126
+ }
127
+ const head = (source.slice(line.start, end) + inline).trim();
128
+ const tail = source.slice(end, line.end);
129
+ if (PYTHON_BLOCK.test(head) && !head.endsWith(':')) {
130
+ const text = `${inline}:${tail}${source.newline(lineIndex)}${whitespaceOf(line.text)}${options.unit}`;
131
+ return { from: end, to: line.end, text, caret: text.length };
132
+ }
133
+ return inline === '' ? startNewLine(source, lineIndex, options) : { from: end, to: end, text: inline, caret: inline.length };
134
+ }
135
+ /* Where Complete Current Statement puts its edits for the carets. Every caret acts on its line, and carets on one line count once. */
136
+ export function planCompleteStatement(source, selections, options) {
137
+ const complete = isBraced(options.language) ? completeBraced : /^(python|py)$/i.test(options.language) ? completePython : null;
138
+ const pieces = new Map();
139
+ for (const selection of selections) {
140
+ const line = source.lineAt(selection.head);
141
+ pieces.set(line, complete === null ? startNewLine(source, line, options) : complete(source, line, selection.head, options));
142
+ }
143
+ const ordered = [...pieces.entries()].sort((left, right) => left[1].from - right[1].from);
144
+ const carets = new Map();
145
+ let delta = 0;
146
+ for (const [line, piece] of ordered) {
147
+ carets.set(line, piece.from + delta + piece.caret);
148
+ delta += piece.text.length - (piece.to - piece.from);
149
+ }
150
+ return {
151
+ edits: ordered.map(([, piece]) => ({ from: piece.from, to: piece.to, text: piece.text })),
152
+ selections: selections.map((selection) => {
153
+ const caret = carets.get(source.lineAt(selection.head));
154
+ return { anchor: caret, head: caret };
155
+ })
156
+ };
157
+ }
@@ -3,6 +3,8 @@ import type { DocumentLine } from './rope.ts';
3
3
  import type { FindMatch, FindNextOptions, FindOptions } from './search.ts';
4
4
  import type { FoldingOptions, FoldingRange } from './structure.ts';
5
5
  import type { CommandOptions, Disposable, EditOptions, EditorCommand, EditorSnapshot, Position, Selection, TextEdit } from './types.ts';
6
+ import { type TodoMatch, type TodoOptions } from './todo.ts';
7
+ import { type WhitespaceCleanupOptions } from './whitespace-cleanup.ts';
6
8
  export type { DocumentLine, FindMatch, FindNextOptions, FindOptions, FoldingOptions, FoldingRange };
7
9
  export interface OffsetRange {
8
10
  readonly from: number;
@@ -41,11 +43,17 @@ export declare class DocumentModel {
41
43
  private structureCache?;
42
44
  private selectionHistory;
43
45
  private typingContexts;
46
+ private readonly modified;
44
47
  private statementClosers;
45
48
  private arrows;
46
49
  private cloneSession;
47
50
  private occurrenceSession;
48
51
  occurrencesExhausted: boolean;
52
+ hippieSource: {
53
+ from: number;
54
+ to: number;
55
+ } | null;
56
+ private hippieState;
49
57
  private closers;
50
58
  private bracketCache?;
51
59
  private bracketTracker;
@@ -76,6 +84,12 @@ export declare class DocumentModel {
76
84
  replaceAll(query: string, replacement: string, options?: ReplaceAllOptions): number;
77
85
  replaceMatches(matches: readonly FindMatch[], replacement: string, options?: ReplaceOptions): number;
78
86
  getFoldingRanges(options?: FoldingOptions): FoldingRange[];
87
+ findTodos(language: string, options?: TodoOptions, first?: number, last?: number): TodoMatch[];
88
+ markSaved(): void;
89
+ getModifiedLines(): number[];
90
+ cleanUpWhitespace(options?: WhitespaceCleanupOptions & {
91
+ language?: string;
92
+ }): boolean;
79
93
  typeText(text: string, options?: TypeTextOptions): boolean;
80
94
  private takeBackArrows;
81
95
  private completeArrows;
@@ -115,6 +129,8 @@ export declare class DocumentModel {
115
129
  private touchedLines;
116
130
  private duplicate;
117
131
  private deleteLines;
132
+ private moveStatements;
133
+ private completeFromWords;
118
134
  private moveLines;
119
135
  private insertNewline;
120
136
  private startNewLineBefore;
package/dist/document.js CHANGED
@@ -2,6 +2,10 @@ import { scriptSource } from './script-source.js';
2
2
  import { CloserTracker } from './closer-tracker.js';
3
3
  import { planBlockComment, planLineComments } from './comments.js';
4
4
  import { planEnter } from './enter.js';
5
+ import { planCodeBlock } from './code-blocks.js';
6
+ import { planCompleteStatement } from './complete-statement.js';
7
+ import { planMoveStatement, statementMoveMode } from './statement-move.js';
8
+ import { planHippie } from './hippie.js';
5
9
  import { planAutoIndent, planJoinLines, planToggleCase, properIndent, spacingBetween } from './line-commands.js';
6
10
  import { mapOffset, offsetMapper } from './offsets.js';
7
11
  import { planPaste } from './paste.js';
@@ -18,7 +22,13 @@ import { clampInteger, TextRope, ROPE_CHUNK_SIZE } from './rope.js';
18
22
  import { findMatches, findMatchFrom, replacementText, splitsSurrogate } from './search.js';
19
23
  import { dedupeFoldingRanges, deriveFoldingRanges, indentationColumn, scanStructure, tabWidth } from './structure.js';
20
24
  import { TypingContexts } from './typing-context.js';
25
+ import { todosInLines } from './todo.js';
26
+ import { planConvertIndents, planLineOrder } from './line-order.js';
27
+ import { ModifiedStretches, planWhitespaceCleanup } from './whitespace-cleanup.js';
21
28
  import { isHumpBoundary, isWordBoundary } from './words.js';
29
+ const LINE_ORDERS = { sortLines: 'sort', reverseLines: 'reverse', uniqueLines: 'unique' };
30
+ /* Where trailing whitespace is part of a string's value, which a cleanup must not change. */
31
+ const STRING_MODES = new Set(['quote', 'template', 'heredoc']);
22
32
  /* 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. */
23
33
  const MOVE_FORWARD = { word: 'end', line: 'start' };
24
34
  const MOVE_BACKWARD = { word: 'start', line: 'end' };
@@ -33,6 +43,7 @@ const identifier = /[\p{L}\p{N}\p{M}\p{Pc}$]/u;
33
43
  /* What can follow `->`: the first letter of a member name, or the `{` of a dynamic one. */
34
44
  const arrowContinues = /^[\p{L}_{]/u;
35
45
  const wordCommand = /^(select|delete)?(word|camel)(Left|Right)$/i;
46
+ const codeBlockCommand = /^codeBlock(Start|End)(WithSelection)?$/;
36
47
  let graphemes;
37
48
  function sameSelections(left, right) {
38
49
  return left.length === right.length && left.every((selection, index) => selection.anchor === right[index].anchor && selection.head === right[index].head);
@@ -263,6 +274,7 @@ export class DocumentModel {
263
274
  structureCache;
264
275
  selectionHistory = [];
265
276
  typingContexts = new TypingContexts((line) => this.getLine(line));
277
+ modified = new ModifiedStretches();
266
278
  statementClosers = new Map();
267
279
  /* The carets that stand right after a `->` the editor completed, where the next keystroke decides whether it stays. */
268
280
  arrows = new Set();
@@ -270,6 +282,9 @@ export class DocumentModel {
270
282
  occurrenceSession;
271
283
  /* The last select next occurrence found no more, which a host may say. */
272
284
  occurrencesExhausted = false;
285
+ /* Where the word the last Hippie completion took from this document stands, which a view reads right after the command to show it. */
286
+ hippieSource = null;
287
+ hippieState = null;
273
288
  closers = new CloserTracker();
274
289
  bracketCache;
275
290
  bracketTracker = new BracketTracker(() => this.rope);
@@ -534,6 +549,41 @@ export class DocumentModel {
534
549
  markFileHeader(unique, getLine, this.getLineCount(), options.language);
535
550
  return options.hints === undefined ? unique : applyFoldHints(unique, options.hints, lineAt, getLine, slice, minimum);
536
551
  }
552
+ /* The TODO comments of the lines from `first` through `last`. It reads the lexer state the editing commands keep, so after an edit only the lines from the edit on are lexed again. */
553
+ findTodos(language, options = {}, first = 0, last = this.getLineCount() - 1) {
554
+ const source = {
555
+ lineCount: this.getLineCount(),
556
+ line: (index) => this.getLine(index),
557
+ context: (line, column) => this.typingContexts.at(line, column, language)
558
+ };
559
+ return todosInLines(source, options, first, last);
560
+ }
561
+ /* Forgets which lines changed, as when the text was just written to disk. */
562
+ markSaved() {
563
+ this.modified.clear();
564
+ }
565
+ /* The zero-based lines changed since `markSaved`, or since the document was made, undo and redo included. */
566
+ getModifiedLines() {
567
+ return this.modified.lines((offset) => this.rope.lineAt(offset), (line) => this.rope.lineBounds(line).start);
568
+ }
569
+ /*
570
+ * Cleans up the whitespace before a save, as one undo step: the trailing whitespace of the modified or
571
+ * of all lines, except on the lines with a caret and inside a multiline string of the language, the
572
+ * blank lines at the end, and a final line break. Returns whether the text changed.
573
+ */
574
+ cleanUpWhitespace(options = {}) {
575
+ const language = options.language ?? 'plaintext';
576
+ const modified = new Set(options.trailingWhitespace === 'modified' ? this.getModifiedLines() : []);
577
+ const source = {
578
+ lineCount: this.getLineCount(),
579
+ line: (index) => this.getLine(index),
580
+ newline: (index) => this.newlineAt(index),
581
+ keeps: (line, column) => STRING_MODES.has(this.typingContexts.at(line, column, language).mode)
582
+ };
583
+ const carets = new Set(this.selections.map((selection) => this.rope.lineAt(selection.head)));
584
+ const edits = planWhitespaceCleanup(source, options, { modified: (line) => modified.has(line), carets });
585
+ return edits.length > 0 && this.applyEdits(edits, { source: 'command' });
586
+ }
537
587
  /*
538
588
  * Types `text` at every caret as one keystroke would: it wraps a selection in a bracket or quote,
539
589
  * closes an opener, types over a closer that is already there and, in TypeScript, JavaScript and
@@ -661,6 +711,25 @@ export class DocumentModel {
661
711
  const pairing = options.autoClosingPairs !== false && !isPlainText(language);
662
712
  return this.deleteCharacter(command === 'smartBackspace', { tabSize, unit }, { brackets: pairing && options.autoClosingBrackets !== false, quotes: pairing && options.autoClosingQuotes !== false }, language);
663
713
  }
714
+ if (command === 'moveStatementUp' || command === 'moveStatementDown') {
715
+ return this.moveStatements(command === 'moveStatementUp' ? -1 : 1, { language, unit, tabSize }, options);
716
+ }
717
+ if (command === 'hippieCompletion' || command === 'hippieBackwardCompletion') {
718
+ return this.completeFromWords(command === 'hippieCompletion' ? 1 : -1, { language, wordSources: options.wordSources });
719
+ }
720
+ if (command === 'completeCurrentStatement') {
721
+ const plan = planCompleteStatement(this.editSource(language), this.selections, { language, unit, tabSize });
722
+ return this.applyEdits(plan.edits, { source: 'command', selections: plan.selections });
723
+ }
724
+ const blockMatch = codeBlockCommand.exec(command);
725
+ if (blockMatch) {
726
+ const plan = planCodeBlock(this.editSource(language), this.selections, blockMatch[1] === 'Start' ? 'start' : 'end', blockMatch[2] !== undefined, {
727
+ tabSize,
728
+ guides: options.indentGuides === true,
729
+ mate: (offset) => this.matchBracket(offset, language)?.mate
730
+ });
731
+ return this.changeSelections(plan);
732
+ }
664
733
  const enter = { language, unit, tabSize, smart: options.smartEnter !== false, reach: true };
665
734
  if (command === 'insertNewline' || command === 'startNewLine' || command === 'splitLine') {
666
735
  return this.insertNewline(enter, command === 'insertNewline' ? 'enter' : command === 'startNewLine' ? 'below' : 'split');
@@ -671,6 +740,15 @@ export class DocumentModel {
671
740
  if (command === 'joinLines' || command === 'toggleCase' || command === 'autoIndentLines') {
672
741
  return this.editLines(command, { language, unit });
673
742
  }
743
+ const order = LINE_ORDERS[command];
744
+ if (order !== undefined) {
745
+ const plan = planLineOrder(this.editSource(language), this.selections, order);
746
+ return plan !== null && this.applyEdits(plan.edits, { source: 'command', selections: plan.selections });
747
+ }
748
+ if (command === 'convertIndentsToSpaces' || command === 'convertIndentsToTabs') {
749
+ const edits = planConvertIndents(this.editSource(language), this.selections, tabSize, command === 'convertIndentsToSpaces');
750
+ return edits.length > 0 && this.applyEdits(edits, { source: 'command' });
751
+ }
674
752
  if (command === 'insertTab') {
675
753
  return this.insertTab(tabSize, options);
676
754
  }
@@ -1283,6 +1361,47 @@ export class DocumentModel {
1283
1361
  }
1284
1362
  return this.applyEdits(edits, { source: 'command', selections });
1285
1363
  }
1364
+ /* Moves the statement at each caret past its neighbor; a language without brackets or indentation to go by moves lines. */
1365
+ moveStatements(direction, settings, options) {
1366
+ if (statementMoveMode(settings.language) === null) {
1367
+ return this.moveLines(this.selectedLines(options.lineSpan), direction, options);
1368
+ }
1369
+ const structure = options.structure?.() ?? this.getFoldingRanges({ language: settings.language, tabSize: settings.tabSize });
1370
+ const plan = planMoveStatement(this.editSource(settings.language), this.selections, direction, { ...settings, structure, lineSpan: options.lineSpan });
1371
+ if (plan === null) {
1372
+ return false;
1373
+ }
1374
+ const changed = this.applyEdits(plan.edits, { source: 'command', selections: plan.selections });
1375
+ if (changed) {
1376
+ options.onLinesMoved?.(plan.moves);
1377
+ }
1378
+ return changed;
1379
+ }
1380
+ /* Hippie completion: the word before every caret becomes the next longer word that starts the same, from this document and then from the others. */
1381
+ completeFromWords(direction, options) {
1382
+ const carets = this.selections.map((selection) => selection.head).sort((a, b) => a - b);
1383
+ const primary = this.selections.at(-1).head;
1384
+ const held = this.hippieState;
1385
+ const continues = held !== null &&
1386
+ held.revision === this.revision &&
1387
+ held.cycle.carets.length === carets.length &&
1388
+ held.cycle.carets.every((caret, i) => caret === carets[i]);
1389
+ const plan = planHippie(this.getText(), carets, primary, continues ? held.cycle : null, direction, options);
1390
+ this.hippieState = null;
1391
+ this.hippieSource = null;
1392
+ if (plan === null) {
1393
+ return false;
1394
+ }
1395
+ const primaryIndex = carets.indexOf(primary);
1396
+ const landed = plan.carets.map((caret) => ({ anchor: caret, head: caret }));
1397
+ const [primaryCaret] = landed.splice(primaryIndex, 1);
1398
+ this.applyEdits(plan.edits, { source: 'command', selections: [...landed, primaryCaret] });
1399
+ if (plan.cycle !== null) {
1400
+ this.hippieState = { cycle: plan.cycle, revision: this.revision };
1401
+ this.hippieSource = plan.source;
1402
+ }
1403
+ return true;
1404
+ }
1286
1405
  moveLines(indices, direction, options) {
1287
1406
  const span = options.lineSpan ?? ((line) => ({ first: line, last: line }));
1288
1407
  const groups = blocks(indices).filter((block) => (direction === -1 ? block.first > 0 : block.last < this.getLineCount() - 1));
@@ -1945,6 +2064,9 @@ export class DocumentModel {
1945
2064
  emit(changes, source, contentEdits, typed) {
1946
2065
  const snapshot = this.getSnapshot();
1947
2066
  if (changes) {
2067
+ for (const transaction of changes) {
2068
+ this.modified.record(transaction);
2069
+ }
1948
2070
  snapshot.changes = changes.map((transaction) => transaction.map((change) => ({ ...change })));
1949
2071
  snapshot.source = source;
1950
2072
  if (typed) {
@@ -0,0 +1,34 @@
1
+ import type { TextEdit } from './types.ts';
2
+ export interface HippieOptions {
3
+ language: string;
4
+ wordSources?: () => readonly string[];
5
+ }
6
+ interface Word {
7
+ text: string;
8
+ start: number;
9
+ end: number;
10
+ }
11
+ interface Variant extends Word {
12
+ source: 'here' | 'other';
13
+ side: 'before' | 'after';
14
+ }
15
+ export interface HippieCycle {
16
+ prefix: string;
17
+ length: number;
18
+ carets: readonly number[];
19
+ proposal: Pick<Variant, 'text' | 'source' | 'side'>;
20
+ }
21
+ export interface HippiePlan {
22
+ edits: TextEdit[];
23
+ carets: number[];
24
+ cycle: HippieCycle | null;
25
+ source: {
26
+ from: number;
27
+ to: number;
28
+ } | null;
29
+ }
30
+ export declare function hippieWords(text: string, hyphenated: boolean): Word[];
31
+ export declare function hippiePrefixStart(text: string, caret: number, hyphenated: boolean): number;
32
+ export declare function hippieMatches(word: string, prefix: string): boolean;
33
+ export declare function planHippie(text: string, carets: readonly number[], primary: number, cycle: HippieCycle | null, direction: 1 | -1, options: HippieOptions): HippiePlan | null;
34
+ export {};