@adecore/editor-core 0.41.1 → 0.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/code-blocks.d.ts +8 -0
- package/dist/code-blocks.js +59 -0
- package/dist/complete-statement.d.ts +8 -0
- package/dist/complete-statement.js +157 -0
- package/dist/document.d.ts +16 -0
- package/dist/document.js +122 -0
- package/dist/hippie.d.ts +34 -0
- package/dist/hippie.js +183 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/lexical.d.ts +1 -0
- package/dist/lexical.js +6 -0
- package/dist/line-order.d.ts +10 -0
- package/dist/line-order.js +156 -0
- package/dist/statement-move.d.ts +21 -0
- package/dist/statement-move.js +361 -0
- package/dist/statements.d.ts +23 -0
- package/dist/statements.js +221 -0
- package/dist/todo.d.ts +21 -0
- package/dist/todo.js +72 -0
- package/dist/types.d.ts +5 -1
- package/dist/whitespace-cleanup.d.ts +26 -0
- package/dist/whitespace-cleanup.js +100 -0
- package/package.json +1 -1
- package/src/code-blocks.ts +93 -0
- package/src/complete-statement.ts +231 -0
- package/src/document.ts +161 -0
- package/src/hippie.ts +290 -0
- package/src/index.ts +3 -0
- package/src/lexical.ts +9 -0
- package/src/line-order.ts +215 -0
- package/src/statement-move.ts +533 -0
- package/src/statements.ts +307 -0
- package/src/todo.ts +118 -0
- package/src/types.ts +23 -1
- package/src/whitespace-cleanup.ts +162 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import type { EditSource } from './edit-source.ts';
|
|
2
|
+
import { lineContinues } from './enter.ts';
|
|
3
|
+
import type { FoldingRange } from './structure.ts';
|
|
4
|
+
import { isBlank, whitespaceOf } from './line-text.ts';
|
|
5
|
+
|
|
6
|
+
/* The indentation of a line text in columns, tabs taken to their stops. */
|
|
7
|
+
export function indentWidth(text: string, tabSize: number): number {
|
|
8
|
+
return visualWidth(whitespaceOf(text), tabSize);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/* The width in columns of a text that starts a line, tabs taken to their stops. */
|
|
12
|
+
export function visualWidth(text: string, tabSize: number): number {
|
|
13
|
+
let width = 0;
|
|
14
|
+
|
|
15
|
+
for (const character of text) {
|
|
16
|
+
width += character === '\t' ? tabSize - (width % tabSize) : 1;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return width;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/* The `{` brackets the offset is inside, innermost first. None in a string, a comment or markup, and an `${` of a template is no block. */
|
|
23
|
+
export function enclosingBraces(source: EditSource, offset: number): number[] {
|
|
24
|
+
const context = source.context(offset);
|
|
25
|
+
|
|
26
|
+
if (context.mode !== 'code') {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const braces: number[] = [];
|
|
31
|
+
|
|
32
|
+
for (let bracket = context.bracket; bracket !== undefined; bracket = bracket.previous) {
|
|
33
|
+
if (bracket.close === '}' && bracket.template !== true) {
|
|
34
|
+
braces.push(bracket.at);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return braces;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/* The innermost `{` around the offset, or null. */
|
|
42
|
+
export function enclosingBrace(source: EditSource, offset: number): number | null {
|
|
43
|
+
return enclosingBraces(source, offset)[0] ?? null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface IndentGuide {
|
|
47
|
+
/* The line the guide starts on: the header, at the guide's own indent. */
|
|
48
|
+
startLine: number;
|
|
49
|
+
/* The first later line at or above the guide's indent, or the line count when the text ends first. */
|
|
50
|
+
endLine: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function nextNonBlank(source: EditSource, line: number): number {
|
|
54
|
+
let next = line + 1;
|
|
55
|
+
|
|
56
|
+
while (next < source.lineCount && isBlank(source.line(next).text)) {
|
|
57
|
+
next++;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return next;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/*
|
|
64
|
+
* The guide an editor draws at `column` (in columns, tabs expanded) that holds `line`: a line at that indent
|
|
65
|
+
* whose next non-blank line is deeper starts it, and the first later non-blank line at or above that indent ends it.
|
|
66
|
+
*/
|
|
67
|
+
export function indentGuideAt(source: EditSource, line: number, column: number, tabSize: number): IndentGuide | null {
|
|
68
|
+
if (column <= 0 || line < 0 || line >= source.lineCount) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (let start = line; start >= 0; start--) {
|
|
73
|
+
const text = source.line(start).text;
|
|
74
|
+
|
|
75
|
+
if (isBlank(text)) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const indent = indentWidth(text, tabSize);
|
|
80
|
+
|
|
81
|
+
if (indent > column) {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const following = nextNonBlank(source, start);
|
|
86
|
+
|
|
87
|
+
if (indent < column || following >= source.lineCount || indentWidth(source.line(following).text, tabSize) <= column) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let end = following;
|
|
92
|
+
|
|
93
|
+
while (end < source.lineCount && (isBlank(source.line(end).text) || indentWidth(source.line(end).text, tabSize) > column)) {
|
|
94
|
+
end++;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return end > line ? { startLine: start, endLine: end } : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const CONTINUATION_START = /^(?:\{|\.|\?\.|&&|\|\||\?\?|\+(?!\+)|-(?!-)|\?|:|else\b|catch\b|finally\b)/;
|
|
104
|
+
|
|
105
|
+
/*
|
|
106
|
+
* The first line of the statement a line belongs to: a line that opens with a closer belongs to the statement of its
|
|
107
|
+
* opener, and a line the one above leaves open, or that goes on with a member access, an operator or `else`, to the one above.
|
|
108
|
+
*/
|
|
109
|
+
export function statementStart(source: EditSource, line: number, language: string): number {
|
|
110
|
+
let first = line;
|
|
111
|
+
|
|
112
|
+
// Each step goes up at least one line, so the walk ends.
|
|
113
|
+
while (first > 0) {
|
|
114
|
+
const info = source.line(first);
|
|
115
|
+
const lead = info.text.length - info.text.trimStart().length;
|
|
116
|
+
const opener = /^[)\]}]/.test(info.text.slice(lead)) ? source.context(info.start + lead).bracket : undefined;
|
|
117
|
+
const openerLine = opener === undefined ? first : source.lineAt(opener.at);
|
|
118
|
+
|
|
119
|
+
if (openerLine < first) {
|
|
120
|
+
first = openerLine;
|
|
121
|
+
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const above = first - 1;
|
|
126
|
+
|
|
127
|
+
if (isBlank(source.line(above).text) || !(CONTINUATION_START.test(info.text.trimStart()) || lineContinues(source, language, above))) {
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
first = above;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return first;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const MODIFIERS =
|
|
138
|
+
'(?:export\\s+|default\\s+|declare\\s+|abstract\\s+|async\\s+|pub(?:\\([^)]*\\))?\\s+|public\\s+|private\\s+|protected\\s+|static\\s+|final\\s+|readonly\\s+|override\\s+|unsafe\\s+|extern\\s+)*';
|
|
139
|
+
|
|
140
|
+
// The outline of the editor reads headers the same way; a statement is not a declaration when it ends in a value, so `type` and `namespace` are left out.
|
|
141
|
+
export const DECLARATION = new RegExp(`^${MODIFIERS}(function\\*?|class|interface|enum|trait|impl|struct|fn|func|object|record)\\b`);
|
|
142
|
+
export const METHOD = /^(?:(?:public|private|protected|static|async|get|set|override|readonly|abstract)\s+)*\*?\s*([A-Za-z_$#][\w$]*)\s*[(<]/;
|
|
143
|
+
export const NOT_METHODS = new Set([
|
|
144
|
+
'if',
|
|
145
|
+
'for',
|
|
146
|
+
'while',
|
|
147
|
+
'switch',
|
|
148
|
+
'catch',
|
|
149
|
+
'function',
|
|
150
|
+
'return',
|
|
151
|
+
'else',
|
|
152
|
+
'with',
|
|
153
|
+
'do',
|
|
154
|
+
'try',
|
|
155
|
+
'new',
|
|
156
|
+
'await',
|
|
157
|
+
'typeof',
|
|
158
|
+
'super',
|
|
159
|
+
'finally'
|
|
160
|
+
]);
|
|
161
|
+
export const BODIES = new Set(['class', 'interface', 'enum', 'trait', 'impl', 'struct', 'object', 'record']);
|
|
162
|
+
/* What kind of body the offset is in: that of an interface, of another type or namespace, or none of them. */
|
|
163
|
+
export function declarationBody(source: EditSource, offset: number): 'interface' | 'other' | null {
|
|
164
|
+
const brace = enclosingBrace(source, offset);
|
|
165
|
+
|
|
166
|
+
if (brace === null) {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const braceLine = source.lineAt(brace);
|
|
171
|
+
const own = source.line(braceLine).text.slice(0, brace - source.line(braceLine).start);
|
|
172
|
+
// A brace on a line of its own belongs to the header above it.
|
|
173
|
+
const header = own.trim() === '' && braceLine > 0 ? source.line(braceLine - 1).text : own;
|
|
174
|
+
|
|
175
|
+
if (/\binterface\b/.test(header)) {
|
|
176
|
+
return 'interface';
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return /\b(?:class|enum|trait|impl|struct|object|record|namespace)\b/.test(header) ? 'other' : null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export interface StatementRange {
|
|
183
|
+
/* The first line, counting the doc comment and the attributes above the code. */
|
|
184
|
+
first: number;
|
|
185
|
+
/* The first line of the code itself. */
|
|
186
|
+
code: number;
|
|
187
|
+
last: number;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/* The doc comment a line is part of, as the lines it spans. */
|
|
191
|
+
function docCommentAt(source: EditSource, line: number): { first: number; last: number } | null {
|
|
192
|
+
let first = line;
|
|
193
|
+
|
|
194
|
+
while (first >= 0 && !source.line(first).text.trim().startsWith('/**') && source.line(first).text.trim().startsWith('*')) {
|
|
195
|
+
first--;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (first < 0 || !source.line(first).text.trim().startsWith('/**')) {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
let last = first;
|
|
203
|
+
|
|
204
|
+
while (last < source.lineCount - 1 && !source.line(last).text.includes('*/')) {
|
|
205
|
+
last++;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return last >= line ? { first, last } : null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function isAttribute(text: string): boolean {
|
|
212
|
+
return /^\s*(?:@|#\[)/.test(text);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const NO_STRUCTURE: readonly FoldingRange[] = [];
|
|
216
|
+
|
|
217
|
+
/* The offset of the bracket that is still open at the start of a line and is no block of statements. */
|
|
218
|
+
function enclosingOpener(source: EditSource, line: number): number | null {
|
|
219
|
+
const open = source.context(source.line(line).start).bracket;
|
|
220
|
+
|
|
221
|
+
return open !== undefined && open.template !== true && !(open.close === '}' && open.block === true) ? open.at : null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function chainLength(source: EditSource, offset: number): number {
|
|
225
|
+
let length = 0;
|
|
226
|
+
|
|
227
|
+
for (let bracket = source.context(offset).bracket; bracket !== undefined; bracket = bracket.previous) {
|
|
228
|
+
length++;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return length;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/* The last line of the statement that starts on `from`: where the brackets it opened are closed again and nothing goes on. */
|
|
235
|
+
function statementLast(source: EditSource, from: number, language: string): number {
|
|
236
|
+
const depth = chainLength(source, source.line(from).start);
|
|
237
|
+
const isDo = /^\s*do\b/.test(source.line(from).text);
|
|
238
|
+
|
|
239
|
+
for (let line = from; line < source.lineCount; line++) {
|
|
240
|
+
const info = source.line(line);
|
|
241
|
+
|
|
242
|
+
if (chainLength(source, info.end) > depth || lineContinues(source, language, line)) {
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const next = line + 1 < source.lineCount ? source.line(line + 1).text.trimStart() : '';
|
|
247
|
+
|
|
248
|
+
if (next !== '' && (CONTINUATION_START.test(next) || (isDo && /^while\b/.test(next))) && !isBlank(info.text)) {
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return line;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return source.lineCount - 1;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/*
|
|
259
|
+
* The lines of the statement a line belongs to. A doc comment and the attributes right above it are part of it, a line
|
|
260
|
+
* in them stands for the statement below, and a range a server or a bracket pair folds from its first line sets its end.
|
|
261
|
+
*/
|
|
262
|
+
export function statementAt(source: EditSource, line: number, language: string, structure: readonly FoldingRange[] = NO_STRUCTURE): StatementRange {
|
|
263
|
+
let code = line;
|
|
264
|
+
const doc = docCommentAt(source, code);
|
|
265
|
+
|
|
266
|
+
if (doc !== null) {
|
|
267
|
+
code = doc.last + 1;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
while (code < source.lineCount - 1 && isAttribute(source.line(code).text)) {
|
|
271
|
+
code++;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (code >= source.lineCount || isBlank(source.line(code).text)) {
|
|
275
|
+
return { first: line, code: line, last: line };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// A line inside brackets a line above opened belongs to the statement of that line.
|
|
279
|
+
for (let open = enclosingOpener(source, code); open !== null; open = enclosingOpener(source, code)) {
|
|
280
|
+
code = statementStart(source, source.lineAt(open), language);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
code = statementStart(source, code, language);
|
|
284
|
+
let first = code;
|
|
285
|
+
|
|
286
|
+
for (let above = first - 1; above >= 0; above = first - 1) {
|
|
287
|
+
const comment = source.line(above).text.trim().endsWith('*/') ? docCommentAt(source, above) : null;
|
|
288
|
+
|
|
289
|
+
if (comment !== null) {
|
|
290
|
+
first = comment.first;
|
|
291
|
+
} else if (isAttribute(source.line(above).text)) {
|
|
292
|
+
first = above;
|
|
293
|
+
} else {
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
let last = statementLast(source, code, language);
|
|
299
|
+
|
|
300
|
+
for (const range of structure) {
|
|
301
|
+
if (range.startLine === code && range.endLine > last) {
|
|
302
|
+
last = range.endLine;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return { first, code, last };
|
|
307
|
+
}
|
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
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { FoldingRange } from './structure.ts';
|
|
2
|
+
|
|
1
3
|
/* Offsets are UTF-16 code units from the start of the document. */
|
|
2
4
|
export interface Selection {
|
|
3
5
|
anchor: number;
|
|
@@ -110,7 +112,21 @@ export type EditorCommand =
|
|
|
110
112
|
| 'selectNextOccurrence'
|
|
111
113
|
| 'unselectOccurrence'
|
|
112
114
|
| 'selectAllOccurrences'
|
|
113
|
-
| 'addCaretPerSelectedLine'
|
|
115
|
+
| 'addCaretPerSelectedLine'
|
|
116
|
+
| 'sortLines'
|
|
117
|
+
| 'reverseLines'
|
|
118
|
+
| 'uniqueLines'
|
|
119
|
+
| 'convertIndentsToSpaces'
|
|
120
|
+
| 'convertIndentsToTabs'
|
|
121
|
+
| 'codeBlockStart'
|
|
122
|
+
| 'codeBlockEnd'
|
|
123
|
+
| 'codeBlockStartWithSelection'
|
|
124
|
+
| 'codeBlockEndWithSelection'
|
|
125
|
+
| 'completeCurrentStatement'
|
|
126
|
+
| 'moveStatementUp'
|
|
127
|
+
| 'moveStatementDown'
|
|
128
|
+
| 'hippieCompletion'
|
|
129
|
+
| 'hippieBackwardCompletion';
|
|
114
130
|
|
|
115
131
|
export interface CommandOptions {
|
|
116
132
|
/* Clamped to 1 through 16. */
|
|
@@ -146,4 +162,10 @@ export interface CommandOptions {
|
|
|
146
162
|
lineSpan?: (line: number) => { first: number; last: number };
|
|
147
163
|
/* Told which lines a move went to (`from` before, `to` after), so a host can take what it keeps by line along. */
|
|
148
164
|
onLinesMoved?: (moves: readonly { from: number; to: number }[]) => void;
|
|
165
|
+
/* The folding ranges of the document, a language server's included, which Move Statement reads the extent of a statement from. Without it the model scans its own. */
|
|
166
|
+
structure?: () => readonly FoldingRange[];
|
|
167
|
+
/* Whether the indent guides count as blocks for the code block commands. Off unless true. */
|
|
168
|
+
indentGuides?: boolean;
|
|
169
|
+
/* The texts of the other open documents, which Hippie completion takes words from after those of this one. */
|
|
170
|
+
wordSources?: () => readonly string[];
|
|
149
171
|
}
|
|
@@ -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
|
+
}
|