@adecore/editor-core 0.41.1 → 0.42.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.
@@ -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,6 +43,7 @@ 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;
@@ -76,6 +79,12 @@ export declare class DocumentModel {
76
79
  replaceAll(query: string, replacement: string, options?: ReplaceAllOptions): number;
77
80
  replaceMatches(matches: readonly FindMatch[], replacement: string, options?: ReplaceOptions): number;
78
81
  getFoldingRanges(options?: FoldingOptions): FoldingRange[];
82
+ findTodos(language: string, options?: TodoOptions, first?: number, last?: number): TodoMatch[];
83
+ markSaved(): void;
84
+ getModifiedLines(): number[];
85
+ cleanUpWhitespace(options?: WhitespaceCleanupOptions & {
86
+ language?: string;
87
+ }): boolean;
79
88
  typeText(text: string, options?: TypeTextOptions): boolean;
80
89
  private takeBackArrows;
81
90
  private completeArrows;
package/dist/document.js CHANGED
@@ -18,7 +18,13 @@ import { clampInteger, TextRope, ROPE_CHUNK_SIZE } from './rope.js';
18
18
  import { findMatches, findMatchFrom, replacementText, splitsSurrogate } from './search.js';
19
19
  import { dedupeFoldingRanges, deriveFoldingRanges, indentationColumn, scanStructure, tabWidth } from './structure.js';
20
20
  import { TypingContexts } from './typing-context.js';
21
+ import { todosInLines } from './todo.js';
22
+ import { planConvertIndents, planLineOrder } from './line-order.js';
23
+ import { ModifiedStretches, planWhitespaceCleanup } from './whitespace-cleanup.js';
21
24
  import { isHumpBoundary, isWordBoundary } from './words.js';
25
+ const LINE_ORDERS = { sortLines: 'sort', reverseLines: 'reverse', uniqueLines: 'unique' };
26
+ /* Where trailing whitespace is part of a string's value, which a cleanup must not change. */
27
+ const STRING_MODES = new Set(['quote', 'template', 'heredoc']);
22
28
  /* 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
29
  const MOVE_FORWARD = { word: 'end', line: 'start' };
24
30
  const MOVE_BACKWARD = { word: 'start', line: 'end' };
@@ -263,6 +269,7 @@ export class DocumentModel {
263
269
  structureCache;
264
270
  selectionHistory = [];
265
271
  typingContexts = new TypingContexts((line) => this.getLine(line));
272
+ modified = new ModifiedStretches();
266
273
  statementClosers = new Map();
267
274
  /* The carets that stand right after a `->` the editor completed, where the next keystroke decides whether it stays. */
268
275
  arrows = new Set();
@@ -534,6 +541,41 @@ export class DocumentModel {
534
541
  markFileHeader(unique, getLine, this.getLineCount(), options.language);
535
542
  return options.hints === undefined ? unique : applyFoldHints(unique, options.hints, lineAt, getLine, slice, minimum);
536
543
  }
544
+ /* 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. */
545
+ findTodos(language, options = {}, first = 0, last = this.getLineCount() - 1) {
546
+ const source = {
547
+ lineCount: this.getLineCount(),
548
+ line: (index) => this.getLine(index),
549
+ context: (line, column) => this.typingContexts.at(line, column, language)
550
+ };
551
+ return todosInLines(source, options, first, last);
552
+ }
553
+ /* Forgets which lines changed, as when the text was just written to disk. */
554
+ markSaved() {
555
+ this.modified.clear();
556
+ }
557
+ /* The zero-based lines changed since `markSaved`, or since the document was made, undo and redo included. */
558
+ getModifiedLines() {
559
+ return this.modified.lines((offset) => this.rope.lineAt(offset), (line) => this.rope.lineBounds(line).start);
560
+ }
561
+ /*
562
+ * Cleans up the whitespace before a save, as one undo step: the trailing whitespace of the modified or
563
+ * of all lines, except on the lines with a caret and inside a multiline string of the language, the
564
+ * blank lines at the end, and a final line break. Returns whether the text changed.
565
+ */
566
+ cleanUpWhitespace(options = {}) {
567
+ const language = options.language ?? 'plaintext';
568
+ const modified = new Set(options.trailingWhitespace === 'modified' ? this.getModifiedLines() : []);
569
+ const source = {
570
+ lineCount: this.getLineCount(),
571
+ line: (index) => this.getLine(index),
572
+ newline: (index) => this.newlineAt(index),
573
+ keeps: (line, column) => STRING_MODES.has(this.typingContexts.at(line, column, language).mode)
574
+ };
575
+ const carets = new Set(this.selections.map((selection) => this.rope.lineAt(selection.head)));
576
+ const edits = planWhitespaceCleanup(source, options, { modified: (line) => modified.has(line), carets });
577
+ return edits.length > 0 && this.applyEdits(edits, { source: 'command' });
578
+ }
537
579
  /*
538
580
  * Types `text` at every caret as one keystroke would: it wraps a selection in a bracket or quote,
539
581
  * closes an opener, types over a closer that is already there and, in TypeScript, JavaScript and
@@ -671,6 +713,15 @@ export class DocumentModel {
671
713
  if (command === 'joinLines' || command === 'toggleCase' || command === 'autoIndentLines') {
672
714
  return this.editLines(command, { language, unit });
673
715
  }
716
+ const order = LINE_ORDERS[command];
717
+ if (order !== undefined) {
718
+ const plan = planLineOrder(this.editSource(language), this.selections, order);
719
+ return plan !== null && this.applyEdits(plan.edits, { source: 'command', selections: plan.selections });
720
+ }
721
+ if (command === 'convertIndentsToSpaces' || command === 'convertIndentsToTabs') {
722
+ const edits = planConvertIndents(this.editSource(language), this.selections, tabSize, command === 'convertIndentsToSpaces');
723
+ return edits.length > 0 && this.applyEdits(edits, { source: 'command' });
724
+ }
674
725
  if (command === 'insertTab') {
675
726
  return this.insertTab(tabSize, options);
676
727
  }
@@ -1945,6 +1996,9 @@ export class DocumentModel {
1945
1996
  emit(changes, source, contentEdits, typed) {
1946
1997
  const snapshot = this.getSnapshot();
1947
1998
  if (changes) {
1999
+ for (const transaction of changes) {
2000
+ this.modified.record(transaction);
2001
+ }
1948
2002
  snapshot.changes = changes.map((transaction) => transaction.map((change) => ({ ...change })));
1949
2003
  snapshot.source = source;
1950
2004
  if (typed) {
package/dist/index.d.ts CHANGED
@@ -4,6 +4,9 @@ export { scanBrackets, type BracketIndex, type BracketMatch } from './brackets.t
4
4
  export { findMatches, iterateMatches, findMatchFrom, replacementText, type FindMatch, type FindNextOptions, type FindOptions } from './search.ts';
5
5
  export { indentationColumn } from './structure.ts';
6
6
  export { findDocComments, MAX_DOC_COMMENTS, type DocComment } from './doc-comments.ts';
7
+ export { DEFAULT_TODO_WORDS, findTodos, type TodoMatch, type TodoOptions } from './todo.ts';
8
+ export { detectIndentation, type DetectedIndentation } from './line-order.ts';
9
+ export type { TrailingWhitespace, WhitespaceCleanupOptions } from './whitespace-cleanup.ts';
7
10
  export type { FoldHints, FoldRangeHint, FoldRole, FoldSymbolHint, FoldingOptions, FoldingRange } from './structure.ts';
8
11
  export type { ChangeSource, CommandOptions, ContentEdit, Disposable, DocumentChange, EditOptions, EditorCommand, EditorSnapshot, Position, Selection, TextEdit } from './types.ts';
9
12
  export { changedSpan, changedSpans, type TextSpan } from './text-span.ts';
package/dist/index.js CHANGED
@@ -3,4 +3,6 @@ export { scanBrackets } from './brackets.js';
3
3
  export { findMatches, iterateMatches, findMatchFrom, replacementText } from './search.js';
4
4
  export { indentationColumn } from './structure.js';
5
5
  export { findDocComments, MAX_DOC_COMMENTS } from './doc-comments.js';
6
+ export { DEFAULT_TODO_WORDS, findTodos } from './todo.js';
7
+ export { detectIndentation } from './line-order.js';
6
8
  export { changedSpan, changedSpans } from './text-span.js';
@@ -0,0 +1,10 @@
1
+ import type { EditPlan, EditSource } from './edit-source.ts';
2
+ import type { Selection, TextEdit } from './types.ts';
3
+ export type LineOrder = 'sort' | 'reverse' | 'unique';
4
+ export declare function planLineOrder(source: EditSource, selections: readonly Selection[], order: LineOrder): EditPlan | null;
5
+ export declare function planConvertIndents(source: EditSource, selections: readonly Selection[], tabSize: number, insertSpaces: boolean): TextEdit[];
6
+ export interface DetectedIndentation {
7
+ insertSpaces: boolean;
8
+ tabSize?: number;
9
+ }
10
+ export declare function detectIndentation(text: string): DetectedIndentation | null;
@@ -0,0 +1,156 @@
1
+ import { indentText, rangeOf, whitespaceOf } from './line-text.js';
2
+ import { indentationColumn } from './structure.js';
3
+ /* The runs of lines the selections touch, or every line when nothing is selected. A selection that ends at the start of a line does not touch it. */
4
+ function runsOf(source, selections) {
5
+ const ranges = selections.map(rangeOf).filter(({ from, to }) => from !== to);
6
+ const wanted = ranges.length > 0 ? ranges : [{ from: 0, to: source.line(source.lineCount - 1).end }];
7
+ const runs = [];
8
+ for (const { from, to } of [...wanted].sort((left, right) => left.from - right.from)) {
9
+ const first = source.lineAt(from);
10
+ let last = source.lineAt(to);
11
+ if (last > first && source.line(last).start === to) {
12
+ last--;
13
+ }
14
+ const previous = runs.at(-1);
15
+ if (previous !== undefined && first <= previous.last) {
16
+ previous.last = Math.max(previous.last, last);
17
+ }
18
+ else {
19
+ runs.push({ first, last });
20
+ }
21
+ }
22
+ return runs;
23
+ }
24
+ function reorder(lines, order) {
25
+ if (order === 'reverse') {
26
+ return [...lines].reverse();
27
+ }
28
+ if (order === 'unique') {
29
+ return [...new Set(lines)];
30
+ }
31
+ return [...lines].sort((left, right) => (left < right ? -1 : left > right ? 1 : 0));
32
+ }
33
+ /*
34
+ * Sorts, reverses or dedupes the lines the selections touch, or the whole text with nothing selected,
35
+ * each run of lines on its own. Every line break stays where it was, so a last line without one stays
36
+ * without one. Sorting compares by code unit, so capitals come first, and keeps equal lines in order;
37
+ * dedupe keeps the first of equal lines. A selection keeps its offsets, as far as the text still reaches.
38
+ */
39
+ export function planLineOrder(source, selections, order) {
40
+ const edits = [];
41
+ const runs = [];
42
+ for (const { first, last } of runsOf(source, selections)) {
43
+ if (last <= first) {
44
+ continue;
45
+ }
46
+ const lines = [];
47
+ const breaks = [];
48
+ for (let index = first; index <= last; index++) {
49
+ lines.push(source.line(index).text);
50
+ if (index < last) {
51
+ breaks.push(source.newline(index));
52
+ }
53
+ }
54
+ const result = reorder(lines, order);
55
+ const text = result.map((line, index) => line + (index < result.length - 1 ? breaks[index] : '')).join('');
56
+ const from = source.line(first).start;
57
+ const to = source.line(last).end;
58
+ if (text !== source.slice(from, to)) {
59
+ edits.push({ from, to, text });
60
+ runs.push({ from, to, length: text.length });
61
+ }
62
+ }
63
+ if (edits.length === 0) {
64
+ return null;
65
+ }
66
+ const place = (offset) => {
67
+ let shift = 0;
68
+ for (const run of runs) {
69
+ if (offset < run.from) {
70
+ break;
71
+ }
72
+ if (offset < run.to) {
73
+ return Math.min(offset, run.from + run.length) + shift;
74
+ }
75
+ shift += run.length - (run.to - run.from);
76
+ }
77
+ return offset + shift;
78
+ };
79
+ return { edits, selections: selections.map((selection) => ({ anchor: place(selection.anchor), head: place(selection.head) })) };
80
+ }
81
+ /*
82
+ * Rewrites the indentation of the lines the selections touch, or of every line with nothing selected:
83
+ * to spaces with each tab taken to its stop, or to as many tabs as fit and the spaces left over. Only the
84
+ * whitespace a line starts with changes.
85
+ */
86
+ export function planConvertIndents(source, selections, tabSize, insertSpaces) {
87
+ const edits = [];
88
+ for (const { first, last } of runsOf(source, selections)) {
89
+ for (let index = first; index <= last; index++) {
90
+ const line = source.line(index);
91
+ const current = whitespaceOf(line.text);
92
+ const target = indentText(indentationColumn(current, tabSize), { tabSize, insertSpaces });
93
+ if (current !== target) {
94
+ edits.push({ from: line.start, to: line.start + current.length, text: target });
95
+ }
96
+ }
97
+ }
98
+ return edits;
99
+ }
100
+ /* Past this many lines the rest says nothing new. */
101
+ const DETECT_LINES = 10_000;
102
+ const MAX_INDENT_SIZE = 8;
103
+ /*
104
+ * Reads the indentation a text already uses, to edit it the same way. Tabs win when more lines start with
105
+ * a tab than with spaces; otherwise the width of one level is the step between a line and a deeper one
106
+ * after it that occurs most often. On a tie the step more indents are a multiple of wins, so a line
107
+ * aligned under a parenthesis does not pass for a level, and then the smaller. The lines inside a block
108
+ * comment that start with a star are left out, since their single space is no level. Null when no line says.
109
+ */
110
+ export function detectIndentation(text) {
111
+ const steps = new Map();
112
+ const widths = [];
113
+ let tabs = 0;
114
+ let spaces = 0;
115
+ let previous = 0;
116
+ let count = 0;
117
+ for (const line of text.split(/\r\n|\r|\n/)) {
118
+ if (++count > DETECT_LINES) {
119
+ break;
120
+ }
121
+ const indent = whitespaceOf(line);
122
+ const content = line.slice(indent.length);
123
+ if (content === '' || content.startsWith('*')) {
124
+ continue;
125
+ }
126
+ if (indent.startsWith('\t')) {
127
+ tabs++;
128
+ previous = -1;
129
+ continue;
130
+ }
131
+ if (indent.includes('\t')) {
132
+ previous = -1;
133
+ continue;
134
+ }
135
+ if (indent.length > 0) {
136
+ spaces++;
137
+ widths.push(indent.length);
138
+ }
139
+ const step = indent.length - previous;
140
+ if (previous >= 0 && step > 0 && step <= MAX_INDENT_SIZE) {
141
+ steps.set(step, (steps.get(step) ?? 0) + 1);
142
+ }
143
+ previous = indent.length;
144
+ }
145
+ if (tabs > spaces) {
146
+ return { insertSpaces: false };
147
+ }
148
+ let best = null;
149
+ for (const [size, uses] of steps) {
150
+ const fits = widths.filter((width) => width % size === 0).length;
151
+ if (best === null || uses > best.uses || (uses === best.uses && (fits > best.fits || (fits === best.fits && size < best.size)))) {
152
+ best = { size, uses, fits };
153
+ }
154
+ }
155
+ return best === null ? null : { insertSpaces: true, tabSize: best.size };
156
+ }
package/dist/todo.d.ts ADDED
@@ -0,0 +1,21 @@
1
+ import type { DocumentLine } from './rope.ts';
2
+ import { type TypingContext } from './typing-context.ts';
3
+ export declare const DEFAULT_TODO_WORDS: readonly string[];
4
+ export interface TodoMatch {
5
+ from: number;
6
+ to: number;
7
+ line: number;
8
+ word: string;
9
+ }
10
+ export interface TodoOptions {
11
+ words?: readonly string[];
12
+ }
13
+ export interface TodoSource {
14
+ lineCount: number;
15
+ line(index: number): DocumentLine;
16
+ context(line: number, column: number): TypingContext;
17
+ }
18
+ export declare function todoPattern(words?: readonly string[]): RegExp | null;
19
+ export declare function todosOnLine(source: TodoSource, index: number, pattern: RegExp, words: readonly string[]): TodoMatch[];
20
+ export declare function todosInLines(source: TodoSource, options?: TodoOptions, first?: number, last?: number): TodoMatch[];
21
+ export declare function findTodos(text: string, language: string, options?: TodoOptions): TodoMatch[];
package/dist/todo.js ADDED
@@ -0,0 +1,72 @@
1
+ import { TypingContexts } from './typing-context.js';
2
+ /* The words a TODO comment starts with when a host names none. */
3
+ export const DEFAULT_TODO_WORDS = ['TODO', 'FIXME'];
4
+ const CLOSERS = { 'block-comment': '*/', 'html-comment': '-->' };
5
+ function escape(word) {
6
+ return word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
7
+ }
8
+ /* Null when there is nothing to look for. */
9
+ export function todoPattern(words = DEFAULT_TODO_WORDS) {
10
+ const usable = words.map((word) => word.trim()).filter((word) => word !== '');
11
+ if (usable.length === 0) {
12
+ return null;
13
+ }
14
+ // Longest first, so `TODO` does not stop a list that also has `TODO_LATER` short.
15
+ const alternatives = [...usable].sort((left, right) => right.length - left.length).map(escape);
16
+ return new RegExp(`(?<![\\p{L}\\p{N}_])(?:${alternatives.join('|')})(?![\\p{L}\\p{N}_])`, 'giu');
17
+ }
18
+ /*
19
+ * The TODOs on one line. A word counts only where the lexer says a comment is, so one in a string or in
20
+ * code is no TODO, and a second word in the same comment is part of the first one's TODO.
21
+ */
22
+ export function todosOnLine(source, index, pattern, words) {
23
+ const line = source.line(index);
24
+ const result = [];
25
+ pattern.lastIndex = 0;
26
+ for (let found = pattern.exec(line.text); found !== null; found = pattern.exec(line.text)) {
27
+ const context = source.context(index, found.index);
28
+ const closer = CLOSERS[context.mode];
29
+ if (context.mode !== 'line-comment' && closer === undefined) {
30
+ continue;
31
+ }
32
+ const close = closer === undefined ? -1 : line.text.indexOf(closer, found.index + found[0].length);
33
+ const end = close === -1 ? line.text.length : close;
34
+ const word = words.find((candidate) => candidate.trim().toLowerCase() === found[0].toLowerCase()) ?? found[0];
35
+ result.push({ from: line.start + found.index, to: line.start + end, line: index, word: word.trim() });
36
+ pattern.lastIndex = Math.max(pattern.lastIndex, end);
37
+ }
38
+ return result;
39
+ }
40
+ /* Every TODO of the lines from `first` through `last`, in order. */
41
+ export function todosInLines(source, options = {}, first = 0, last = source.lineCount - 1) {
42
+ const words = options.words ?? DEFAULT_TODO_WORDS;
43
+ const pattern = todoPattern(words);
44
+ if (pattern === null) {
45
+ return [];
46
+ }
47
+ const result = [];
48
+ for (let index = Math.max(0, first); index <= Math.min(last, source.lineCount - 1); index++) {
49
+ result.push(...todosOnLine(source, index, pattern, words));
50
+ }
51
+ return result;
52
+ }
53
+ /*
54
+ * The TODO comments of a text, for a host that lists them. The language decides what a comment is, as
55
+ * it does for the editor's own commands, and a language without comments has none.
56
+ */
57
+ export function findTodos(text, language, options = {}) {
58
+ const lines = splitLines(text);
59
+ const contexts = new TypingContexts((index) => lines[index]);
60
+ return todosInLines({ lineCount: lines.length, line: (index) => lines[index], context: (line, column) => contexts.at(line, column, language) }, options);
61
+ }
62
+ function splitLines(text) {
63
+ const lines = [];
64
+ const breaks = /\r\n|\r|\n/g;
65
+ let start = 0;
66
+ for (let found = breaks.exec(text); found !== null; found = breaks.exec(text)) {
67
+ lines.push({ start, end: found.index, next: found.index + found[0].length, text: text.slice(start, found.index) });
68
+ start = found.index + found[0].length;
69
+ }
70
+ lines.push({ start, end: text.length, next: text.length, text: text.slice(start) });
71
+ return lines;
72
+ }
package/dist/types.d.ts CHANGED
@@ -43,7 +43,7 @@ export interface EditOptions {
43
43
  joinPrevious?: boolean;
44
44
  typed?: boolean;
45
45
  }
46
- export type EditorCommand = 'undo' | 'redo' | 'selectAll' | 'smartHome' | 'smartEnd' | 'selectSmartHome' | 'selectSmartEnd' | 'wordLeft' | 'wordRight' | 'selectWordLeft' | 'selectWordRight' | 'deleteWordLeft' | 'deleteWordRight' | 'camelLeft' | 'camelRight' | 'selectCamelLeft' | 'selectCamelRight' | 'deleteCamelLeft' | 'deleteCamelRight' | 'expandSelection' | 'shrinkSelection' | 'smartBackspace' | 'deleteForward' | 'duplicateLine' | 'deleteLine' | 'moveLineUp' | 'moveLineDown' | 'toggleLineComment' | 'toggleBlockComment' | 'insertTab' | 'indent' | 'outdent' | 'insertNewline' | 'startNewLine' | 'startNewLineBefore' | 'splitLine' | 'joinLines' | 'toggleCase' | 'autoIndentLines' | 'addCaretAbove' | 'addCaretBelow' | 'selectNextOccurrence' | 'unselectOccurrence' | 'selectAllOccurrences' | 'addCaretPerSelectedLine';
46
+ export type EditorCommand = 'undo' | 'redo' | 'selectAll' | 'smartHome' | 'smartEnd' | 'selectSmartHome' | 'selectSmartEnd' | 'wordLeft' | 'wordRight' | 'selectWordLeft' | 'selectWordRight' | 'deleteWordLeft' | 'deleteWordRight' | 'camelLeft' | 'camelRight' | 'selectCamelLeft' | 'selectCamelRight' | 'deleteCamelLeft' | 'deleteCamelRight' | 'expandSelection' | 'shrinkSelection' | 'smartBackspace' | 'deleteForward' | 'duplicateLine' | 'deleteLine' | 'moveLineUp' | 'moveLineDown' | 'toggleLineComment' | 'toggleBlockComment' | 'insertTab' | 'indent' | 'outdent' | 'insertNewline' | 'startNewLine' | 'startNewLineBefore' | 'splitLine' | 'joinLines' | 'toggleCase' | 'autoIndentLines' | 'addCaretAbove' | 'addCaretBelow' | 'selectNextOccurrence' | 'unselectOccurrence' | 'selectAllOccurrences' | 'addCaretPerSelectedLine' | 'sortLines' | 'reverseLines' | 'uniqueLines' | 'convertIndentsToSpaces' | 'convertIndentsToTabs';
47
47
  export interface CommandOptions {
48
48
  tabSize?: number;
49
49
  insertSpaces?: boolean;
@@ -0,0 +1,26 @@
1
+ import type { DocumentLine } from './rope.ts';
2
+ import type { DocumentChange, TextEdit } from './types.ts';
3
+ export type TrailingWhitespace = 'off' | 'modified' | 'all';
4
+ export interface WhitespaceCleanupOptions {
5
+ trailingWhitespace?: TrailingWhitespace;
6
+ keepCaretLines?: boolean;
7
+ removeTrailingBlankLines?: boolean;
8
+ ensureFinalNewline?: boolean;
9
+ }
10
+ export interface CleanupSource {
11
+ lineCount: number;
12
+ line(index: number): DocumentLine;
13
+ newline(index: number): string;
14
+ keeps?(line: number, column: number): boolean;
15
+ }
16
+ export interface CleanupLines {
17
+ modified(line: number): boolean;
18
+ carets: ReadonlySet<number>;
19
+ }
20
+ export declare function planWhitespaceCleanup(source: CleanupSource, options: WhitespaceCleanupOptions, lines: CleanupLines): TextEdit[];
21
+ export declare class ModifiedStretches {
22
+ private stretches;
23
+ clear(): void;
24
+ record(changes: readonly DocumentChange[]): void;
25
+ lines(lineAt: (offset: number) => number, lineStart: (line: number) => number): number[];
26
+ }
@@ -0,0 +1,100 @@
1
+ import { isBlank } from './line-text.js';
2
+ /* The edits that clean up the whitespace of a text before it is saved, all in the coordinates of the text as it is. */
3
+ export function planWhitespaceCleanup(source, options, lines) {
4
+ const edits = [];
5
+ let lastText = source.lineCount - 1;
6
+ if (options.removeTrailingBlankLines === true) {
7
+ while (lastText >= 0 && isBlank(source.line(lastText).text)) {
8
+ lastText--;
9
+ }
10
+ }
11
+ const mode = options.trailingWhitespace ?? 'off';
12
+ for (let index = 0; mode !== 'off' && index <= lastText; index++) {
13
+ const line = source.line(index);
14
+ const trailing = /[\t ]+$/.exec(line.text);
15
+ if (trailing === null) {
16
+ continue;
17
+ }
18
+ const kept = trailing.index;
19
+ if ((mode === 'modified' && !lines.modified(index)) ||
20
+ (options.keepCaretLines !== false && lines.carets.has(index)) ||
21
+ source.keeps?.(index, kept) === true) {
22
+ continue;
23
+ }
24
+ edits.push({ from: line.start + kept, to: line.end, text: '' });
25
+ }
26
+ const last = source.line(source.lineCount - 1);
27
+ if (lastText < source.lineCount - 1) {
28
+ edits.push({ from: lastText < 0 ? 0 : source.line(lastText + 1).start, to: last.end, text: '' });
29
+ }
30
+ // A last line that is empty, or that the cleanup emptied, already stands after a line break.
31
+ const end = source.line(source.lineCount - 1);
32
+ const emptied = edits.some((edit) => edit.from === end.start && edit.to === end.end);
33
+ if (options.ensureFinalNewline === true && lastText === source.lineCount - 1 && end.text !== '' && !emptied) {
34
+ edits.push({ from: end.end, to: end.end, text: source.lineCount > 1 ? source.newline(0) : '\n' });
35
+ }
36
+ return edits;
37
+ }
38
+ /*
39
+ * The stretches of a text that changed since it was last marked as saved, followed through every
40
+ * change after it, undo included. A deletion leaves an empty stretch, which still marks its line.
41
+ */
42
+ export class ModifiedStretches {
43
+ stretches = [];
44
+ clear() {
45
+ this.stretches = [];
46
+ }
47
+ /* One transaction, its changes in the coordinates of the text before it. */
48
+ record(changes) {
49
+ const ordered = [...changes].sort((left, right) => left.from - right.from);
50
+ let index = 0;
51
+ let shift = 0;
52
+ // The stretches are in order and apart, so one walk over the changes maps all their offsets.
53
+ const map = (offset, end) => {
54
+ for (; index < ordered.length; index++) {
55
+ const change = ordered[index];
56
+ if (offset < change.from || (offset === change.from && !end)) {
57
+ break;
58
+ }
59
+ if (offset < change.to) {
60
+ return change.from + shift + (end ? change.insertedLength : 0);
61
+ }
62
+ shift += change.insertedLength - (change.to - change.from);
63
+ }
64
+ return offset + shift;
65
+ };
66
+ const next = this.stretches.map((stretch) => ({ from: map(stretch.from, false), to: map(stretch.to, true) }));
67
+ shift = 0;
68
+ for (const change of ordered) {
69
+ const from = change.from + shift;
70
+ next.push({ from, to: from + change.insertedLength });
71
+ shift += change.insertedLength - (change.to - change.from);
72
+ }
73
+ next.sort((left, right) => left.from - right.from);
74
+ this.stretches = [];
75
+ for (const stretch of next) {
76
+ const previous = this.stretches.at(-1);
77
+ if (previous !== undefined && stretch.from <= previous.to) {
78
+ previous.to = Math.max(previous.to, stretch.to);
79
+ }
80
+ else {
81
+ this.stretches.push({ ...stretch });
82
+ }
83
+ }
84
+ }
85
+ /* The zero-based lines the stretches touch, in order. A stretch that ends at the start of a line, such as an inserted line, does not touch it. */
86
+ lines(lineAt, lineStart) {
87
+ const result = [];
88
+ for (const { from, to } of this.stretches) {
89
+ const first = lineAt(from);
90
+ let last = lineAt(to);
91
+ if (last > first && lineStart(last) === to) {
92
+ last--;
93
+ }
94
+ for (let line = Math.max(first, (result.at(-1) ?? -1) + 1); line <= last; line++) {
95
+ result.push(line);
96
+ }
97
+ }
98
+ return result;
99
+ }
100
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/editor-core",
3
- "version": "0.41.1",
3
+ "version": "0.42.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
package/src/document.ts CHANGED
@@ -37,10 +37,17 @@ import type {
37
37
  } from './types.ts';
38
38
  import { TypingContexts } from './typing-context.ts';
39
39
  import type { TypingContext } from './typing-context.ts';
40
+ import { todosInLines, type TodoMatch, type TodoOptions } from './todo.ts';
41
+ import { type LineOrder, planConvertIndents, planLineOrder } from './line-order.ts';
42
+ import { ModifiedStretches, planWhitespaceCleanup, type WhitespaceCleanupOptions } from './whitespace-cleanup.ts';
40
43
  import { isHumpBoundary, isWordBoundary } from './words.ts';
41
44
 
42
45
  export type { DocumentLine, FindMatch, FindNextOptions, FindOptions, FoldingOptions, FoldingRange };
43
46
 
47
+ const LINE_ORDERS: Partial<Record<EditorCommand, LineOrder>> = { sortLines: 'sort', reverseLines: 'reverse', uniqueLines: 'unique' };
48
+ /* Where trailing whitespace is part of a string's value, which a cleanup must not change. */
49
+ const STRING_MODES = new Set<TypingContext['mode']>(['quote', 'template', 'heredoc']);
50
+
44
51
  export interface OffsetRange {
45
52
  readonly from: number;
46
53
  readonly to: number;
@@ -456,6 +463,7 @@ export class DocumentModel {
456
463
  private structureCache?: StructureRange[];
457
464
  private selectionHistory: Selection[][] = [];
458
465
  private typingContexts = new TypingContexts((line) => this.getLine(line));
466
+ private readonly modified = new ModifiedStretches();
459
467
  private statementClosers = new Map<number, { text: string; language: string }>();
460
468
  /* The carets that stand right after a `->` the editor completed, where the next keystroke decides whether it stays. */
461
469
  private arrows = new Set<number>();
@@ -814,6 +822,52 @@ export class DocumentModel {
814
822
  return options.hints === undefined ? unique : applyFoldHints(unique, options.hints, lineAt, getLine, slice, minimum);
815
823
  }
816
824
 
825
+ /* 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. */
826
+ findTodos(language: string, options: TodoOptions = {}, first = 0, last = this.getLineCount() - 1): TodoMatch[] {
827
+ const source = {
828
+ lineCount: this.getLineCount(),
829
+ line: (index: number) => this.getLine(index),
830
+ context: (line: number, column: number) => this.typingContexts.at(line, column, language)
831
+ };
832
+
833
+ return todosInLines(source, options, first, last);
834
+ }
835
+
836
+ /* Forgets which lines changed, as when the text was just written to disk. */
837
+ markSaved(): void {
838
+ this.modified.clear();
839
+ }
840
+
841
+ /* The zero-based lines changed since `markSaved`, or since the document was made, undo and redo included. */
842
+ getModifiedLines(): number[] {
843
+ return this.modified.lines(
844
+ (offset) => this.rope.lineAt(offset),
845
+ (line) => this.rope.lineBounds(line).start
846
+ );
847
+ }
848
+
849
+ /*
850
+ * Cleans up the whitespace before a save, as one undo step: the trailing whitespace of the modified or
851
+ * of all lines, except on the lines with a caret and inside a multiline string of the language, the
852
+ * blank lines at the end, and a final line break. Returns whether the text changed.
853
+ */
854
+ cleanUpWhitespace(options: WhitespaceCleanupOptions & { language?: string } = {}): boolean {
855
+ const language = options.language ?? 'plaintext';
856
+ const modified = new Set(options.trailingWhitespace === 'modified' ? this.getModifiedLines() : []);
857
+
858
+ const source = {
859
+ lineCount: this.getLineCount(),
860
+ line: (index: number) => this.getLine(index),
861
+ newline: (index: number) => this.newlineAt(index),
862
+ keeps: (line: number, column: number) => STRING_MODES.has(this.typingContexts.at(line, column, language).mode)
863
+ };
864
+
865
+ const carets = new Set(this.selections.map((selection) => this.rope.lineAt(selection.head)));
866
+ const edits = planWhitespaceCleanup(source, options, { modified: (line) => modified.has(line), carets });
867
+
868
+ return edits.length > 0 && this.applyEdits(edits, { source: 'command' });
869
+ }
870
+
817
871
  /*
818
872
  * Types `text` at every caret as one keystroke would: it wraps a selection in a bracket or quote,
819
873
  * closes an opener, types over a closer that is already there and, in TypeScript, JavaScript and
@@ -1006,6 +1060,20 @@ export class DocumentModel {
1006
1060
  return this.editLines(command, { language, unit });
1007
1061
  }
1008
1062
 
1063
+ const order = LINE_ORDERS[command];
1064
+
1065
+ if (order !== undefined) {
1066
+ const plan = planLineOrder(this.editSource(language), this.selections, order);
1067
+
1068
+ return plan !== null && this.applyEdits(plan.edits, { source: 'command', selections: plan.selections });
1069
+ }
1070
+
1071
+ if (command === 'convertIndentsToSpaces' || command === 'convertIndentsToTabs') {
1072
+ const edits = planConvertIndents(this.editSource(language), this.selections, tabSize, command === 'convertIndentsToSpaces');
1073
+
1074
+ return edits.length > 0 && this.applyEdits(edits, { source: 'command' });
1075
+ }
1076
+
1009
1077
  if (command === 'insertTab') {
1010
1078
  return this.insertTab(tabSize, options);
1011
1079
  }
@@ -2738,6 +2806,10 @@ export class DocumentModel {
2738
2806
  const snapshot = this.getSnapshot();
2739
2807
 
2740
2808
  if (changes) {
2809
+ for (const transaction of changes) {
2810
+ this.modified.record(transaction);
2811
+ }
2812
+
2741
2813
  snapshot.changes = changes.map((transaction) => transaction.map((change) => ({ ...change })));
2742
2814
  snapshot.source = source;
2743
2815
 
package/src/index.ts CHANGED
@@ -4,6 +4,9 @@ export { scanBrackets, type BracketIndex, type BracketMatch } from './brackets.t
4
4
  export { findMatches, iterateMatches, findMatchFrom, replacementText, type FindMatch, type FindNextOptions, type FindOptions } from './search.ts';
5
5
  export { indentationColumn } from './structure.ts';
6
6
  export { findDocComments, MAX_DOC_COMMENTS, type DocComment } from './doc-comments.ts';
7
+ export { DEFAULT_TODO_WORDS, findTodos, type TodoMatch, type TodoOptions } from './todo.ts';
8
+ export { detectIndentation, type DetectedIndentation } from './line-order.ts';
9
+ export type { TrailingWhitespace, WhitespaceCleanupOptions } from './whitespace-cleanup.ts';
7
10
  export type { FoldHints, FoldRangeHint, FoldRole, FoldSymbolHint, FoldingOptions, FoldingRange } from './structure.ts';
8
11
 
9
12
  export type {
@@ -0,0 +1,215 @@
1
+ import type { EditPlan, EditSource } from './edit-source.ts';
2
+ import { indentText, rangeOf, whitespaceOf } from './line-text.ts';
3
+ import { indentationColumn } from './structure.ts';
4
+ import type { Selection, TextEdit } from './types.ts';
5
+
6
+ export type LineOrder = 'sort' | 'reverse' | 'unique';
7
+
8
+ interface LineRun {
9
+ first: number;
10
+ last: number;
11
+ }
12
+
13
+ /* The runs of lines the selections touch, or every line when nothing is selected. A selection that ends at the start of a line does not touch it. */
14
+ function runsOf(source: EditSource, selections: readonly Selection[]): LineRun[] {
15
+ const ranges = selections.map(rangeOf).filter(({ from, to }) => from !== to);
16
+ const wanted = ranges.length > 0 ? ranges : [{ from: 0, to: source.line(source.lineCount - 1).end }];
17
+ const runs: LineRun[] = [];
18
+
19
+ for (const { from, to } of [...wanted].sort((left, right) => left.from - right.from)) {
20
+ const first = source.lineAt(from);
21
+ let last = source.lineAt(to);
22
+
23
+ if (last > first && source.line(last).start === to) {
24
+ last--;
25
+ }
26
+
27
+ const previous = runs.at(-1);
28
+
29
+ if (previous !== undefined && first <= previous.last) {
30
+ previous.last = Math.max(previous.last, last);
31
+ } else {
32
+ runs.push({ first, last });
33
+ }
34
+ }
35
+
36
+ return runs;
37
+ }
38
+
39
+ function reorder(lines: readonly string[], order: LineOrder): string[] {
40
+ if (order === 'reverse') {
41
+ return [...lines].reverse();
42
+ }
43
+
44
+ if (order === 'unique') {
45
+ return [...new Set(lines)];
46
+ }
47
+
48
+ return [...lines].sort((left, right) => (left < right ? -1 : left > right ? 1 : 0));
49
+ }
50
+
51
+ /*
52
+ * Sorts, reverses or dedupes the lines the selections touch, or the whole text with nothing selected,
53
+ * each run of lines on its own. Every line break stays where it was, so a last line without one stays
54
+ * without one. Sorting compares by code unit, so capitals come first, and keeps equal lines in order;
55
+ * dedupe keeps the first of equal lines. A selection keeps its offsets, as far as the text still reaches.
56
+ */
57
+ export function planLineOrder(source: EditSource, selections: readonly Selection[], order: LineOrder): EditPlan | null {
58
+ const edits: TextEdit[] = [];
59
+ const runs: { from: number; to: number; length: number }[] = [];
60
+
61
+ for (const { first, last } of runsOf(source, selections)) {
62
+ if (last <= first) {
63
+ continue;
64
+ }
65
+
66
+ const lines: string[] = [];
67
+ const breaks: string[] = [];
68
+
69
+ for (let index = first; index <= last; index++) {
70
+ lines.push(source.line(index).text);
71
+
72
+ if (index < last) {
73
+ breaks.push(source.newline(index));
74
+ }
75
+ }
76
+
77
+ const result = reorder(lines, order);
78
+ const text = result.map((line, index) => line + (index < result.length - 1 ? breaks[index]! : '')).join('');
79
+ const from = source.line(first).start;
80
+ const to = source.line(last).end;
81
+
82
+ if (text !== source.slice(from, to)) {
83
+ edits.push({ from, to, text });
84
+ runs.push({ from, to, length: text.length });
85
+ }
86
+ }
87
+
88
+ if (edits.length === 0) {
89
+ return null;
90
+ }
91
+
92
+ const place = (offset: number): number => {
93
+ let shift = 0;
94
+
95
+ for (const run of runs) {
96
+ if (offset < run.from) {
97
+ break;
98
+ }
99
+
100
+ if (offset < run.to) {
101
+ return Math.min(offset, run.from + run.length) + shift;
102
+ }
103
+
104
+ shift += run.length - (run.to - run.from);
105
+ }
106
+
107
+ return offset + shift;
108
+ };
109
+
110
+ return { edits, selections: selections.map((selection) => ({ anchor: place(selection.anchor), head: place(selection.head) })) };
111
+ }
112
+
113
+ /*
114
+ * Rewrites the indentation of the lines the selections touch, or of every line with nothing selected:
115
+ * to spaces with each tab taken to its stop, or to as many tabs as fit and the spaces left over. Only the
116
+ * whitespace a line starts with changes.
117
+ */
118
+ export function planConvertIndents(source: EditSource, selections: readonly Selection[], tabSize: number, insertSpaces: boolean): TextEdit[] {
119
+ const edits: TextEdit[] = [];
120
+
121
+ for (const { first, last } of runsOf(source, selections)) {
122
+ for (let index = first; index <= last; index++) {
123
+ const line = source.line(index);
124
+ const current = whitespaceOf(line.text);
125
+ const target = indentText(indentationColumn(current, tabSize), { tabSize, insertSpaces });
126
+
127
+ if (current !== target) {
128
+ edits.push({ from: line.start, to: line.start + current.length, text: target });
129
+ }
130
+ }
131
+ }
132
+
133
+ return edits;
134
+ }
135
+
136
+ /* The indentation a text uses: spaces with the width of one level, or tabs, whose width a text cannot say. */
137
+ export interface DetectedIndentation {
138
+ insertSpaces: boolean;
139
+ tabSize?: number;
140
+ }
141
+
142
+ /* Past this many lines the rest says nothing new. */
143
+ const DETECT_LINES = 10_000;
144
+ const MAX_INDENT_SIZE = 8;
145
+
146
+ /*
147
+ * Reads the indentation a text already uses, to edit it the same way. Tabs win when more lines start with
148
+ * a tab than with spaces; otherwise the width of one level is the step between a line and a deeper one
149
+ * after it that occurs most often. On a tie the step more indents are a multiple of wins, so a line
150
+ * aligned under a parenthesis does not pass for a level, and then the smaller. The lines inside a block
151
+ * comment that start with a star are left out, since their single space is no level. Null when no line says.
152
+ */
153
+ export function detectIndentation(text: string): DetectedIndentation | null {
154
+ const steps = new Map<number, number>();
155
+ const widths: number[] = [];
156
+ let tabs = 0;
157
+ let spaces = 0;
158
+ let previous = 0;
159
+ let count = 0;
160
+
161
+ for (const line of text.split(/\r\n|\r|\n/)) {
162
+ if (++count > DETECT_LINES) {
163
+ break;
164
+ }
165
+
166
+ const indent = whitespaceOf(line);
167
+ const content = line.slice(indent.length);
168
+
169
+ if (content === '' || content.startsWith('*')) {
170
+ continue;
171
+ }
172
+
173
+ if (indent.startsWith('\t')) {
174
+ tabs++;
175
+ previous = -1;
176
+
177
+ continue;
178
+ }
179
+
180
+ if (indent.includes('\t')) {
181
+ previous = -1;
182
+
183
+ continue;
184
+ }
185
+
186
+ if (indent.length > 0) {
187
+ spaces++;
188
+ widths.push(indent.length);
189
+ }
190
+
191
+ const step = indent.length - previous;
192
+
193
+ if (previous >= 0 && step > 0 && step <= MAX_INDENT_SIZE) {
194
+ steps.set(step, (steps.get(step) ?? 0) + 1);
195
+ }
196
+
197
+ previous = indent.length;
198
+ }
199
+
200
+ if (tabs > spaces) {
201
+ return { insertSpaces: false };
202
+ }
203
+
204
+ let best: { size: number; uses: number; fits: number } | null = null;
205
+
206
+ for (const [size, uses] of steps) {
207
+ const fits = widths.filter((width) => width % size === 0).length;
208
+
209
+ if (best === null || uses > best.uses || (uses === best.uses && (fits > best.fits || (fits === best.fits && size < best.size)))) {
210
+ best = { size, uses, fits };
211
+ }
212
+ }
213
+
214
+ return best === null ? null : { insertSpaces: true, tabSize: best.size };
215
+ }
package/src/todo.ts ADDED
@@ -0,0 +1,118 @@
1
+ import type { DocumentLine } from './rope.ts';
2
+ import { type TypingContext, TypingContexts } from './typing-context.ts';
3
+
4
+ /* The words a TODO comment starts with when a host names none. */
5
+ export const DEFAULT_TODO_WORDS: readonly string[] = ['TODO', 'FIXME'];
6
+
7
+ /* A TODO in a comment: from its word to the end of the comment on that line. */
8
+ export interface TodoMatch {
9
+ from: number;
10
+ to: number;
11
+ /* Zero-based. */
12
+ line: number;
13
+ /* The word of the list it matched, as the list writes it. */
14
+ word: string;
15
+ }
16
+
17
+ export interface TodoOptions {
18
+ /* Matched as whole words in any case. `DEFAULT_TODO_WORDS` without them; an empty list finds nothing. */
19
+ words?: readonly string[];
20
+ }
21
+
22
+ /* What finding the TODOs of a document reads of it. */
23
+ export interface TodoSource {
24
+ lineCount: number;
25
+ line(index: number): DocumentLine;
26
+ context(line: number, column: number): TypingContext;
27
+ }
28
+
29
+ const CLOSERS: Partial<Record<TypingContext['mode'], string>> = { 'block-comment': '*/', 'html-comment': '-->' };
30
+
31
+ function escape(word: string): string {
32
+ return word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
33
+ }
34
+
35
+ /* Null when there is nothing to look for. */
36
+ export function todoPattern(words: readonly string[] = DEFAULT_TODO_WORDS): RegExp | null {
37
+ const usable = words.map((word) => word.trim()).filter((word) => word !== '');
38
+
39
+ if (usable.length === 0) {
40
+ return null;
41
+ }
42
+
43
+ // Longest first, so `TODO` does not stop a list that also has `TODO_LATER` short.
44
+ const alternatives = [...usable].sort((left, right) => right.length - left.length).map(escape);
45
+
46
+ return new RegExp(`(?<![\\p{L}\\p{N}_])(?:${alternatives.join('|')})(?![\\p{L}\\p{N}_])`, 'giu');
47
+ }
48
+
49
+ /*
50
+ * The TODOs on one line. A word counts only where the lexer says a comment is, so one in a string or in
51
+ * code is no TODO, and a second word in the same comment is part of the first one's TODO.
52
+ */
53
+ export function todosOnLine(source: TodoSource, index: number, pattern: RegExp, words: readonly string[]): TodoMatch[] {
54
+ const line = source.line(index);
55
+ const result: TodoMatch[] = [];
56
+ pattern.lastIndex = 0;
57
+
58
+ for (let found = pattern.exec(line.text); found !== null; found = pattern.exec(line.text)) {
59
+ const context = source.context(index, found.index);
60
+ const closer = CLOSERS[context.mode];
61
+
62
+ if (context.mode !== 'line-comment' && closer === undefined) {
63
+ continue;
64
+ }
65
+
66
+ const close = closer === undefined ? -1 : line.text.indexOf(closer, found.index + found[0].length);
67
+ const end = close === -1 ? line.text.length : close;
68
+ const word = words.find((candidate) => candidate.trim().toLowerCase() === found[0].toLowerCase()) ?? found[0];
69
+ result.push({ from: line.start + found.index, to: line.start + end, line: index, word: word.trim() });
70
+ pattern.lastIndex = Math.max(pattern.lastIndex, end);
71
+ }
72
+
73
+ return result;
74
+ }
75
+
76
+ /* Every TODO of the lines from `first` through `last`, in order. */
77
+ export function todosInLines(source: TodoSource, options: TodoOptions = {}, first = 0, last = source.lineCount - 1): TodoMatch[] {
78
+ const words = options.words ?? DEFAULT_TODO_WORDS;
79
+ const pattern = todoPattern(words);
80
+
81
+ if (pattern === null) {
82
+ return [];
83
+ }
84
+
85
+ const result: TodoMatch[] = [];
86
+
87
+ for (let index = Math.max(0, first); index <= Math.min(last, source.lineCount - 1); index++) {
88
+ result.push(...todosOnLine(source, index, pattern, words));
89
+ }
90
+
91
+ return result;
92
+ }
93
+
94
+ /*
95
+ * The TODO comments of a text, for a host that lists them. The language decides what a comment is, as
96
+ * it does for the editor's own commands, and a language without comments has none.
97
+ */
98
+ export function findTodos(text: string, language: string, options: TodoOptions = {}): TodoMatch[] {
99
+ const lines = splitLines(text);
100
+ const contexts = new TypingContexts((index) => lines[index]!);
101
+
102
+ return todosInLines({ lineCount: lines.length, line: (index) => lines[index]!, context: (line, column) => contexts.at(line, column, language) }, options);
103
+ }
104
+
105
+ function splitLines(text: string): DocumentLine[] {
106
+ const lines: DocumentLine[] = [];
107
+ const breaks = /\r\n|\r|\n/g;
108
+ let start = 0;
109
+
110
+ for (let found = breaks.exec(text); found !== null; found = breaks.exec(text)) {
111
+ lines.push({ start, end: found.index, next: found.index + found[0].length, text: text.slice(start, found.index) });
112
+ start = found.index + found[0].length;
113
+ }
114
+
115
+ lines.push({ start, end: text.length, next: text.length, text: text.slice(start) });
116
+
117
+ return lines;
118
+ }
package/src/types.ts CHANGED
@@ -110,7 +110,12 @@ export type EditorCommand =
110
110
  | 'selectNextOccurrence'
111
111
  | 'unselectOccurrence'
112
112
  | 'selectAllOccurrences'
113
- | 'addCaretPerSelectedLine';
113
+ | 'addCaretPerSelectedLine'
114
+ | 'sortLines'
115
+ | 'reverseLines'
116
+ | 'uniqueLines'
117
+ | 'convertIndentsToSpaces'
118
+ | 'convertIndentsToTabs';
114
119
 
115
120
  export interface CommandOptions {
116
121
  /* Clamped to 1 through 16. */
@@ -0,0 +1,162 @@
1
+ import { isBlank } from './line-text.ts';
2
+ import type { DocumentLine } from './rope.ts';
3
+ import type { DocumentChange, TextEdit } from './types.ts';
4
+
5
+ /* Which lines lose the spaces and tabs at their end. */
6
+ export type TrailingWhitespace = 'off' | 'modified' | 'all';
7
+
8
+ export interface WhitespaceCleanupOptions {
9
+ /* Off unless set. `modified` reads the lines changed since the document was last marked as saved. */
10
+ trailingWhitespace?: TrailingWhitespace;
11
+ /* The lines a caret is on keep their trailing whitespace, so a person typing there does not lose it. On unless false. */
12
+ keepCaretLines?: boolean;
13
+ /* The blank lines at the end go, up to the last line with text and its line break. Off unless true. */
14
+ removeTrailingBlankLines?: boolean;
15
+ /* A text that does not end in a line break gets one, in the style of its first. Off unless true. */
16
+ ensureFinalNewline?: boolean;
17
+ }
18
+
19
+ /* What the cleanup reads of a document. */
20
+ export interface CleanupSource {
21
+ lineCount: number;
22
+ line(index: number): DocumentLine;
23
+ newline(index: number): string;
24
+ /* Whether trailing whitespace starting at this column belongs to the text itself, such as inside a multiline string. */
25
+ keeps?(line: number, column: number): boolean;
26
+ }
27
+
28
+ export interface CleanupLines {
29
+ modified(line: number): boolean;
30
+ carets: ReadonlySet<number>;
31
+ }
32
+
33
+ /* The edits that clean up the whitespace of a text before it is saved, all in the coordinates of the text as it is. */
34
+ export function planWhitespaceCleanup(source: CleanupSource, options: WhitespaceCleanupOptions, lines: CleanupLines): TextEdit[] {
35
+ const edits: TextEdit[] = [];
36
+ let lastText = source.lineCount - 1;
37
+
38
+ if (options.removeTrailingBlankLines === true) {
39
+ while (lastText >= 0 && isBlank(source.line(lastText).text)) {
40
+ lastText--;
41
+ }
42
+ }
43
+
44
+ const mode = options.trailingWhitespace ?? 'off';
45
+
46
+ for (let index = 0; mode !== 'off' && index <= lastText; index++) {
47
+ const line = source.line(index);
48
+ const trailing = /[\t ]+$/.exec(line.text);
49
+
50
+ if (trailing === null) {
51
+ continue;
52
+ }
53
+
54
+ const kept = trailing.index;
55
+
56
+ if (
57
+ (mode === 'modified' && !lines.modified(index)) ||
58
+ (options.keepCaretLines !== false && lines.carets.has(index)) ||
59
+ source.keeps?.(index, kept) === true
60
+ ) {
61
+ continue;
62
+ }
63
+
64
+ edits.push({ from: line.start + kept, to: line.end, text: '' });
65
+ }
66
+
67
+ const last = source.line(source.lineCount - 1);
68
+
69
+ if (lastText < source.lineCount - 1) {
70
+ edits.push({ from: lastText < 0 ? 0 : source.line(lastText + 1).start, to: last.end, text: '' });
71
+ }
72
+
73
+ // A last line that is empty, or that the cleanup emptied, already stands after a line break.
74
+ const end = source.line(source.lineCount - 1);
75
+ const emptied = edits.some((edit) => edit.from === end.start && edit.to === end.end);
76
+
77
+ if (options.ensureFinalNewline === true && lastText === source.lineCount - 1 && end.text !== '' && !emptied) {
78
+ edits.push({ from: end.end, to: end.end, text: source.lineCount > 1 ? source.newline(0) : '\n' });
79
+ }
80
+
81
+ return edits;
82
+ }
83
+
84
+ /*
85
+ * The stretches of a text that changed since it was last marked as saved, followed through every
86
+ * change after it, undo included. A deletion leaves an empty stretch, which still marks its line.
87
+ */
88
+ export class ModifiedStretches {
89
+ private stretches: { from: number; to: number }[] = [];
90
+
91
+ clear(): void {
92
+ this.stretches = [];
93
+ }
94
+
95
+ /* One transaction, its changes in the coordinates of the text before it. */
96
+ record(changes: readonly DocumentChange[]): void {
97
+ const ordered = [...changes].sort((left, right) => left.from - right.from);
98
+ let index = 0;
99
+ let shift = 0;
100
+
101
+ // The stretches are in order and apart, so one walk over the changes maps all their offsets.
102
+ const map = (offset: number, end: boolean): number => {
103
+ for (; index < ordered.length; index++) {
104
+ const change = ordered[index]!;
105
+
106
+ if (offset < change.from || (offset === change.from && !end)) {
107
+ break;
108
+ }
109
+
110
+ if (offset < change.to) {
111
+ return change.from + shift + (end ? change.insertedLength : 0);
112
+ }
113
+
114
+ shift += change.insertedLength - (change.to - change.from);
115
+ }
116
+
117
+ return offset + shift;
118
+ };
119
+
120
+ const next = this.stretches.map((stretch) => ({ from: map(stretch.from, false), to: map(stretch.to, true) }));
121
+ shift = 0;
122
+
123
+ for (const change of ordered) {
124
+ const from = change.from + shift;
125
+ next.push({ from, to: from + change.insertedLength });
126
+ shift += change.insertedLength - (change.to - change.from);
127
+ }
128
+
129
+ next.sort((left, right) => left.from - right.from);
130
+ this.stretches = [];
131
+
132
+ for (const stretch of next) {
133
+ const previous = this.stretches.at(-1);
134
+
135
+ if (previous !== undefined && stretch.from <= previous.to) {
136
+ previous.to = Math.max(previous.to, stretch.to);
137
+ } else {
138
+ this.stretches.push({ ...stretch });
139
+ }
140
+ }
141
+ }
142
+
143
+ /* The zero-based lines the stretches touch, in order. A stretch that ends at the start of a line, such as an inserted line, does not touch it. */
144
+ lines(lineAt: (offset: number) => number, lineStart: (line: number) => number): number[] {
145
+ const result: number[] = [];
146
+
147
+ for (const { from, to } of this.stretches) {
148
+ const first = lineAt(from);
149
+ let last = lineAt(to);
150
+
151
+ if (last > first && lineStart(last) === to) {
152
+ last--;
153
+ }
154
+
155
+ for (let line = Math.max(first, (result.at(-1) ?? -1) + 1); line <= last; line++) {
156
+ result.push(line);
157
+ }
158
+ }
159
+
160
+ return result;
161
+ }
162
+ }