@adecore/editor-core 0.0.1 → 0.17.0-beta.1

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.
Files changed (80) hide show
  1. package/LICENSE +110 -0
  2. package/LICENSE.apache-2.0.txt +201 -0
  3. package/NOTICE +11 -0
  4. package/README.md +54 -0
  5. package/dist/block-folds.d.ts +3 -0
  6. package/dist/block-folds.js +112 -0
  7. package/dist/brackets.d.ts +5 -0
  8. package/dist/brackets.js +136 -0
  9. package/dist/closer-tracker.d.ts +9 -0
  10. package/dist/closer-tracker.js +46 -0
  11. package/dist/comments.d.ts +14 -0
  12. package/dist/comments.js +344 -0
  13. package/dist/document.d.ts +148 -0
  14. package/dist/document.js +1729 -0
  15. package/dist/edit-source.d.ts +19 -0
  16. package/dist/edit-source.js +1 -0
  17. package/dist/enter.d.ts +17 -0
  18. package/dist/enter.js +387 -0
  19. package/dist/fold-roles.d.ts +6 -0
  20. package/dist/fold-roles.js +142 -0
  21. package/dist/index.d.ts +10 -0
  22. package/dist/index.js +7 -0
  23. package/dist/languages.d.ts +12 -0
  24. package/dist/languages.js +51 -0
  25. package/dist/lexical-folds.d.ts +10 -0
  26. package/dist/lexical-folds.js +116 -0
  27. package/dist/lexical.d.ts +16 -0
  28. package/dist/lexical.js +74 -0
  29. package/dist/line-commands.d.ts +9 -0
  30. package/dist/line-commands.js +306 -0
  31. package/dist/markup-regions.d.ts +8 -0
  32. package/dist/markup-regions.js +331 -0
  33. package/dist/offsets.d.ts +2 -0
  34. package/dist/offsets.js +14 -0
  35. package/dist/paste.d.ts +9 -0
  36. package/dist/paste.js +108 -0
  37. package/dist/preserve-case.d.ts +1 -0
  38. package/dist/preserve-case.js +61 -0
  39. package/dist/rope.d.ts +22 -0
  40. package/dist/rope.js +246 -0
  41. package/dist/search.d.ts +26 -0
  42. package/dist/search.js +85 -0
  43. package/dist/structure.d.ts +50 -0
  44. package/dist/structure.js +124 -0
  45. package/dist/text-span.d.ts +7 -0
  46. package/dist/text-span.js +211 -0
  47. package/dist/types.d.ts +70 -0
  48. package/dist/types.js +1 -0
  49. package/dist/typing-context.d.ts +30 -0
  50. package/dist/typing-context.js +270 -0
  51. package/dist/typing-handlers.d.ts +15 -0
  52. package/dist/typing-handlers.js +125 -0
  53. package/dist/words.d.ts +7 -0
  54. package/dist/words.js +72 -0
  55. package/package.json +34 -3
  56. package/src/block-folds.ts +117 -0
  57. package/src/brackets.ts +159 -0
  58. package/src/closer-tracker.ts +60 -0
  59. package/src/comments.ts +411 -0
  60. package/src/document.ts +2021 -0
  61. package/src/edit-source.ts +25 -0
  62. package/src/enter.ts +452 -0
  63. package/src/fold-roles.ts +166 -0
  64. package/src/index.ts +22 -0
  65. package/src/languages.ts +77 -0
  66. package/src/lexical-folds.ts +138 -0
  67. package/src/lexical.ts +113 -0
  68. package/src/line-commands.ts +356 -0
  69. package/src/markup-regions.ts +333 -0
  70. package/src/offsets.ts +16 -0
  71. package/src/paste.ts +126 -0
  72. package/src/preserve-case.ts +62 -0
  73. package/src/rope.ts +278 -0
  74. package/src/search.ts +119 -0
  75. package/src/structure.ts +223 -0
  76. package/src/text-span.ts +230 -0
  77. package/src/types.ts +141 -0
  78. package/src/typing-context.ts +329 -0
  79. package/src/typing-handlers.ts +143 -0
  80. package/src/words.ts +83 -0
