@adecore/editor-core 0.43.0 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,10 @@ 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 TextPlace } from './text-places.ts';
7
+ import { type PostfixOperand } from './postfix-operands.ts';
8
+ import { type SurroundShape } from './surround-shape.ts';
9
+ import { type UnwrapTarget } from './unwrap.ts';
6
10
  import { type TodoMatch, type TodoOptions } from './todo.ts';
7
11
  import { type WhitespaceCleanupOptions } from './whitespace-cleanup.ts';
8
12
  export type { DocumentLine, FindMatch, FindNextOptions, FindOptions, FoldingOptions, FoldingRange };
@@ -135,6 +139,10 @@ export declare class DocumentModel {
135
139
  private insertNewline;
136
140
  private startNewLineBefore;
137
141
  private editLines;
142
+ placeAt(offset: number, language: string): TextPlace;
143
+ postfixOperands(dot: number, language: string): PostfixOperand[];
144
+ surroundShape(from: number, to: number, language: string): SurroundShape | null;
145
+ unwrapTargets(offset: number, language: string, indentUnit: string): UnwrapTarget[];
138
146
  private editSource;
139
147
  private isUnclosedBrace;
140
148
  private scriptSource;
package/dist/document.js CHANGED
@@ -22,6 +22,10 @@ import { clampInteger, TextRope, ROPE_CHUNK_SIZE } from './rope.js';
22
22
  import { findMatches, findMatchFrom, replacementText, splitsSurrogate } from './search.js';
23
23
  import { dedupeFoldingRanges, deriveFoldingRanges, indentationColumn, scanStructure, tabWidth } from './structure.js';
24
24
  import { TypingContexts } from './typing-context.js';
25
+ import { placeAt } from './text-places.js';
26
+ import { postfixOperands } from './postfix-operands.js';
27
+ import { surroundShape } from './surround-shape.js';
28
+ import { unwrapTargets } from './unwrap.js';
25
29
  import { todosInLines } from './todo.js';
26
30
  import { planConvertIndents, planLineOrder } from './line-order.js';
27
31
  import { ModifiedStretches, planWhitespaceCleanup } from './whitespace-cleanup.js';
@@ -1509,6 +1513,22 @@ export class DocumentModel {
1509
1513
  });
1510
1514
  return this.applyEdits(plan.edits, { source: 'command', selections });
1511
1515
  }
