@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.
- package/dist/document.d.ts +9 -0
- package/dist/document.js +54 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/line-order.d.ts +10 -0
- package/dist/line-order.js +156 -0
- package/dist/todo.d.ts +21 -0
- package/dist/todo.js +72 -0
- package/dist/types.d.ts +1 -1
- package/dist/whitespace-cleanup.d.ts +26 -0
- package/dist/whitespace-cleanup.js +100 -0
- package/package.json +1 -1
- package/src/document.ts +72 -0
- package/src/index.ts +3 -0
- package/src/line-order.ts +215 -0
- package/src/todo.ts +118 -0
- package/src/types.ts +6 -1
- package/src/whitespace-cleanup.ts +162 -0
package/dist/document.d.ts
CHANGED
|
@@ -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
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
|
+
}
|