@@ -0,0 +1,25 @@
1
+ import type { VueRegion } from './languages.ts';
2
+ import type { DocumentLine } from './rope.ts';
3
+ import type { TypingContext } from './typing-context.ts';
4
+ import type { Selection, TextEdit } from './types.ts';
5
+
6
+ /* What the editing features that go by the lines around a caret read from the document. */
7
+ export interface EditSource {
8
+ slice(from: number, to: number): string;
9
+ charAt(offset: number): string;
10
+ lineAt(offset: number): number;
11
+ lineCount: number;
12
+ line(index: number): DocumentLine;
13
+ /* The line break of a line. */
14
+ newline(index: number): string;
15
+ context(offset: number): TypingContext;
16
+ region(line: number): VueRegion | null;
17
+ /* Whether a `{` at this offset has no closer anywhere in the document. */
18
+ unmatchedBrace(offset: number): boolean;
19
+ }
20
+
21
+ /* Edits to apply together and the selections that stand after them. */
22
+ export interface EditPlan {
23
+ edits: TextEdit[];
24
+ selections: Selection[];
25
+ }
package/src/enter.ts ADDED
@@ -0,0 +1,452 @@
1
+ import type { EditSource } from './edit-source.ts';
2
+ import { commentSyntax } from './languages.ts';
3
+ import { hasHashComments, isBraced, isPhp } from './lexical.ts';
4
+ import { hasJsx, isJsxAttributeValue } from './markup-regions.ts';
5
+ import type { DocumentLine } from './rope.ts';
6
+ import { indentationColumn } from './structure.ts';
7
+ import type { TypingContext } from './typing-context.ts';
8
+
9
+ export interface EnterOptions {
10
+ language: string;
11
+ /* One level of indentation. */
12
+ unit: string;
13
+ tabSize: number;
14
+ /* Plain Enter: a new line at the indentation of this one. */
15
+ smart: boolean;
16
+ /* The plan may touch text away from the caret. Off when several carets could meet. */
17
+ reach: boolean;
18
+ }
19
+
20
+ /* The text that replaces `[from, to)` and where the caret lands in it. */
21
+ export interface EnterPlan {
22
+ from: number;
23
+ to: number;
24
+ text: string;
25
+ caret: number;
26
+ }
27
+
28
+ const closers: Readonly<Record<string, string>> = { '(': ')', '[': ']', '{': '}' };
29
+ const concatenation = new Map<string, string>();
30
+ for (const id of 'typescript javascript typescriptreact javascriptreact tsx jsx ts js java csharp cs kotlin scala dart swift go groovy'.split(' ')) {
31
+ concatenation.set(id, '+');
32
+ }
33
+ concatenation.set('php', '.');
34
+
35
+ const markupLanguages = /^(html|xml|svg|vue|svelte|astro|mdx|jsx|tsx|javascriptreact|typescriptreact)$/i;
36
+ const voidElements = /^(area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)$/i;
37
+ const trailingOperator = /(?:=>|&&|\|\||\?\?|[=*%?|&]|(?<!\+)\+|(?<!-)-)$/;
38
+ const lookBack = 50;
39
+ /* The longest text a JSX attribute is looked for in on an Enter. */
40
+ const jsxScanLimit = 2_000_000;
41
+ /* How far past the caret a comment is searched for its end. */
42
+ const commentReach = 20_000;
43
+
44
+ function whitespaceOf(text: string): string {
45
+ return /^[\t ]*/.exec(text)![0];
46
+ }
47
+
48
+ function isBlank(text: string): boolean {
49
+ return /^[\t ]*$/.test(text);
50
+ }
51
+
52
+ function escapeForPattern(text: string): string {
53
+ return text.replace(/[.*+?^${}()|[\]\\/-]/g, '\\$&');
54
+ }
55
+
56
+ /*
57
+ * Whether the code before `end` leaves its statement open: after the parenthesis of a braceless
58
+ * `if` or `for`, after `else`, `=>` or a binary operator. Languages with braces only.
59
+ */
60
+ export function continues(text: string, context: TypingContext, language: string): boolean {
61
+ if (!isBraced(language) || context.mode !== 'code') {
62
+ return false;
63
+ }
64
+ if (context.word === 'control-close' || context.word === 'else' || context.word === 'do') {
65
+ return true;
66
+ }
67
+ if (isPhp(language) && /(?<!\.)\.$/.test(text)) {
68
+ return true;
69
+ }
70
+ return trailingOperator.test(text);
71
+ }
72
+
73
+ class Enter {
74
+ private readonly source: EditSource;
75
+ private readonly options: EnterOptions;
76
+
77
+ constructor(source: EditSource, options: EnterOptions) {
78
+ this.source = source;
79
+ this.options = options;
80
+ }
81
+
82
+ plan(from: number, to: number): EnterPlan {
83
+ const { source, options } = this;
84
+ const index = source.lineAt(from);
85
+ const line = source.line(index);
86
+ const before = source.slice(line.start, from);
87
+ const leading = whitespaceOf(before);
88
+ const newline = source.newline(index);
89
+ if (!options.smart) {
90
+ return { from, to, text: newline + leading, caret: newline.length + leading.length };
91
+ }
92
+ const context = source.context(from);
93
+ const selection = from !== to;
94
+ const planned =
95
+ context.mode === 'block-comment'
96
+ ? this.inBlockComment(from, to, context, index)
97
+ : context.mode === 'line-comment' && !selection
98
+ ? this.inLineComment(from, context, index)
99
+ : context.mode === 'quote' && !selection
100
+ ? this.inString(from, context, index)
101
+ : null;
102
+ return planned ?? this.inCode(from, to, context, index);
103
+ }
104
+
105
+ /* The indentation a new statement after this line gets, which is not the line's own when the line is a continuation. */
106
+ private baseIndent(index: number, depth = 0): string {
107
+ const line = this.source.line(index);
108
+ const own = whitespaceOf(line.text);
109
+ const previous = this.previousCodeLine(index);
110
+ if (previous === null || depth > lookBack || !this.lineContinues(previous)) {
111
+ return own;
112
+ }
113
+ return this.baseIndent(previous, depth + 1);
114
+ }
115
+
116
+ private previousCodeLine(index: number): number | null {
117
+ for (let candidate = index - 1; candidate >= 0 && candidate >= index - lookBack; candidate--) {
118
+ if (!isBlank(this.source.line(candidate).text)) {
119
+ return candidate;
120
+ }
121
+ }
122
+ return null;
123
+ }
124
+
125
+ private lineContinues(index: number): boolean {
126
+ const line = this.source.line(index);
127
+ const text = line.text.trimEnd();
128
+ return text.length > 0 && continues(text, this.source.context(line.start + text.length), this.options.language);
129
+ }
130
+
131
+ private inCode(from: number, to: number, lineContext: TypingContext, index: number): EnterPlan {
132
+ const { source, options } = this;
133
+ const line = source.line(index);
134
+ const before = source.slice(line.start, from);
135
+ const leading = whitespaceOf(before);
136
+ const newline = source.newline(index);
137
+ const { unit, language } = options;
138
+ // A comment that trails the code is not part of what decides the indentation.
139
+ const commentAt = lineContext.mode === 'line-comment' ? lineContext.commentStart : undefined;
140
+ const trailing = commentAt !== undefined && commentAt >= line.start && !isBlank(source.slice(line.start, commentAt));
141
+ const trimmed = trailing ? source.slice(line.start, commentAt).trimEnd() : before.trimEnd();
142
+ const context = trailing ? source.context(line.start + trimmed.length) : lineContext;
143
+ const last = trimmed.at(-1) ?? '';
144
+ const rest = source.slice(to, line.end);
145
+ const afterWhitespace = to + whitespaceOf(rest).length;
146
+ const next = source.charAt(afterWhitespace);
147
+ const base = context.mode === 'code' ? this.baseIndent(index) : leading;
148
+ const start = leading.length < before.length ? base : leading;
149
+ const plain = (indent: string): EnterPlan => ({ from, to: afterWhitespace, text: newline + indent, caret: newline.length + indent.length });
150
+
151
+ if (context.mode !== 'code') {
152
+ return plain(leading);
153
+ }
154
+
155
+ const opener = closers[last] !== undefined && context.bracket?.close === closers[last] && context.bracket.at === line.start + trimmed.length - 1;
156
+ if (opener) {
157
+ const inner = start + unit;
158
+ if (next === closers[last]) {
159
+ return { from, to: afterWhitespace, text: newline + inner + newline + start, caret: newline.length + inner.length };
160
+ }
161
+ const closing =
162
+ last === '{' && source.unmatchedBrace(context.bracket!.at) ? this.closeBrace(from, afterWhitespace, line, rest, newline, start, inner) : null;
163
+ return closing ?? plain(inner);
164
+ }
165
+
166
+ const tag = this.openTag(trimmed, index);
167
+ if (tag) {
168
+ const inner = start + unit;
169
+ if (new RegExp(`^</${escapeForPattern(tag)}\\s*>`, 'i').test(source.slice(afterWhitespace, line.end))) {
170
+ return { from, to: afterWhitespace, text: newline + inner + newline + start, caret: newline.length + inner.length };
171
+ }
172
+ return plain(inner);
173
+ }
174
+
175
+ if (this.opensIndentedBlock(trimmed, context)) {
176
+ return plain(this.blockIndent(line.text, start) + unit);
177
+ }
178
+
179
+ if (continues(trimmed, context, language)) {
180
+ const continued = this.previousCodeLine(index) !== null && this.lineContinues(this.previousCodeLine(index)!);
181
+ return plain(continued ? leading : start + unit);
182
+ }
183
+ return plain(start);
184
+ }
185
+
186
+ /* The `}` of a `{` that nothing closes goes under the line it opens, or after what follows the caret when that is a statement. */
187
+ private closeBrace(
188
+ from: number,
189
+ afterWhitespace: number,
190
+ line: DocumentLine,
191
+ rest: string,
192
+ newline: string,
193
+ base: string,
194
+ inner: string
195
+ ): EnterPlan | null {
196
+ const tail = rest.trimStart();
197
+ if (tail === '' || /^[)\];,%<?]/.test(tail)) {
198
+ const text = newline + inner + newline + base + '}';
199
+ return { from, to: afterWhitespace, text, caret: newline.length + inner.length };
200
+ }
201
+ if (!this.options.reach) {
202
+ return null;
203
+ }
204
+ const text = newline + inner + tail.trimEnd() + newline + base + '}';
205
+ return { from, to: line.end, text, caret: newline.length + inner.length };
206
+ }
207
+
208
+ /* The name of the tag the text ends in, when it is an open tag with children to come. */
209
+ private openTag(trimmed: string, index: number): string | null {
210
+ const { language } = this.options;
211
+ if (!markupLanguages.test(language)) {
212
+ return null;
213
+ }
214
+ if (/^vue$/i.test(language) && this.source.region(index) === 'script') {
215
+ return null;
216
+ }
217
+ const match = /(?:^|[^\w$)\]])<([A-Za-z][\w:.-]*)(?:\s(?:[^<>]|=>)*)?>$/.exec(trimmed);
218
+ if (!match || trimmed.endsWith('/>') || (voidElements.test(match[1]!) && !/^xml$/i.test(language))) {
219
+ return null;
220
+ }
221
+ return match[1]!;
222
+ }
223
+
224
+ /* Python's `def f():`, a YAML `key:` and a `case x:`. */
225
+ private opensIndentedBlock(trimmed: string, context: TypingContext): boolean {
226
+ const { language } = this.options;
227
+ if (/^(python|py)$/i.test(language)) {
228
+ return trimmed.endsWith(':') && context.bracket === undefined;
229
+ }
230
+ if (/^(yaml|yml)$/i.test(language)) {
231
+ return /(?::|[|>][+-]?)$/.test(trimmed) && !trimmed.trimStart().startsWith('#');
232
+ }
233
+ if (isBraced(language)) {
234
+ return (context.bracket === undefined || context.bracket.close === '}') && /^(?:case\b[^]*|default)\s*:$/.test(trimmed.trimStart());
235
+ }
236
+ return false;
237
+ }
238
+
239
+ /* A YAML list item's children line up with its key, not with its dash. */
240
+ private blockIndent(text: string, start: string): string {
241
+ if (!/^(yaml|yml)$/i.test(this.options.language)) {
242
+ return start;
243
+ }
244
+ const dashes = /^[\t ]*(?:- +)+/.exec(text)?.[0];
245
+ return dashes ? start + ' '.repeat(dashes.length - whitespaceOf(text).length) : start;
246
+ }
247
+
248
+ private inLineComment(from: number, context: TypingContext, index: number): EnterPlan | null {
249
+ const { source, options } = this;
250
+ const line = source.line(index);
251
+ const commentStart = context.commentStart;
252
+ const syntax = commentSyntax(options.language, source.region(index));
253
+ // A hash comment of a language that has both, as in PHP, continues with its own marker.
254
+ const token = commentStart !== undefined && hasHashComments(options.language) && source.charAt(commentStart) === '#' ? '#' : syntax.line;
255
+ if (commentStart === undefined || commentStart < line.start || token === null) {
256
+ return null;
257
+ }
258
+ const marker = new RegExp(`^${escapeForPattern(token)}${token === '//' ? '[/!]?' : token === '#' ? '#*' : ''}`).exec(
259
+ source.slice(commentStart, line.end)
260
+ )?.[0];
261
+ if (marker === undefined || from < commentStart + marker.length) {
262
+ return null;
263
+ }
264
+ const rest = source.slice(from, line.end);
265
+ const textStart = from + whitespaceOf(rest).length;
266
+ if (textStart >= line.end) {
267
+ return null;
268
+ }
269
+ const newline = source.newline(index);
270
+ const leading = whitespaceOf(source.slice(line.start, from));
271
+ if (source.slice(textStart, textStart + token.length) === token) {
272
+ return { from, to: textStart, text: newline + leading, caret: newline.length + leading.length };
273
+ }
274
+ const code = source.slice(line.start, commentStart);
275
+ const onlyComment = isBlank(code);
276
+ let spacing = ' ';
277
+ if (onlyComment) {
278
+ const gap = whitespaceOf(source.slice(commentStart + marker.length, line.end));
279
+ spacing = gap === '' ? ' ' : gap;
280
+ } else if (source.charAt(from) === ' ') {
281
+ spacing = '';
282
+ }
283
+ const text = newline + leading + marker + spacing;
284
+ return { from, to: textStart, text, caret: text.length };
285
+ }
286
+
287
+ private inString(from: number, context: TypingContext, index: number): EnterPlan | null {
288
+ const { source, options } = this;
289
+ const operator = concatenation.get(options.language.toLowerCase());
290
+ const quote = context.quote;
291
+ if (operator === undefined || (quote !== '"' && quote !== "'")) {
292
+ return null;
293
+ }
294
+ const line = source.line(index);
295
+ const opener = this.stringStart(line, from, quote);
296
+ if (opener !== null && (this.isModulePath(line, opener, index) || this.isMarkupAttribute(opener))) {
297
+ return null;
298
+ }
299
+ let at = from;
300
+ if (context.escaped) {
301
+ at += /^(?:u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|.)/.exec(source.slice(from, line.end))?.[0].length ?? 0;
302
+ }
303
+ if (!this.closesOnLine(at, quote, line)) {
304
+ return null;
305
+ }
306
+ const newline = source.newline(index);
307
+ const previous = this.previousCodeLine(index);
308
+ const leading = whitespaceOf(line.text);
309
+ const continued = previous !== null && this.lineContinues(previous);
310
+ const indent = continued ? leading : leading + options.unit;
311
+ const text = `${quote} ${operator}${newline}${indent}${quote}`;
312
+ const rest = source.slice(at, line.end);
313
+ return { from: at, to: at + whitespaceOf(rest).length, text, caret: text.length };
314
+ }
315
+
316
+ /* Where the string the caret is in opened, when it opened on this line. */
317
+ private stringStart(line: DocumentLine, from: number, quote: string): number | null {
318
+ let open: { at: number; quote: string } | null = null;
319
+ for (let at = line.start; at < from; at++) {
320
+ const char = this.source.charAt(at);
321
+ if (open === null) {
322
+ if (char === '"' || char === "'" || char === '`') {
323
+ open = { at, quote: char };
324
+ }
325
+ } else if (char === '\\') {
326
+ at++;
327
+ } else if (char === open.quote) {
328
+ open = null;
329
+ }
330
+ }
331
+ return open?.quote === quote ? open.at : null;
332
+ }
333
+
334
+ /* A path an import, a `require` or an `export ... from` names, where a break and a concatenation would name another file. */
335
+ private isModulePath(line: DocumentLine, opener: number, index: number): boolean {
336
+ const before = this.source.slice(line.start, opener).trimEnd();
337
+ if (/(?:^|[^\w$.])(?:import|from|require|require_once|include|include_once)\s*\(?$/.test(before)) {
338
+ return true;
339
+ }
340
+ return /^go$/i.test(this.options.language) && before === '' && this.inGoImportBlock(index);
341
+ }
342
+
343
+ private inGoImportBlock(index: number): boolean {
344
+ for (let candidate = index - 1; candidate >= 0 && candidate >= index - lookBack; candidate--) {
345
+ const text = this.source.line(candidate).text.trim();
346
+ if (/^import\s*\($/.test(text)) {
347
+ return true;
348
+ }
349
+ if (text.startsWith(')')) {
350
+ return false;
351
+ }
352
+ }
353
+ return false;
354
+ }
355
+
356
+ /* The value of a JSX attribute, which has to stay one string. */
357
+ private isMarkupAttribute(opener: number): boolean {
358
+ if (!hasJsx(this.options.language)) {
359
+ return false;
360
+ }
361
+ const last = this.source.line(this.source.lineCount - 1).end;
362
+ return last <= jsxScanLimit && isJsxAttributeValue(this.source.slice(0, last), opener);
363
+ }
364
+
365
+ private closesOnLine(from: number, quote: string, line: DocumentLine): boolean {
366
+ const text = this.source.slice(from, line.end);
367
+ for (let at = 0; at < text.length; at++) {
368
+ if (text[at] === '\\') {
369
+ at++;
370
+ } else if (text[at] === quote) {
371
+ return true;
372
+ }
373
+ }
374
+ return false;
375
+ }
376
+
377
+ private inBlockComment(from: number, to: number, context: TypingContext, index: number): EnterPlan | null {
378
+ const { source, options } = this;
379
+ const syntax = commentSyntax(options.language, source.region(index));
380
+ const open = context.commentStart;
381
+ if (syntax.block?.open !== '/*' || open === undefined || from < open + 2) {
382
+ return null;
383
+ }
384
+ const startIndex = source.lineAt(open);
385
+ const startLine = source.line(startIndex);
386
+ const line = source.line(index);
387
+ const code = source.slice(startLine.start, open);
388
+ const lead = this.commentLead(code);
389
+ const newline = source.newline(index);
390
+ const closeAt = this.closingOf(open, from);
391
+ const isDoc = source.slice(open, open + 3) === '/**' && source.slice(open, open + 4) !== '/**/';
392
+ const caretText = line.text.trimStart();
393
+ const onStar = index > startIndex && caretText.startsWith('*') && !caretText.startsWith('*/');
394
+ const onOpener = index === startIndex;
395
+ const rest = source.slice(to, line.end);
396
+ const textStart = to + whitespaceOf(rest).length;
397
+
398
+ if (closeAt === null) {
399
+ const star = onOpener || onStar ? this.starPrefix(line, lead, onStar, isDoc) : whitespaceOf(line.text);
400
+ const tail = source.slice(textStart, line.end).trimEnd();
401
+ const closer = `${newline}${lead} */`;
402
+ if (options.reach) {
403
+ const text = newline + star + tail + closer;
404
+ return { from, to: line.end, text, caret: newline.length + star.length };
405
+ }
406
+ return { from, to: textStart, text: newline + star, caret: newline.length + star.length };
407
+ }
408
+ if (isDoc && onOpener && source.lineAt(closeAt) === index && isBlank(source.slice(to, closeAt)) && isBlank(source.slice(closeAt + 2, line.end))) {
409
+ // Nothing left to say after the caret: the closer gets a line of its own, under the opener's star.
410
+ const star = this.starPrefix(line, lead, false, isDoc);
411
+ return { from, to: line.end, text: `${newline}${star}${newline}${lead} */`, caret: newline.length + star.length };
412
+ }
413
+ const second = source.line(startIndex + 1).text.trimStart();
414
+ const continuesStars = onOpener ? isDoc : second.startsWith('*') && !second.startsWith('*/');
415
+ if (!continuesStars) {
416
+ return null;
417
+ }
418
+ const star = this.starPrefix(line, lead, onStar, isDoc);
419
+ return { from, to: textStart, text: newline + star, caret: newline.length + star.length };
420
+ }
421
+
422
+ /* The whitespace that puts a star under the first star of an opener that stands after `code`. */
423
+ private commentLead(code: string): string {
424
+ const indent = whitespaceOf(code);
425
+ const gap = indentationColumn(code, this.options.tabSize) - indentationColumn(indent, this.options.tabSize);
426
+ return indent + ' '.repeat(gap);
427
+ }
428
+
429
+ /* A line of a comment that starts with `*` keeps its own alignment and the indentation inside a doc comment; any other line gets the opener's. */
430
+ private starPrefix(line: DocumentLine, lead: string, onStar: boolean, isDoc: boolean): string {
431
+ if (!onStar) {
432
+ return `${lead} * `;
433
+ }
434
+ const inside = /^\*([\t ]*)/.exec(line.text.trimStart())?.[1] ?? '';
435
+ return `${whitespaceOf(line.text)}*${isDoc && inside !== '' ? inside : ' '}`;
436
+ }
437
+
438
+ /* Where the comment opened at `open` ends after `from`, or null when it runs on or swallows another comment. */
439
+ private closingOf(open: number, from: number): number | null {
440
+ const text = this.source.slice(from, from + commentReach);
441
+ const close = text.indexOf('*/');
442
+ if (close < 0) {
443
+ return null;
444
+ }
445
+ return this.source.slice(open + 2, from + close).includes('/*') ? null : from + close;
446
+ }
447
+ }
448
+
449
+ /* Where one Enter puts its break and caret, as a replacement of text around the caret. */
450
+ export function planEnter(source: EditSource, from: number, to: number, options: EnterOptions): EnterPlan {
451
+ return new Enter(source, options).plan(from, to);
452
+ }
@@ -0,0 +1,166 @@
1
+ import { isImportLine } from './lexical-folds.ts';
2
+ import type { DocumentLine } from './rope.ts';
3
+ import type { FoldHints, FoldRole, FoldingRange } from './structure.ts';
4
+
5
+ const SCRIPT_LANGUAGES = /^(typescript|javascript|typescriptreact|javascriptreact|tsx|jsx|ts|js|mjs|cjs|mts|cts)$/i;
6
+ /* How far back from a bracket the text before it is read. */
7
+ const LOOK_BEHIND = 200;
8
+ /* A file header sits at the top, behind at most a shebang or an open tag and a blank line or two. */
9
+ const HEADER_LINES = 4;
10
+ /* Words an expression can follow, so a bracket after one is a value and not a block. */
11
+ const VALUE_WORDS = /\b(?:return|default|yield|await|of|in)$/;
12
+
13
+ /* A block comment that opens with two stars is documentation; `/**` closed at once is an empty one. */
14
+ export function commentRole(slice: (from: number, to: number) => string, from: number, to: number): FoldRole | undefined {
15
+ return to - from > 4 && slice(from, from + 3) === '/**' ? 'doc-comment' : undefined;
16
+ }
17
+
18
+ /*
19
+ * Whether a bracket opens an array or an object literal in a script, or a PHP attribute list, read from what
20
+ * comes before it. A block (`) {`, `else {`) and an index (`items[`) have a word or a closer in front of them.
21
+ */
22
+ export function bracketRole(slice: (from: number, to: number) => string, from: number, language: string): FoldRole | undefined {
23
+ const opener = slice(from, from + 1);
24
+ const before = slice(Math.max(0, from - LOOK_BEHIND), from);
25
+ if (language.toLowerCase() === 'php') {
26
+ return opener === '[' && before.endsWith('#') ? 'attribute' : undefined;
27
+ }
28
+ if (!SCRIPT_LANGUAGES.test(language) || (opener !== '{' && opener !== '[')) {
29
+ return undefined;
30
+ }
31
+ const head = before.slice(before.lastIndexOf('\n') + 1);
32
+ const trimmed = before.trimEnd();
33
+ const previous = trimmed.at(-1);
34
+ if (previous === undefined) {
35
+ return undefined;
36
+ }
37
+ const arrow = trimmed.endsWith('=>');
38
+ const value = (/[=:(,[?]/.test(previous) && !arrow) || VALUE_WORDS.test(trimmed) || (arrow && opener === '[');
39
+ if (!value) {
40
+ return undefined;
41
+ }
42
+ if (opener === '[') {
43
+ return 'array-literal';
44
+ }
45
+ // A `case x: {` and a `type A = {` are a block and a type, not a value.
46
+ return /^\s*(?:case\b|default\s*:|(?:export\s+)?(?:declare\s+)?(?:type|interface)\b)/.test(head) ? undefined : 'object-literal';
47
+ }
48
+
49
+ function isBlank(text: string): boolean {
50
+ return text.trim() === '';
51
+ }
52
+
53
+ /*
54
+ * The comment at the very top of a file is its header, unless it is the documentation of what follows
55
+ * right under it. Only the first comment can be one, and only behind a shebang, an open tag or blank lines.
56
+ */
57
+ export function markFileHeader(ranges: FoldingRange[], getLine: (line: number) => DocumentLine, lineCount: number, language: string): void {
58
+ const candidate = ranges.find((range) => (range.kind === 'comment' || range.kind === 'line-comments') && range.startLine <= HEADER_LINES);
59
+ if (candidate === undefined) {
60
+ return;
61
+ }
62
+ for (let line = 0; line < candidate.startLine; line++) {
63
+ const text = getLine(line).text;
64
+ if (!isBlank(text) && !(line === 0 && /^(#!|<\?php\s*$)/.test(text))) {
65
+ return;
66
+ }
67
+ }
68
+ const start = getLine(candidate.startLine);
69
+ if (candidate.from > start.start + (start.text.length - start.text.trimStart().length)) {
70
+ return;
71
+ }
72
+ const next = candidate.endLine + 1 < lineCount ? getLine(candidate.endLine + 1).text : '';
73
+ const attached = candidate.role === 'doc-comment' && !isBlank(next) && !isImportLine(language, next.trimStart());
74
+ if (!attached) {
75
+ candidate.role = 'file-header';
76
+ }
77
+ }
78
+
79
+ /* The role a language server's range kind says, when the text under it says nothing else. */
80
+ function serverRole(kind: string | undefined, text: string): FoldRole | undefined {
81
+ if (kind === 'imports') {
82
+ return 'imports';
83
+ }
84
+ if (kind === 'region') {
85
+ return 'region';
86
+ }
87
+ if (kind === 'comment') {
88
+ return text.startsWith('/**') ? 'doc-comment' : undefined;
89
+ }
90
+ return text.startsWith('<') ? 'tag' : undefined;
91
+ }
92
+
93
+ /*
94
+ * What a language server adds to the folds the text gives. A symbol names the body among the brackets: the
95
+ * pair that closes where it ends, or the indented block that does. A range the text has no fold for becomes a
96
+ * fold of its own, one per line it starts on, so the server's `{` block is not a second fold under the pair.
97
+ */
98
+ export function applyFoldHints(
99
+ ranges: readonly FoldingRange[],
100
+ hints: FoldHints,
101
+ lineAt: (offset: number) => number,
102
+ getLine: (line: number) => DocumentLine,
103
+ slice: (from: number, to: number) => string,
104
+ minimum: number
105
+ ): FoldingRange[] {
106
+ const result = ranges.map((range) => ({ ...range }));
107
+ for (const symbol of hints.symbols ?? []) {
108
+ const startLine = lineAt(symbol.from);
109
+ const endLine = lineAt(Math.max(symbol.from, symbol.to - 1));
110
+ const body = bodyOf(result, symbol, startLine, endLine, slice);
111
+ if (body !== undefined) {
112
+ body.range.role = body.role;
113
+ }
114
+ }
115
+ const starts = new Set(result.map((range) => range.startLine));
116
+ for (const hint of hints.ranges ?? []) {
117
+ const startLine = lineAt(hint.from);
118
+ const endLine = lineAt(hint.to);
119
+ if (endLine - startLine < minimum || starts.has(startLine)) {
120
+ continue;
121
+ }
122
+ const first = getLine(startLine);
123
+ const indent = first.text.length - first.text.trimStart().length;
124
+ const role = serverRole(hint.kind, first.text.trimStart());
125
+ starts.add(startLine);
126
+ result.push({
127
+ startLine,
128
+ endLine,
129
+ from: first.start + indent,
130
+ to: getLine(endLine).end,
131
+ kind: 'server',
132
+ ...(role === undefined ? {} : { role })
133
+ });
134
+ }
135
+ return result.sort((left, right) => left.startLine - right.startLine || right.endLine - left.endLine);
136
+ }
137
+
138
+ /* The fold that is the body of a symbol, and what kind of body it is. */
139
+ function bodyOf(
140
+ ranges: FoldingRange[],
141
+ symbol: { from: number; to: number; body: 'function' | 'method' | 'class' | 'value' },
142
+ startLine: number,
143
+ endLine: number,
144
+ slice: (from: number, to: number) => string
145
+ ): { range: FoldingRange; role: FoldRole } | undefined {
146
+ const brace = ranges
147
+ .filter((range) => range.kind === 'bracket' && range.from >= symbol.from && range.to <= symbol.to && slice(range.from, range.from + 1) === '{')
148
+ .filter((range) => /^[\s;,]*$/.test(slice(range.to, symbol.to)))
149
+ .sort((left, right) => right.to - left.to || left.from - right.from)[0];
150
+ const range =
151
+ brace ??
152
+ ranges
153
+ .filter((candidate) => candidate.kind === 'indentation' && candidate.endLine === endLine && candidate.startLine >= startLine)
154
+ .sort((left, right) => left.startLine - right.startLine)[0];
155
+ if (range === undefined) {
156
+ return undefined;
157
+ }
158
+ if (symbol.body === 'class') {
159
+ return { range, role: 'class-body' };
160
+ }
161
+ if (symbol.body === 'value') {
162
+ const signature = slice(symbol.from, range.from);
163
+ return /=>|\bfunction\b/.test(signature) ? { range, role: 'function-body' } : undefined;
164
+ }
165
+ return { range, role: symbol.body === 'method' ? 'method-body' : 'function-body' };
166
+ }
package/src/index.ts ADDED
@@ -0,0 +1,22 @@
1
+ export { DocumentModel } from './document.ts';
2
+ export type { DocumentEditOptions, DocumentLine, OffsetRange, ReplaceAllOptions, ReplaceOptions, TypeTextOptions } from './document.ts';
3
+ export { scanBrackets, type BracketIndex } from './brackets.ts';
4
+ export { replaceWithCaseRespect } from './preserve-case.ts';
5
+ export { findMatches, replacementText, type FindMatch, type FindNextOptions, type FindOptions } from './search.ts';
6
+ export { indentationColumn } from './structure.ts';
7
+ export type { FoldHints, FoldRangeHint, FoldRole, FoldSymbolHint, FoldingOptions, FoldingRange } from './structure.ts';
8
+ export { isHumpBoundary, isWordBoundary, wordBoundary, type WordText } from './words.ts';
9
+ export type {
10
+ ChangeSource,
11
+ CommandOptions,
12
+ ContentEdit,
13
+ Disposable,
14
+ DocumentChange,
15
+ EditOptions,
16
+ EditorCommand,
17
+ EditorSnapshot,
18
+ Position,
19
+ Selection,
20
+ TextEdit
21
+ } from './types.ts';
22
+ export { changedSpan, changedSpans, type TextSpan } from './text-span.ts';