1516
+ /* What the text at an offset is written in, with embedded regions resolved, and whether it is code, a comment or a string. */
1517
+ placeAt(offset, language) {
1518
+ return placeAt(this.editSource(language), offset, language);
1519
+ }
1520
+ /* Every expression that ends right before the `.` at `dot`, innermost first; empty where none does. */
1521
+ postfixOperands(dot, language) {
1522
+ return postfixOperands(this.editSource(language), dot, language);
1523
+ }
1524
+ /* What a range can be surrounded with: whole statements, one expression, both or neither. Null where the range is blank. */
1525
+ surroundShape(from, to, language) {
1526
+ return surroundShape(this.editSource(language), from, to, language);
1527
+ }
1528
+ /* What can be unwrapped or removed around an offset, innermost first, each with the edits that do it. */
1529
+ unwrapTargets(offset, language, indentUnit) {
1530
+ return unwrapTargets(this.editSource(language), offset, language, indentUnit);
1531
+ }
1512
1532
  editSource(language) {
1513
1533
  return {
1514
1534
  lineCount: this.getLineCount(),
package/dist/index.d.ts CHANGED
@@ -3,6 +3,7 @@ export type { DocumentEditOptions, DocumentLine, OffsetRange, ReplaceAllOptions,
3
3
  export { scanBrackets, type BracketIndex, type BracketMatch } from './brackets.ts';
4
4
  export { findMatches, iterateMatches, findMatchFrom, replacementText, type FindMatch, type FindNextOptions, type FindOptions } from './search.ts';
5
5
  export { indentationColumn } from './structure.ts';
6
+ export { commentSyntax, type CommentSyntax } from './languages.ts';
6
7
  export { findDocComments, MAX_DOC_COMMENTS, type DocComment } from './doc-comments.ts';
7
8
  export { DEFAULT_TODO_WORDS, findTodos, type TodoMatch, type TodoOptions } from './todo.ts';
8
9
  export { detectIndentation, type DetectedIndentation } from './line-order.ts';
@@ -10,3 +11,7 @@ export type { TrailingWhitespace, WhitespaceCleanupOptions } from './whitespace-
10
11
  export type { FoldHints, FoldRangeHint, FoldRole, FoldSymbolHint, FoldingOptions, FoldingRange } from './structure.ts';
11
12
  export type { ChangeSource, CommandOptions, ContentEdit, Disposable, DocumentChange, EditOptions, EditorCommand, EditorSnapshot, Position, Selection, TextEdit } from './types.ts';
12
13
  export { changedSpan, changedSpans, type TextSpan } from './text-span.ts';
14
+ export type { TextPlace, TextPlaceKind } from './text-places.ts';
15
+ export type { PostfixOperand } from './postfix-operands.ts';
16
+ export type { SurroundShape } from './surround-shape.ts';
17
+ export type { UnwrapKind, UnwrapTarget } from './unwrap.ts';
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@ export { DocumentModel } from './document.js';
2
2
  export { scanBrackets } from './brackets.js';
3
3
  export { findMatches, iterateMatches, findMatchFrom, replacementText } from './search.js';
4
4
  export { indentationColumn } from './structure.js';
5
+ export { commentSyntax } from './languages.js';
5
6
  export { findDocComments, MAX_DOC_COMMENTS } from './doc-comments.js';
6
7
  export { DEFAULT_TODO_WORDS, findTodos } from './todo.js';
7
8
  export { detectIndentation } from './line-order.js';
@@ -0,0 +1,6 @@
1
+ import type { EditSource } from './edit-source.ts';
2
+ export interface PostfixOperand {
3
+ readonly from: number;
4
+ readonly statement: boolean;
5
+ }
6
+ export declare function postfixOperands(source: EditSource, dot: number, language: string): PostfixOperand[];
@@ -0,0 +1,397 @@
1
+ import { isPhp, isScript, isWordCharacter } from './lexical.js';
2
+ import { atStatementStart } from './surround-shape.js';
3
+ import { placeAt } from './text-places.js';
4
+ import { skipTriviaBack } from './trivia.js';
5
+ const TERNARY = 2;
6
+ const LITERAL_LIMIT = 10_000;
7
+ const MAX_OPERATOR_LENGTH = 4;
8
+ const binary = (precedence, right = false) => ({ kind: 'binary', precedence, right });
9
+ const stop = { kind: 'stop', precedence: 0, right: false };
10
+ const SCRIPT_OPERATORS = new Map([
11
+ ['**', binary(15, true)],
12
+ ['*', binary(14)],
13
+ ['/', binary(14)],
14
+ ['%', binary(14)],
15
+ ['+', binary(13)],
16
+ ['-', binary(13)],
17
+ ['<<', binary(12)],
18
+ ['>>', binary(12)],
19
+ ['>>>', binary(12)],
20
+ ['<', binary(10)],
21
+ ['>', binary(10)],
22
+ ['<=', binary(10)],
23
+ ['>=', binary(10)],
24
+ ['==', binary(9)],
25
+ ['!=', binary(9)],
26
+ ['===', binary(9)],
27
+ ['!==', binary(9)],
28
+ ['&', binary(8)],
29
+ ['^', binary(7)],
30
+ ['|', binary(6)],
31
+ ['&&', binary(5)],
32
+ ['||', binary(4)],
33
+ ['??', binary(3, true)],
34
+ ['?:', binary(TERNARY, true)],
35
+ ['?', { kind: 'question', precedence: TERNARY, right: true }],
36
+ [':', { kind: 'colon', precedence: TERNARY, right: true }]
37
+ ]);
38
+ // An assignment ends the operand, and they are listed so that `>>=` is not read as `>=`.
39
+ for (const text of ['=', '=>', '+=', '-=', '*=', '/=', '%=', '&=', '|=', '^=', '**=', '<<=', '>>=', '>>>=', '&&=', '||=', '??=', '?.', '++', '--', '::']) {
40
+ SCRIPT_OPERATORS.set(text, stop);
41
+ }
42
+ const PHP_OPERATORS = new Map(SCRIPT_OPERATORS);
43
+ PHP_OPERATORS.set('.', binary(11));
44
+ PHP_OPERATORS.set('.=', stop);
45
+ PHP_OPERATORS.set('<>', binary(10));
46
+ PHP_OPERATORS.set('<=>', binary(9));
47
+ PHP_OPERATORS.set('->', stop);
48
+ PHP_OPERATORS.set('?->', stop);
49
+ const SCRIPT_WORDS = new Map([
50
+ ['in', binary(10)],
51
+ ['instanceof', binary(10)]
52
+ ]);
53
+ const PHP_WORDS = new Map([
54
+ ['instanceof', binary(10)],
55
+ ['and', binary(1.5)],
56
+ ['xor', binary(1.4)],
57
+ ['or', binary(1.3)]
58
+ ]);
59
+ const UNARY_WORDS = new Set(['typeof', 'void', 'await', 'delete']);
60
+ // Words that start a statement or a clause, so they are no operand and nothing to the left of them belongs to an expression.
61
+ const KEYWORDS = new Set([
62
+ 'return',
63
+ 'throw',
64
+ 'yield',
65
+ 'case',
66
+ 'default',
67
+ 'const',
68
+ 'let',
69
+ 'var',
70
+ 'echo',
71
+ 'print',
72
+ 'if',
73
+ 'else',
74
+ 'elseif',
75
+ 'for',
76
+ 'foreach',
77
+ 'while',
78
+ 'do',
79
+ 'switch',
80
+ 'try',
81
+ 'catch',
82
+ 'finally',
83
+ 'function',
84
+ 'class',
85
+ 'import',
86
+ 'export',
87
+ 'of',
88
+ 'as',
89
+ 'use',
90
+ 'new',
91
+ 'extends',
92
+ 'implements',
93
+ 'global',
94
+ 'include',
95
+ 'require'
96
+ ]);
97
+ const CAST = /^\s*(?:int|integer|bool|boolean|float|double|string|array|object|binary|unset)\s*$/i;
98
+ function insideTemplate(context) {
99
+ for (let bracket = context.bracket; bracket !== undefined; bracket = bracket.previous) {
100
+ if (bracket.template === true) {
101
+ return true;
102
+ }
103
+ }
104
+ return false;
105
+ }
106
+ class Scanner {
107
+ source;
108
+ php;
109
+ typescript;
110
+ operators;
111
+ words;
112
+ constructor(source, place) {
113
+ this.source = source;
114
+ this.php = isPhp(place);
115
+ this.typescript = /^(?:typescript|ts|tsx|typescriptreact|mts|cts)$/i.test(place);
116
+ this.operators = this.php ? PHP_OPERATORS : SCRIPT_OPERATORS;
117
+ this.words = this.php ? PHP_WORDS : SCRIPT_WORDS;
118
+ }
119
+ skip(end) {
120
+ return skipTriviaBack(this.source, end);
121
+ }
122
+ wordStart(end) {
123
+ let from = end;
124
+ while (from > 0 && isWordCharacter(this.source.charAt(from - 1))) {
125
+ from--;
126
+ }
127
+ return from;
128
+ }
129
+ /* The start of the string, template or regex literal that ends at `end`. */
130
+ literalStart(end) {
131
+ const closing = this.source.context(end - 1);
132
+ if (closing.mode === 'code' && !insideTemplate(closing)) {
133
+ return null;
134
+ }
135
+ for (let at = end - 2; at >= 0 && end - at <= LITERAL_LIMIT; at--) {
136
+ const context = this.source.context(at);
137
+ if (context.mode === 'code' && !insideTemplate(context)) {
138
+ return at;
139
+ }
140
+ }
141
+ return null;
142
+ }
143
+ /* The word, group or literal that ends at `end`. A block, the parenthesis of a control statement and a stray character are none. */
144
+ atom(end) {
145
+ const { source } = this;
146
+ if (end <= 0) {
147
+ return null;
148
+ }
149
+ const char = source.charAt(end - 1);
150
+ if (isWordCharacter(char)) {
151
+ const from = this.wordStart(end);
152
+ const word = source.slice(from, end);
153
+ if (from > 0 && source.charAt(from - 1) === '/' && source.context(from - 1).mode === 'regex' && /^[a-z]+$/i.test(word)) {
154
+ const start = this.literalStart(from);
155
+ return start === null ? null : { from: start };
156
+ }
157
+ return { from, word };
158
+ }
159
+ if (char === '"' || char === "'" || char === '`' || char === '/') {
160
+ const start = this.literalStart(end);
161
+ return start === null ? null : { from: start };
162
+ }
163
+ if (char === ')' || char === ']' || char === '}') {
164
+ const context = source.context(end - 1);
165
+ const bracket = context.bracket;
166
+ if (context.mode !== 'code' || bracket === undefined || bracket.close !== char || bracket.control === true || bracket.template === true) {
167
+ return null;
168
+ }
169
+ return char === '}' && bracket.block === true ? null : { from: bracket.at, group: source.charAt(bracket.at) };
170
+ }
171
+ return null;
172
+ }
173
+ reserved(atom) {
174
+ return atom.word !== undefined && (KEYWORDS.has(atom.word) || UNARY_WORDS.has(atom.word) || this.words.has(atom.word));
175
+ }
176
+ /* An atom with the calls and indexes right behind it, down to its callee. */
177
+ unit(end, property = false) {
178
+ const { source } = this;
179
+ let at = end;
180
+ // The non-null assertion of TypeScript, as long as it is not the start of `!=`.
181
+ while (this.typescript && source.charAt(at - 1) === '!' && source.charAt(at) !== '=' && /[\p{L}\p{N}_$)\]]/u.test(source.charAt(at - 2))) {
182
+ at--;
183
+ }
184
+ let atom = this.atom(at);
185
+ // A keyword is a fine property name.
186
+ if (atom === null || (!property && this.reserved(atom))) {
187
+ return null;
188
+ }
189
+ let start = atom.from;
190
+ while (atom.group === '(' || atom.group === '[') {
191
+ const callee = this.atom(start);
192
+ if (callee === null || this.reserved(callee)) {
193
+ break;
194
+ }
195
+ start = callee.from;
196
+ atom = callee;
197
+ }
198
+ return start;
199
+ }
200
+ /* The length of the member operator that ends at `end`. */
201
+ member(end) {
202
+ const text = this.source.slice(Math.max(0, end - 3), end);
203
+ if (this.php) {
204
+ if (text.endsWith('?->')) {
205
+ return 3;
206
+ }
207
+ return text.endsWith('->') || text.endsWith('::') ? 2 : Number(text.endsWith('\\'));
208
+ }
209
+ if (text === '...') {
210
+ return 0;
211
+ }
212
+ return text.endsWith('?.') ? 2 : Number(text.endsWith('.'));
213
+ }
214
+ /* The start of the chain of members, calls and indexes that ends at `end`. */
215
+ chain(end) {
216
+ const first = this.unit(end);
217
+ if (first === null) {
218
+ return null;
219
+ }
220
+ let start = first;
221
+ for (;;) {
222
+ const before = this.skip(start);
223
+ const length = this.member(before);
224
+ if (length === 0) {
225
+ break;
226
+ }
227
+ // A namespace separator sits between its names without whitespace.
228
+ const separator = this.php && this.source.charAt(before - 1) === '\\';
229
+ const next = separator && before !== start ? null : this.unit(separator ? before - length : this.skip(before - length), true);
230
+ if (next === null) {
231
+ // A namespace may start with a backslash of its own.
232
+ if (this.source.charAt(before - 1) === '\\') {
233
+ start = before - 1;
234
+ }
235
+ break;
236
+ }
237
+ start = next;
238
+ }
239
+ const before = this.skip(start);
240
+ const word = this.atom(before)?.word;
241
+ return word === 'new' ? before - 3 : start;
242
+ }
243
+ /* Whether the `.` after the operand is the decimal point of a number. */
244
+ decimalPoint(dot) {
245
+ const last = this.atom(dot);
246
+ return !this.php && last?.word !== undefined && /^\d/.test(last.word) && this.source.charAt(last.from - 1) !== '.';
247
+ }
248
+ operandEnd(end) {
249
+ const atom = this.atom(end);
250
+ return atom !== null && !this.reserved(atom);
251
+ }
252
+ /* The start of the prefix operator right before `end`, or -1. */
253
+ unaryBefore(end) {
254
+ const { source } = this;
255
+ const char = source.charAt(end - 1);
256
+ if (char === '!' || char === '~' || (this.php && char === '@')) {
257
+ return end - 1;
258
+ }
259
+ if (char === '-' || char === '+') {
260
+ const start = source.charAt(end - 2) === char ? end - 2 : end - 1;
261
+ return this.operandEnd(this.skip(start)) ? -1 : start;
262
+ }
263
+ const word = this.atom(end);
264
+ if (word?.word !== undefined) {
265
+ return UNARY_WORDS.has(word.word) ? word.from : -1;
266
+ }
267
+ if (this.php && word?.group === '(' && CAST.test(this.source.slice(word.from + 1, end - 1))) {
268
+ return word.from;
269
+ }
270
+ return -1;
271
+ }
272
+ /* Moves left over the prefix operators of an operand, reporting each start. */
273
+ unaries(start, report) {
274
+ let from = start;
275
+ for (;;) {
276
+ const found = this.unaryBefore(this.skip(from));
277
+ if (found < 0) {
278
+ return from;
279
+ }
280
+ from = found;
281
+ report?.(from);
282
+ }
283
+ }
284
+ operand(end) {
285
+ const start = this.chain(end);
286
+ return start === null ? null : this.unaries(start);
287
+ }
288
+ operatorEndingAt(end) {
289
+ const word = this.atom(end);
290
+ if (word?.word !== undefined) {
291
+ const operator = this.words.get(word.word);
292
+ return operator === undefined ? null : { text: word.word, operator };
293
+ }
294
+ for (let length = MAX_OPERATOR_LENGTH; length > 0; length--) {
295
+ const text = this.source.slice(Math.max(0, end - length), end);
296
+ const operator = this.operators.get(text);
297
+ if (text.length === length && operator !== undefined) {
298
+ return { text, operator };
299
+ }
300
+ }
301
+ return null;
302
+ }
303
+ /* The binary operator left of an expression that starts at `from`. */
304
+ operatorBefore(from) {
305
+ const end = this.skip(from);
306
+ const found = end === 0 ? null : this.operatorEndingAt(end);
307
+ if (found === null || (found.operator.kind !== 'binary' && found.operator.kind !== 'colon')) {
308
+ return null;
309
+ }
310
+ return { ...found.operator, start: end - found.text.length };
311
+ }
312
+ /* Whether a line break stands right before `index`, which ends the line of what is left of it. */
313
+ breaksBefore(index) {
314
+ return /[\n\r]/.test(this.source.slice(this.skip(index), index));
315
+ }
316
+ /* The `?` that belongs to the `:` at `colon`, past the ternaries nested between them. */
317
+ questionBefore(colon) {
318
+ let depth = 0;
319
+ let at = colon;
320
+ for (;;) {
321
+ at = this.skip(at);
322
+ if (at === 0) {
323
+ return null;
324
+ }
325
+ const atom = this.atom(at);
326
+ if (atom !== null) {
327
+ if (atom.word !== undefined && KEYWORDS.has(atom.word)) {
328
+ return null;
329
+ }
330
+ at = atom.from;
331
+ continue;
332
+ }
333
+ const found = this.operatorEndingAt(at);
334
+ if (found === null || found.operator.kind === 'stop') {
335
+ return null;
336
+ }
337
+ if (found.operator.kind === 'question') {
338
+ if (depth === 0) {
339
+ return at - 1;
340
+ }
341
+ depth--;
342
+ }
343
+ else if (found.operator.kind === 'colon') {
344
+ depth++;
345
+ }
346
+ at -= found.text.length;
347
+ }
348
+ }
349
+ }
350
+ /* Every expression that ends right before the `.` at `dot`, innermost first; empty where none does. */
351
+ export function postfixOperands(source, dot, language) {
352
+ if (source.charAt(dot) !== '.') {
353
+ return [];
354
+ }
355
+ const place = placeAt(source, dot, language);
356
+ if (place.kind !== 'code' || !(isScript(place.language) || isPhp(place.language))) {
357
+ return [];
358
+ }
359
+ const scanner = new Scanner(source, place.language);
360
+ const end = scanner.skip(dot);
361
+ const start = end === 0 || scanner.decimalPoint(dot) ? null : scanner.chain(end);
362
+ if (start === null) {
363
+ return [];
364
+ }
365
+ const found = [start];
366
+ const report = (from) => {
367
+ if (from < found.at(-1)) {
368
+ found.push(from);
369
+ }
370
+ };
371
+ let from = scanner.unaries(start, report);
372
+ let minimum = Infinity;
373
+ for (;;) {
374
+ const operator = scanner.operatorBefore(from);
375
+ if (operator === null) {
376
+ break;
377
+ }
378
+ const operatorStart = operator.kind === 'colon' ? scanner.questionBefore(operator.start) : operator.start;
379
+ // A line has to end in the operator for the expression to go on over it.
380
+ if (operatorStart === null || scanner.breaksBefore(operatorStart)) {
381
+ break;
382
+ }
383
+ const left = scanner.operand(scanner.skip(operatorStart));
384
+ if (left === null) {
385
+ break;
386
+ }
387
+ from = left;
388
+ minimum = Math.min(minimum, operator.precedence);
389
+ // The expression is a subtree only when the operator on its left binds looser than everything inside it.
390
+ const next = scanner.operatorBefore(from);
391
+ if (next === null || scanner.breaksBefore(next.start) || next.precedence < minimum || (next.right && next.precedence <= minimum)) {
392
+ report(from);
393
+ }
394
+ }
395
+ const widest = found.length - 1;
396
+ return found.map((operandFrom, index) => ({ from: operandFrom, statement: index === widest && atStatementStart(source, operandFrom, language) }));
397
+ }
@@ -0,0 +1,9 @@
1
+ import type { EditSource } from './edit-source.ts';
2
+ export interface SurroundShape {
3
+ readonly from: number;
4
+ readonly to: number;
5
+ readonly statements: boolean;
6
+ readonly expression: boolean;
7
+ }
8
+ export declare function atStatementStart(source: EditSource, offset: number, language: string): boolean;
9
+ export declare function surroundShape(source: EditSource, from: number, to: number, language: string): SurroundShape | null;
@@ -0,0 +1,127 @@
1
+ import { lineContinues } from './enter.js';
2
+ import { skipTriviaBack } from './trivia.js';
3
+ const KEYWORDS = new Set([
4
+ 'if',
5
+ 'else',
6
+ 'for',
7
+ 'foreach',
8
+ 'while',
9
+ 'do',
10
+ 'try',
11
+ 'catch',
12
+ 'finally',
13
+ 'switch',
14
+ 'case',
15
+ 'default',
16
+ 'return',
17
+ 'throw',
18
+ 'break',
19
+ 'continue',
20
+ 'const',
21
+ 'let',
22
+ 'var',
23
+ 'function',
24
+ 'class',
25
+ 'interface',
26
+ 'enum',
27
+ 'import',
28
+ 'export',
29
+ 'echo',
30
+ 'use',
31
+ 'namespace',
32
+ 'abstract',
33
+ 'final'
34
+ ]);
35
+ /* Whether a statement can start at the offset: after `;`, a block, a control statement's head, `else` or `do`, or on a line the one above leaves closed. */
36
+ export function atStatementStart(source, offset, language) {
37
+ const context = source.context(offset);
38
+ const bracket = context.bracket;
39
+ // Inside parentheses, brackets or an object literal a new line starts an element, not a statement.
40
+ if (context.mode !== 'code' || (bracket !== undefined && !(bracket.close === '}' && bracket.block === true && bracket.template !== true))) {
41
+ return false;
42
+ }
43
+ if (context.word === 'control-close' || context.word === 'else' || context.word === 'do') {
44
+ return true;
45
+ }
46
+ const before = skipTriviaBack(source, offset);
47
+ if (before === 0) {
48
+ return true;
49
+ }
50
+ const last = source.charAt(before - 1);
51
+ if (last === ';' || (last === '{' && bracket?.at === before - 1)) {
52
+ return true;
53
+ }
54
+ if (last === '}') {
55
+ const opener = source.context(before - 1).bracket;
56
+ if (opener?.block === true && opener.template !== true) {
57
+ return true;
58
+ }
59
+ }
60
+ const line = source.line(source.lineAt(offset));
61
+ return source.slice(line.start, offset).trim() === '' && (line.start === 0 || !lineContinues(source, language, source.lineAt(offset) - 1));
62
+ }
63
+ /* Whether a statement ends at the offset: after `;` or `}`, or at the end of a line the statement does not go on after. */
64
+ function atStatementEnd(source, offset, language) {
65
+ const last = source.charAt(offset - 1);
66
+ if (last === ';' || last === '}') {
67
+ return true;
68
+ }
69
+ const index = source.lineAt(offset);
70
+ return source.slice(offset, source.line(index).end).trim() === '' && !lineContinues(source, language, index);
71
+ }
72
+ /* Whether the range holds a `;` or a block at its own depth, which no expression does. */
73
+ function holdsStatements(source, from, to) {
74
+ const bracket = source.context(from).bracket;
75
+ for (const match of source.slice(from, to).matchAll(/[;{]/g)) {
76
+ const at = from + match.index;
77
+ if (source.context(at).bracket?.at !== bracket?.at || source.context(at).mode !== 'code') {
78
+ continue;
79
+ }
80
+ if (match[0] === ';' || source.context(at + 1).bracket?.block === true) {
81
+ return true;
82
+ }
83
+ }
84
+ return false;
85
+ }
86
+ /* The line break of the range's last line, past a trailing line comment, or `to` when something else follows. */
87
+ function widenedEnd(source, to) {
88
+ const line = source.line(source.lineAt(to));
89
+ const rest = source.slice(to, line.end);
90
+ if (rest.trim() === '') {
91
+ return to;
92
+ }
93
+ const context = source.context(line.end);
94
+ if (context.mode !== 'line-comment' || context.commentStart === undefined || context.commentStart < to) {
95
+ return to;
96
+ }
97
+ return source.slice(to, context.commentStart).trim() === '' ? to + rest.trimEnd().length : to;
98
+ }
99
+ /*
100
+ * What a range can be surrounded with: whole statements, one expression, both or neither. Both ends have to be code
101
+ * in the same bracket, and statements also start and end where a statement does.
102
+ */
103
+ export function surroundShape(source, from, to, language) {
104
+ let start = from;
105
+ let end = to;
106
+ while (start < end && /\s/u.test(source.charAt(start))) {
107
+ start++;
108
+ }
109
+ while (end > start && /\s/u.test(source.charAt(end - 1))) {
110
+ end--;
111
+ }
112
+ if (start >= end) {
113
+ return null;
114
+ }
115
+ const first = source.context(start);
116
+ const last = source.context(end);
117
+ // Brackets are compared by position, since a scan makes new ones for the line it starts on.
118
+ if (first.mode !== 'code' || last.mode !== 'code' || first.bracket?.at !== last.bracket?.at) {
119
+ return { from: start, to: end, statements: false, expression: false };
120
+ }
121
+ const statements = atStatementStart(source, start, language) && atStatementEnd(source, end, language);
122
+ const wide = statements ? widenedEnd(source, end) : end;
123
+ const text = source.slice(start, end);
124
+ const word = /^[\p{L}_$][\p{L}\p{N}_$]*/u.exec(text)?.[0] ?? '';
125
+ const expression = wide === end && !/[\n\r]/.test(text) && !KEYWORDS.has(word) && !holdsStatements(source, start, end);
126
+ return { from: start, to: wide, statements, expression };
127
+ }
@@ -0,0 +1,7 @@
1
+ import type { EditSource } from './edit-source.ts';
2
+ export type TextPlaceKind = 'code' | 'comment' | 'string';
3
+ export interface TextPlace {
4
+ readonly language: string;
5
+ readonly kind: TextPlaceKind;
6
+ }
7
+ export declare function placeAt(source: EditSource, offset: number, language: string): TextPlace;