@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.
- package/dist/document.d.ts +8 -0
- package/dist/document.js +20 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +1 -0
- package/dist/postfix-operands.d.ts +6 -0
- package/dist/postfix-operands.js +397 -0
- package/dist/surround-shape.d.ts +9 -0
- package/dist/surround-shape.js +127 -0
- package/dist/text-places.d.ts +7 -0
- package/dist/text-places.js +87 -0
- package/dist/trivia.d.ts +2 -0
- package/dist/trivia.js +19 -0
- package/dist/unwrap.d.ts +10 -0
- package/dist/unwrap.js +320 -0
- package/package.json +1 -1
- package/src/document.ts +24 -0
- package/src/index.ts +5 -0
- package/src/postfix-operands.ts +542 -0
- package/src/surround-shape.ts +172 -0
- package/src/text-places.ts +126 -0
- package/src/trivia.ts +29 -0
- package/src/unwrap.ts +460 -0
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
import type { EditSource } from './edit-source.ts';
|
|
2
|
+
import { isPhp, isScript, isWordCharacter } from './lexical.ts';
|
|
3
|
+
import { atStatementStart } from './surround-shape.ts';
|
|
4
|
+
import { placeAt } from './text-places.ts';
|
|
5
|
+
import { skipTriviaBack } from './trivia.ts';
|
|
6
|
+
import type { TypingContext } from './typing-context.ts';
|
|
7
|
+
|
|
8
|
+
export interface PostfixOperand {
|
|
9
|
+
/* The expression is `[from, dot)`, trailing whitespace left out. */
|
|
10
|
+
readonly from: number;
|
|
11
|
+
/* Whether `from` starts a statement; only the widest operand can be true. */
|
|
12
|
+
readonly statement: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type OperatorKind = 'binary' | 'stop' | 'question' | 'colon';
|
|
16
|
+
|
|
17
|
+
interface Operator {
|
|
18
|
+
readonly kind: OperatorKind;
|
|
19
|
+
readonly precedence: number;
|
|
20
|
+
readonly right: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface Atom {
|
|
24
|
+
readonly from: number;
|
|
25
|
+
/* The text of a word, a number or a variable. */
|
|
26
|
+
readonly word?: string;
|
|
27
|
+
/* The opening character of a bracket group. */
|
|
28
|
+
readonly group?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface FoundOperator extends Operator {
|
|
32
|
+
readonly start: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const TERNARY = 2;
|
|
36
|
+
const LITERAL_LIMIT = 10_000;
|
|
37
|
+
const MAX_OPERATOR_LENGTH = 4;
|
|
38
|
+
|
|
39
|
+
const binary = (precedence: number, right = false): Operator => ({ kind: 'binary', precedence, right });
|
|
40
|
+
const stop: Operator = { kind: 'stop', precedence: 0, right: false };
|
|
41
|
+
|
|
42
|
+
const SCRIPT_OPERATORS = new Map<string, Operator>([
|
|
43
|
+
['**', binary(15, true)],
|
|
44
|
+
['*', binary(14)],
|
|
45
|
+
['/', binary(14)],
|
|
46
|
+
['%', binary(14)],
|
|
47
|
+
['+', binary(13)],
|
|
48
|
+
['-', binary(13)],
|
|
49
|
+
['<<', binary(12)],
|
|
50
|
+
['>>', binary(12)],
|
|
51
|
+
['>>>', binary(12)],
|
|
52
|
+
['<', binary(10)],
|
|
53
|
+
['>', binary(10)],
|
|
54
|
+
['<=', binary(10)],
|
|
55
|
+
['>=', binary(10)],
|
|
56
|
+
['==', binary(9)],
|
|
57
|
+
['!=', binary(9)],
|
|
58
|
+
['===', binary(9)],
|
|
59
|
+
['!==', binary(9)],
|
|
60
|
+
['&', binary(8)],
|
|
61
|
+
['^', binary(7)],
|
|
62
|
+
['|', binary(6)],
|
|
63
|
+
['&&', binary(5)],
|
|
64
|
+
['||', binary(4)],
|
|
65
|
+
['??', binary(3, true)],
|
|
66
|
+
['?:', binary(TERNARY, true)],
|
|
67
|
+
['?', { kind: 'question', precedence: TERNARY, right: true }],
|
|
68
|
+
[':', { kind: 'colon', precedence: TERNARY, right: true }]
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
// An assignment ends the operand, and they are listed so that `>>=` is not read as `>=`.
|
|
72
|
+
for (const text of ['=', '=>', '+=', '-=', '*=', '/=', '%=', '&=', '|=', '^=', '**=', '<<=', '>>=', '>>>=', '&&=', '||=', '??=', '?.', '++', '--', '::']) {
|
|
73
|
+
SCRIPT_OPERATORS.set(text, stop);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const PHP_OPERATORS = new Map(SCRIPT_OPERATORS);
|
|
77
|
+
|
|
78
|
+
PHP_OPERATORS.set('.', binary(11));
|
|
79
|
+
PHP_OPERATORS.set('.=', stop);
|
|
80
|
+
PHP_OPERATORS.set('<>', binary(10));
|
|
81
|
+
PHP_OPERATORS.set('<=>', binary(9));
|
|
82
|
+
PHP_OPERATORS.set('->', stop);
|
|
83
|
+
PHP_OPERATORS.set('?->', stop);
|
|
84
|
+
|
|
85
|
+
const SCRIPT_WORDS = new Map<string, Operator>([
|
|
86
|
+
['in', binary(10)],
|
|
87
|
+
['instanceof', binary(10)]
|
|
88
|
+
]);
|
|
89
|
+
const PHP_WORDS = new Map<string, Operator>([
|
|
90
|
+
['instanceof', binary(10)],
|
|
91
|
+
['and', binary(1.5)],
|
|
92
|
+
['xor', binary(1.4)],
|
|
93
|
+
['or', binary(1.3)]
|
|
94
|
+
]);
|
|
95
|
+
const UNARY_WORDS = new Set(['typeof', 'void', 'await', 'delete']);
|
|
96
|
+
// Words that start a statement or a clause, so they are no operand and nothing to the left of them belongs to an expression.
|
|
97
|
+
const KEYWORDS = new Set([
|
|
98
|
+
'return',
|
|
99
|
+
'throw',
|
|
100
|
+
'yield',
|
|
101
|
+
'case',
|
|
102
|
+
'default',
|
|
103
|
+
'const',
|
|
104
|
+
'let',
|
|
105
|
+
'var',
|
|
106
|
+
'echo',
|
|
107
|
+
'print',
|
|
108
|
+
'if',
|
|
109
|
+
'else',
|
|
110
|
+
'elseif',
|
|
111
|
+
'for',
|
|
112
|
+
'foreach',
|
|
113
|
+
'while',
|
|
114
|
+
'do',
|
|
115
|
+
'switch',
|
|
116
|
+
'try',
|
|
117
|
+
'catch',
|
|
118
|
+
'finally',
|
|
119
|
+
'function',
|
|
120
|
+
'class',
|
|
121
|
+
'import',
|
|
122
|
+
'export',
|
|
123
|
+
'of',
|
|
124
|
+
'as',
|
|
125
|
+
'use',
|
|
126
|
+
'new',
|
|
127
|
+
'extends',
|
|
128
|
+
'implements',
|
|
129
|
+
'global',
|
|
130
|
+
'include',
|
|
131
|
+
'require'
|
|
132
|
+
]);
|
|
133
|
+
const CAST = /^\s*(?:int|integer|bool|boolean|float|double|string|array|object|binary|unset)\s*$/i;
|
|
134
|
+
|
|
135
|
+
function insideTemplate(context: TypingContext): boolean {
|
|
136
|
+
for (let bracket = context.bracket; bracket !== undefined; bracket = bracket.previous) {
|
|
137
|
+
if (bracket.template === true) {
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
class Scanner {
|
|
146
|
+
private readonly source: EditSource;
|
|
147
|
+
private readonly php: boolean;
|
|
148
|
+
private readonly typescript: boolean;
|
|
149
|
+
private readonly operators: ReadonlyMap<string, Operator>;
|
|
150
|
+
private readonly words: ReadonlyMap<string, Operator>;
|
|
151
|
+
|
|
152
|
+
constructor(source: EditSource, place: string) {
|
|
153
|
+
this.source = source;
|
|
154
|
+
this.php = isPhp(place);
|
|
155
|
+
this.typescript = /^(?:typescript|ts|tsx|typescriptreact|mts|cts)$/i.test(place);
|
|
156
|
+
this.operators = this.php ? PHP_OPERATORS : SCRIPT_OPERATORS;
|
|
157
|
+
this.words = this.php ? PHP_WORDS : SCRIPT_WORDS;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
skip(end: number): number {
|
|
161
|
+
return skipTriviaBack(this.source, end);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private wordStart(end: number): number {
|
|
165
|
+
let from = end;
|
|
166
|
+
|
|
167
|
+
while (from > 0 && isWordCharacter(this.source.charAt(from - 1))) {
|
|
168
|
+
from--;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return from;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/* The start of the string, template or regex literal that ends at `end`. */
|
|
175
|
+
private literalStart(end: number): number | null {
|
|
176
|
+
const closing = this.source.context(end - 1);
|
|
177
|
+
|
|
178
|
+
if (closing.mode === 'code' && !insideTemplate(closing)) {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
for (let at = end - 2; at >= 0 && end - at <= LITERAL_LIMIT; at--) {
|
|
183
|
+
const context = this.source.context(at);
|
|
184
|
+
|
|
185
|
+
if (context.mode === 'code' && !insideTemplate(context)) {
|
|
186
|
+
return at;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/* The word, group or literal that ends at `end`. A block, the parenthesis of a control statement and a stray character are none. */
|
|
194
|
+
atom(end: number): Atom | null {
|
|
195
|
+
const { source } = this;
|
|
196
|
+
|
|
197
|
+
if (end <= 0) {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const char = source.charAt(end - 1);
|
|
202
|
+
|
|
203
|
+
if (isWordCharacter(char)) {
|
|
204
|
+
const from = this.wordStart(end);
|
|
205
|
+
const word = source.slice(from, end);
|
|
206
|
+
|
|
207
|
+
if (from > 0 && source.charAt(from - 1) === '/' && source.context(from - 1).mode === 'regex' && /^[a-z]+$/i.test(word)) {
|
|
208
|
+
const start = this.literalStart(from);
|
|
209
|
+
|
|
210
|
+
return start === null ? null : { from: start };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return { from, word };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (char === '"' || char === "'" || char === '`' || char === '/') {
|
|
217
|
+
const start = this.literalStart(end);
|
|
218
|
+
|
|
219
|
+
return start === null ? null : { from: start };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (char === ')' || char === ']' || char === '}') {
|
|
223
|
+
const context = source.context(end - 1);
|
|
224
|
+
const bracket = context.bracket;
|
|
225
|
+
|
|
226
|
+
if (context.mode !== 'code' || bracket === undefined || bracket.close !== char || bracket.control === true || bracket.template === true) {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return char === '}' && bracket.block === true ? null : { from: bracket.at, group: source.charAt(bracket.at) };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private reserved(atom: Atom): boolean {
|
|
237
|
+
return atom.word !== undefined && (KEYWORDS.has(atom.word) || UNARY_WORDS.has(atom.word) || this.words.has(atom.word));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/* An atom with the calls and indexes right behind it, down to its callee. */
|
|
241
|
+
private unit(end: number, property = false): number | null {
|
|
242
|
+
const { source } = this;
|
|
243
|
+
let at = end;
|
|
244
|
+
|
|
245
|
+
// The non-null assertion of TypeScript, as long as it is not the start of `!=`.
|
|
246
|
+
while (this.typescript && source.charAt(at - 1) === '!' && source.charAt(at) !== '=' && /[\p{L}\p{N}_$)\]]/u.test(source.charAt(at - 2))) {
|
|
247
|
+
at--;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
let atom = this.atom(at);
|
|
251
|
+
|
|
252
|
+
// A keyword is a fine property name.
|
|
253
|
+
if (atom === null || (!property && this.reserved(atom))) {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
let start = atom.from;
|
|
258
|
+
|
|
259
|
+
while (atom.group === '(' || atom.group === '[') {
|
|
260
|
+
const callee = this.atom(start);
|
|
261
|
+
|
|
262
|
+
if (callee === null || this.reserved(callee)) {
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
start = callee.from;
|
|
267
|
+
atom = callee;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return start;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/* The length of the member operator that ends at `end`. */
|
|
274
|
+
private member(end: number): number {
|
|
275
|
+
const text = this.source.slice(Math.max(0, end - 3), end);
|
|
276
|
+
|
|
277
|
+
if (this.php) {
|
|
278
|
+
if (text.endsWith('?->')) {
|
|
279
|
+
return 3;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return text.endsWith('->') || text.endsWith('::') ? 2 : Number(text.endsWith('\\'));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (text === '...') {
|
|
286
|
+
return 0;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return text.endsWith('?.') ? 2 : Number(text.endsWith('.'));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/* The start of the chain of members, calls and indexes that ends at `end`. */
|
|
293
|
+
chain(end: number): number | null {
|
|
294
|
+
const first = this.unit(end);
|
|
295
|
+
|
|
296
|
+
if (first === null) {
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
let start = first;
|
|
301
|
+
|
|
302
|
+
for (;;) {
|
|
303
|
+
const before = this.skip(start);
|
|
304
|
+
const length = this.member(before);
|
|
305
|
+
|
|
306
|
+
if (length === 0) {
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// A namespace separator sits between its names without whitespace.
|
|
311
|
+
const separator = this.php && this.source.charAt(before - 1) === '\\';
|
|
312
|
+
const next: number | null = separator && before !== start ? null : this.unit(separator ? before - length : this.skip(before - length), true);
|
|
313
|
+
|
|
314
|
+
if (next === null) {
|
|
315
|
+
// A namespace may start with a backslash of its own.
|
|
316
|
+
if (this.source.charAt(before - 1) === '\\') {
|
|
317
|
+
start = before - 1;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
start = next;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const before = this.skip(start);
|
|
327
|
+
const word = this.atom(before)?.word;
|
|
328
|
+
|
|
329
|
+
return word === 'new' ? before - 3 : start;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/* Whether the `.` after the operand is the decimal point of a number. */
|
|
333
|
+
decimalPoint(dot: number): boolean {
|
|
334
|
+
const last = this.atom(dot);
|
|
335
|
+
|
|
336
|
+
return !this.php && last?.word !== undefined && /^\d/.test(last.word) && this.source.charAt(last.from - 1) !== '.';
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
private operandEnd(end: number): boolean {
|
|
340
|
+
const atom = this.atom(end);
|
|
341
|
+
|
|
342
|
+
return atom !== null && !this.reserved(atom);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/* The start of the prefix operator right before `end`, or -1. */
|
|
346
|
+
private unaryBefore(end: number): number {
|
|
347
|
+
const { source } = this;
|
|
348
|
+
const char = source.charAt(end - 1);
|
|
349
|
+
|
|
350
|
+
if (char === '!' || char === '~' || (this.php && char === '@')) {
|
|
351
|
+
return end - 1;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (char === '-' || char === '+') {
|
|
355
|
+
const start = source.charAt(end - 2) === char ? end - 2 : end - 1;
|
|
356
|
+
|
|
357
|
+
return this.operandEnd(this.skip(start)) ? -1 : start;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const word = this.atom(end);
|
|
361
|
+
|
|
362
|
+
if (word?.word !== undefined) {
|
|
363
|
+
return UNARY_WORDS.has(word.word) ? word.from : -1;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (this.php && word?.group === '(' && CAST.test(this.source.slice(word.from + 1, end - 1))) {
|
|
367
|
+
return word.from;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return -1;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/* Moves left over the prefix operators of an operand, reporting each start. */
|
|
374
|
+
unaries(start: number, report?: (from: number) => void): number {
|
|
375
|
+
let from = start;
|
|
376
|
+
|
|
377
|
+
for (;;) {
|
|
378
|
+
const found = this.unaryBefore(this.skip(from));
|
|
379
|
+
|
|
380
|
+
if (found < 0) {
|
|
381
|
+
return from;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
from = found;
|
|
385
|
+
report?.(from);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
operand(end: number): number | null {
|
|
390
|
+
const start = this.chain(end);
|
|
391
|
+
|
|
392
|
+
return start === null ? null : this.unaries(start);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
private operatorEndingAt(end: number): { text: string; operator: Operator } | null {
|
|
396
|
+
const word = this.atom(end);
|
|
397
|
+
|
|
398
|
+
if (word?.word !== undefined) {
|
|
399
|
+
const operator = this.words.get(word.word);
|
|
400
|
+
|
|
401
|
+
return operator === undefined ? null : { text: word.word, operator };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
for (let length = MAX_OPERATOR_LENGTH; length > 0; length--) {
|
|
405
|
+
const text = this.source.slice(Math.max(0, end - length), end);
|
|
406
|
+
const operator = this.operators.get(text);
|
|
407
|
+
|
|
408
|
+
if (text.length === length && operator !== undefined) {
|
|
409
|
+
return { text, operator };
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return null;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/* The binary operator left of an expression that starts at `from`. */
|
|
417
|
+
operatorBefore(from: number): FoundOperator | null {
|
|
418
|
+
const end = this.skip(from);
|
|
419
|
+
const found = end === 0 ? null : this.operatorEndingAt(end);
|
|
420
|
+
|
|
421
|
+
if (found === null || (found.operator.kind !== 'binary' && found.operator.kind !== 'colon')) {
|
|
422
|
+
return null;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return { ...found.operator, start: end - found.text.length };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/* Whether a line break stands right before `index`, which ends the line of what is left of it. */
|
|
429
|
+
breaksBefore(index: number): boolean {
|
|
430
|
+
return /[\n\r]/.test(this.source.slice(this.skip(index), index));
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/* The `?` that belongs to the `:` at `colon`, past the ternaries nested between them. */
|
|
434
|
+
questionBefore(colon: number): number | null {
|
|
435
|
+
let depth = 0;
|
|
436
|
+
let at = colon;
|
|
437
|
+
|
|
438
|
+
for (;;) {
|
|
439
|
+
at = this.skip(at);
|
|
440
|
+
|
|
441
|
+
if (at === 0) {
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const atom = this.atom(at);
|
|
446
|
+
|
|
447
|
+
if (atom !== null) {
|
|
448
|
+
if (atom.word !== undefined && KEYWORDS.has(atom.word)) {
|
|
449
|
+
return null;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
at = atom.from;
|
|
453
|
+
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const found = this.operatorEndingAt(at);
|
|
458
|
+
|
|
459
|
+
if (found === null || found.operator.kind === 'stop') {
|
|
460
|
+
return null;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (found.operator.kind === 'question') {
|
|
464
|
+
if (depth === 0) {
|
|
465
|
+
return at - 1;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
depth--;
|
|
469
|
+
} else if (found.operator.kind === 'colon') {
|
|
470
|
+
depth++;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
at -= found.text.length;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/* Every expression that ends right before the `.` at `dot`, innermost first; empty where none does. */
|
|
479
|
+
export function postfixOperands(source: EditSource, dot: number, language: string): PostfixOperand[] {
|
|
480
|
+
if (source.charAt(dot) !== '.') {
|
|
481
|
+
return [];
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const place = placeAt(source, dot, language);
|
|
485
|
+
|
|
486
|
+
if (place.kind !== 'code' || !(isScript(place.language) || isPhp(place.language))) {
|
|
487
|
+
return [];
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const scanner = new Scanner(source, place.language);
|
|
491
|
+
const end = scanner.skip(dot);
|
|
492
|
+
const start = end === 0 || scanner.decimalPoint(dot) ? null : scanner.chain(end);
|
|
493
|
+
|
|
494
|
+
if (start === null) {
|
|
495
|
+
return [];
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const found = [start];
|
|
499
|
+
const report = (from: number): void => {
|
|
500
|
+
if (from < found.at(-1)!) {
|
|
501
|
+
found.push(from);
|
|
502
|
+
}
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
let from = scanner.unaries(start, report);
|
|
506
|
+
let minimum = Infinity;
|
|
507
|
+
|
|
508
|
+
for (;;) {
|
|
509
|
+
const operator = scanner.operatorBefore(from);
|
|
510
|
+
|
|
511
|
+
if (operator === null) {
|
|
512
|
+
break;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const operatorStart = operator.kind === 'colon' ? scanner.questionBefore(operator.start) : operator.start;
|
|
516
|
+
|
|
517
|
+
// A line has to end in the operator for the expression to go on over it.
|
|
518
|
+
if (operatorStart === null || scanner.breaksBefore(operatorStart)) {
|
|
519
|
+
break;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const left = scanner.operand(scanner.skip(operatorStart));
|
|
523
|
+
|
|
524
|
+
if (left === null) {
|
|
525
|
+
break;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
from = left;
|
|
529
|
+
minimum = Math.min(minimum, operator.precedence);
|
|
530
|
+
|
|
531
|
+
// The expression is a subtree only when the operator on its left binds looser than everything inside it.
|
|
532
|
+
const next = scanner.operatorBefore(from);
|
|
533
|
+
|
|
534
|
+
if (next === null || scanner.breaksBefore(next.start) || next.precedence < minimum || (next.right && next.precedence <= minimum)) {
|
|
535
|
+
report(from);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const widest = found.length - 1;
|
|
540
|
+
|
|
541
|
+
return found.map((operandFrom, index) => ({ from: operandFrom, statement: index === widest && atStatementStart(source, operandFrom, language) }));
|
|
542
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import type { EditSource } from './edit-source.ts';
|
|
2
|
+
import { lineContinues } from './enter.ts';
|
|
3
|
+
import { skipTriviaBack } from './trivia.ts';
|
|
4
|
+
|
|
5
|
+
export interface SurroundShape {
|
|
6
|
+
/* The range after trimming whitespace. */
|
|
7
|
+
readonly from: number;
|
|
8
|
+
readonly to: number;
|
|
9
|
+
/* Whole statements: statement surrounders apply. */
|
|
10
|
+
readonly statements: boolean;
|
|
11
|
+
/* One expression: `(expr)` and `!(expr)` apply. */
|
|
12
|
+
readonly expression: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const KEYWORDS = new Set([
|
|
16
|
+
'if',
|
|
17
|
+
'else',
|
|
18
|
+
'for',
|
|
19
|
+
'foreach',
|
|
20
|
+
'while',
|
|
21
|
+
'do',
|
|
22
|
+
'try',
|
|
23
|
+
'catch',
|
|
24
|
+
'finally',
|
|
25
|
+
'switch',
|
|
26
|
+
'case',
|
|
27
|
+
'default',
|
|
28
|
+
'return',
|
|
29
|
+
'throw',
|
|
30
|
+
'break',
|
|
31
|
+
'continue',
|
|
32
|
+
'const',
|
|
33
|
+
'let',
|
|
34
|
+
'var',
|
|
35
|
+
'function',
|
|
36
|
+
'class',
|
|
37
|
+
'interface',
|
|
38
|
+
'enum',
|
|
39
|
+
'import',
|
|
40
|
+
'export',
|
|
41
|
+
'echo',
|
|
42
|
+
'use',
|
|
43
|
+
'namespace',
|
|
44
|
+
'abstract',
|
|
45
|
+
'final'
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
/* 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. */
|
|
49
|
+
export function atStatementStart(source: EditSource, offset: number, language: string): boolean {
|
|
50
|
+
const context = source.context(offset);
|
|
51
|
+
const bracket = context.bracket;
|
|
52
|
+
|
|
53
|
+
// Inside parentheses, brackets or an object literal a new line starts an element, not a statement.
|
|
54
|
+
if (context.mode !== 'code' || (bracket !== undefined && !(bracket.close === '}' && bracket.block === true && bracket.template !== true))) {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (context.word === 'control-close' || context.word === 'else' || context.word === 'do') {
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const before = skipTriviaBack(source, offset);
|
|
63
|
+
|
|
64
|
+
if (before === 0) {
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const last = source.charAt(before - 1);
|
|
69
|
+
|
|
70
|
+
if (last === ';' || (last === '{' && bracket?.at === before - 1)) {
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (last === '}') {
|
|
75
|
+
const opener = source.context(before - 1).bracket;
|
|
76
|
+
|
|
77
|
+
if (opener?.block === true && opener.template !== true) {
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const line = source.line(source.lineAt(offset));
|
|
83
|
+
|
|
84
|
+
return source.slice(line.start, offset).trim() === '' && (line.start === 0 || !lineContinues(source, language, source.lineAt(offset) - 1));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/* Whether a statement ends at the offset: after `;` or `}`, or at the end of a line the statement does not go on after. */
|
|
88
|
+
function atStatementEnd(source: EditSource, offset: number, language: string): boolean {
|
|
89
|
+
const last = source.charAt(offset - 1);
|
|
90
|
+
|
|
91
|
+
if (last === ';' || last === '}') {
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const index = source.lineAt(offset);
|
|
96
|
+
|
|
97
|
+
return source.slice(offset, source.line(index).end).trim() === '' && !lineContinues(source, language, index);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/* Whether the range holds a `;` or a block at its own depth, which no expression does. */
|
|
101
|
+
function holdsStatements(source: EditSource, from: number, to: number): boolean {
|
|
102
|
+
const bracket = source.context(from).bracket;
|
|
103
|
+
|
|
104
|
+
for (const match of source.slice(from, to).matchAll(/[;{]/g)) {
|
|
105
|
+
const at = from + match.index;
|
|
106
|
+
|
|
107
|
+
if (source.context(at).bracket?.at !== bracket?.at || source.context(at).mode !== 'code') {
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (match[0] === ';' || source.context(at + 1).bracket?.block === true) {
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/* The line break of the range's last line, past a trailing line comment, or `to` when something else follows. */
|
|
120
|
+
function widenedEnd(source: EditSource, to: number): number {
|
|
121
|
+
const line = source.line(source.lineAt(to));
|
|
122
|
+
const rest = source.slice(to, line.end);
|
|
123
|
+
|
|
124
|
+
if (rest.trim() === '') {
|
|
125
|
+
return to;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const context = source.context(line.end);
|
|
129
|
+
|
|
130
|
+
if (context.mode !== 'line-comment' || context.commentStart === undefined || context.commentStart < to) {
|
|
131
|
+
return to;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return source.slice(to, context.commentStart).trim() === '' ? to + rest.trimEnd().length : to;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/*
|
|
138
|
+
* What a range can be surrounded with: whole statements, one expression, both or neither. Both ends have to be code
|
|
139
|
+
* in the same bracket, and statements also start and end where a statement does.
|
|
140
|
+
*/
|
|
141
|
+
export function surroundShape(source: EditSource, from: number, to: number, language: string): SurroundShape | null {
|
|
142
|
+
let start = from;
|
|
143
|
+
let end = to;
|
|
144
|
+
|
|
145
|
+
while (start < end && /\s/u.test(source.charAt(start))) {
|
|
146
|
+
start++;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
while (end > start && /\s/u.test(source.charAt(end - 1))) {
|
|
150
|
+
end--;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (start >= end) {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const first = source.context(start);
|
|
158
|
+
const last = source.context(end);
|
|
159
|
+
|
|
160
|
+
// Brackets are compared by position, since a scan makes new ones for the line it starts on.
|
|
161
|
+
if (first.mode !== 'code' || last.mode !== 'code' || first.bracket?.at !== last.bracket?.at) {
|
|
162
|
+
return { from: start, to: end, statements: false, expression: false };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const statements = atStatementStart(source, start, language) && atStatementEnd(source, end, language);
|
|
166
|
+
const wide = statements ? widenedEnd(source, end) : end;
|
|
167
|
+
const text = source.slice(start, end);
|
|
168
|
+
const word = /^[\p{L}_$][\p{L}\p{N}_$]*/u.exec(text)?.[0] ?? '';
|
|
169
|
+
const expression = wide === end && !/[\n\r]/.test(text) && !KEYWORDS.has(word) && !holdsStatements(source, start, end);
|
|
170
|
+
|
|
171
|
+
return { from: start, to: wide, statements, expression };
|
|
172
|
+
}
|