@adecore/editor-core 0.43.0 → 0.44.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.
- package/dist/document.d.ts +9 -0
- package/dist/document.js +52 -2
- 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/types.d.ts +1 -0
- package/dist/typing-handlers.d.ts +6 -0
- package/dist/typing-handlers.js +63 -0
- package/dist/unwrap.d.ts +10 -0
- package/dist/unwrap.js +320 -0
- package/package.json +1 -1
- package/src/document.ts +66 -2
- 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/types.ts +2 -0
- package/src/typing-handlers.ts +83 -0
- package/src/unwrap.ts +460 -0
|
@@ -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;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { editRegions } from './edit-regions.js';
|
|
2
|
+
import { vueRegionAt } from './languages.js';
|
|
3
|
+
const SCAN_LIMIT = 2_000_000;
|
|
4
|
+
const JSX_LANGUAGES = /^(?:typescriptreact|javascriptreact|tsx|jsx)$/;
|
|
5
|
+
const VUE_TAG = /^<\/?(?:template|script|style)\b/;
|
|
6
|
+
function kindOf(context) {
|
|
7
|
+
switch (context.mode) {
|
|
8
|
+
case 'line-comment':
|
|
9
|
+
case 'block-comment':
|
|
10
|
+
case 'html-comment':
|
|
11
|
+
return 'comment';
|
|
12
|
+
case 'quote':
|
|
13
|
+
case 'template':
|
|
14
|
+
case 'heredoc':
|
|
15
|
+
case 'regex':
|
|
16
|
+
return 'string';
|
|
17
|
+
case 'html':
|
|
18
|
+
if (context.markup === 'comment') {
|
|
19
|
+
return 'comment';
|
|
20
|
+
}
|
|
21
|
+
return context.markup === 'value' ? 'string' : 'code';
|
|
22
|
+
default:
|
|
23
|
+
return 'code';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/* The language of a `<script>` or `<style>` of a Vue file, read from the `lang` attribute of the tag that opened the region. */
|
|
27
|
+
function vueRegionLanguage(source, line, region) {
|
|
28
|
+
for (let index = line; index >= 0; index--) {
|
|
29
|
+
const text = source.line(index).text;
|
|
30
|
+
if (!text.startsWith(`<${region}`)) {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
const lang = /\blang\s*=\s*["']?([\w-]+)/.exec(text)?.[1]?.toLowerCase();
|
|
34
|
+
if (region === 'style') {
|
|
35
|
+
return lang === 'scss' || lang === 'less' ? lang : 'css';
|
|
36
|
+
}
|
|
37
|
+
return lang === 'ts' || lang === 'tsx' ? 'typescript' : 'javascript';
|
|
38
|
+
}
|
|
39
|
+
return region === 'style' ? 'css' : 'javascript';
|
|
40
|
+
}
|
|
41
|
+
function vueLanguage(source, offset) {
|
|
42
|
+
const line = source.lineAt(offset);
|
|
43
|
+
const region = VUE_TAG.test(source.line(line).text) ? null : vueRegionAt((index) => source.line(index).text, line);
|
|
44
|
+
return region === 'script' || region === 'style' ? vueRegionLanguage(source, line, region) : 'html';
|
|
45
|
+
}
|
|
46
|
+
/* Whether a `<script>` or `<style>` element is open at the offset in a plain markup document. */
|
|
47
|
+
function markupLanguage(source, offset) {
|
|
48
|
+
let open = null;
|
|
49
|
+
for (const match of source.slice(0, offset).matchAll(/<(\/)?(script|style)\b[^>]*?(\/)?>/gi)) {
|
|
50
|
+
open = match[1] === undefined && match[3] === undefined ? match[2].toLowerCase() : null;
|
|
51
|
+
}
|
|
52
|
+
if (open === null) {
|
|
53
|
+
return 'html';
|
|
54
|
+
}
|
|
55
|
+
return open === 'script' ? 'javascript' : 'css';
|
|
56
|
+
}
|
|
57
|
+
function phpLanguage(context) {
|
|
58
|
+
if (context.embedded === 'javascript') {
|
|
59
|
+
return 'javascript';
|
|
60
|
+
}
|
|
61
|
+
if (context.mode !== 'html') {
|
|
62
|
+
return 'php';
|
|
63
|
+
}
|
|
64
|
+
return context.markup === 'raw' && context.element === 'style' ? 'css' : 'html';
|
|
65
|
+
}
|
|
66
|
+
/* What the text at an offset is written in and whether it is code, a comment or a string. */
|
|
67
|
+
export function placeAt(source, offset, language) {
|
|
68
|
+
const id = language.toLowerCase();
|
|
69
|
+
if (source.line(source.lineCount - 1).end > SCAN_LIMIT) {
|
|
70
|
+
return { language, kind: 'code' };
|
|
71
|
+
}
|
|
72
|
+
const context = source.context(offset);
|
|
73
|
+
const kind = kindOf(context);
|
|
74
|
+
if (id === 'php') {
|
|
75
|
+
return { language: phpLanguage(context), kind };
|
|
76
|
+
}
|
|
77
|
+
if (id === 'vue') {
|
|
78
|
+
return { language: vueLanguage(source, offset), kind };
|
|
79
|
+
}
|
|
80
|
+
if (id === 'html' || id === 'xhtml') {
|
|
81
|
+
return { language: markupLanguage(source, offset), kind };
|
|
82
|
+
}
|
|
83
|
+
if (JSX_LANGUAGES.test(id) && editRegions(source, id)(source.lineAt(offset), offset) === 'jsx') {
|
|
84
|
+
return { language: 'html', kind };
|
|
85
|
+
}
|
|
86
|
+
return { language, kind };
|
|
87
|
+
}
|
package/dist/trivia.d.ts
ADDED
package/dist/trivia.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/* The end of the last token before `end`, past the whitespace and the comments between them. */
|
|
2
|
+
export function skipTriviaBack(source, end) {
|
|
3
|
+
let at = end;
|
|
4
|
+
while (at > 0) {
|
|
5
|
+
const char = source.charAt(at - 1);
|
|
6
|
+
if (/\s/u.test(char)) {
|
|
7
|
+
at--;
|
|
8
|
+
continue;
|
|
9
|
+
}
|
|
10
|
+
// The lexer leaves a block comment at its `*`, so the state before the closing `/` is already code.
|
|
11
|
+
const inside = source.context(char === '/' && at > 1 && source.charAt(at - 2) === '*' ? at - 2 : at - 1);
|
|
12
|
+
if ((inside.mode === 'line-comment' || inside.mode === 'block-comment') && inside.commentStart !== undefined && inside.commentStart < at) {
|
|
13
|
+
at = inside.commentStart;
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
break;
|
|
17
|
+
}
|
|
18
|
+
return at;
|
|
19
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { EditSource } from './edit-source.ts';
|
|
2
|
+
import type { TextEdit } from './types.ts';
|
|
2
3
|
export interface HandlerEdit {
|
|
3
4
|
from: number;
|
|
4
5
|
to: number;
|
|
@@ -13,3 +14,8 @@ export declare function surround(selected: string, typed: string): {
|
|
|
13
14
|
text: string;
|
|
14
15
|
inner: string;
|
|
15
16
|
};
|
|
17
|
+
export declare function templateString(source: EditSource, offset: number, language: string): {
|
|
18
|
+
open: number;
|
|
19
|
+
close: number;
|
|
20
|
+
} | null;
|
|
21
|
+
export declare function templateLiteralEdits(source: EditSource, open: number, close: number): TextEdit[];
|
package/dist/typing-handlers.js
CHANGED
|
@@ -123,3 +123,66 @@ export function surround(selected, typed) {
|
|
|
123
123
|
}
|
|
124
124
|
return { text: typed + inner + matchingDelimiter(typed), inner };
|
|
125
125
|
}
|
|
126
|
+
const scriptLanguages = /^(javascript|typescript|jsx|tsx|javascriptreact|typescriptreact|js|ts|mjs|cjs|mts|cts)$/i;
|
|
127
|
+
const moduleSpecifier = /(?:\bfrom|\bimport|\brequire)\s*\(?\s*$/;
|
|
128
|
+
/*
|
|
129
|
+
* The quotes of the string a `${` is typed in when that makes it a template literal: a single- or
|
|
130
|
+
* double-quoted string of a script, closed on its line and not the specifier of a module.
|
|
131
|
+
*/
|
|
132
|
+
export function templateString(source, offset, language) {
|
|
133
|
+
const line = source.lineAt(offset);
|
|
134
|
+
const script = scriptLanguages.test(language) || (/^vue$/i.test(language) && source.region(line) === 'script');
|
|
135
|
+
const { start, end } = source.line(line);
|
|
136
|
+
const before = source.slice(start, offset);
|
|
137
|
+
if (!script || !before.endsWith('$') || escapedIn(before, before.length - 1)) {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
const context = source.context(offset);
|
|
141
|
+
if (context.mode !== 'quote' || context.escaped || (context.quote !== "'" && context.quote !== '"')) {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
let open = before.length - 1;
|
|
145
|
+
while (open >= 0 && (before[open] !== context.quote || escapedIn(before, open))) {
|
|
146
|
+
open--;
|
|
147
|
+
}
|
|
148
|
+
if (open < 0 || moduleSpecifier.test(before.slice(0, open))) {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
const after = source.slice(offset, end);
|
|
152
|
+
for (let at = 0; at < after.length; at++) {
|
|
153
|
+
if (after[at] === '\\') {
|
|
154
|
+
at++;
|
|
155
|
+
}
|
|
156
|
+
else if (after[at] === context.quote) {
|
|
157
|
+
return { open: start + open, close: offset + at };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
function escapedIn(text, index) {
|
|
163
|
+
let slashes = 0;
|
|
164
|
+
while (index - slashes - 1 >= 0 && text[index - slashes - 1] === '\\') {
|
|
165
|
+
slashes++;
|
|
166
|
+
}
|
|
167
|
+
return slashes % 2 === 1;
|
|
168
|
+
}
|
|
169
|
+
/* The edits that make the string between the quotes at `open` and `close` a template literal: its backticks are escaped and its escaped old quotes are not. */
|
|
170
|
+
export function templateLiteralEdits(source, open, close) {
|
|
171
|
+
const quote = source.charAt(open);
|
|
172
|
+
const body = source.slice(open + 1, close);
|
|
173
|
+
const edits = [{ from: open, to: open + 1, text: '`' }];
|
|
174
|
+
for (let index = 0; index < body.length; index++) {
|
|
175
|
+
const at = open + 1 + index;
|
|
176
|
+
if (body[index] === '\\') {
|
|
177
|
+
if (body[index + 1] === quote) {
|
|
178
|
+
edits.push({ from: at, to: at + 1, text: '' });
|
|
179
|
+
}
|
|
180
|
+
index++;
|
|
181
|
+
}
|
|
182
|
+
else if (body[index] === '`') {
|
|
183
|
+
edits.push({ from: at, to: at + 1, text: '\\`' });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
edits.push({ from: close, to: close + 1, text: '`' });
|
|
187
|
+
return edits;
|
|
188
|
+
}
|
package/dist/unwrap.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { OffsetRange } from './document.ts';
|
|
2
|
+
import type { EditSource } from './edit-source.ts';
|
|
3
|
+
import type { TextEdit } from './types.ts';
|
|
4
|
+
export type UnwrapKind = 'if' | 'else' | 'removeElse' | 'for' | 'while' | 'do' | 'try' | 'removeCatch' | 'removeFinally' | 'braces';
|
|
5
|
+
export interface UnwrapTarget {
|
|
6
|
+
readonly kind: UnwrapKind;
|
|
7
|
+
readonly range: OffsetRange;
|
|
8
|
+
readonly edits: readonly TextEdit[];
|
|
9
|
+
}
|
|
10
|
+
export declare function unwrapTargets(source: EditSource, offset: number, language: string, indentUnit: string): UnwrapTarget[];
|
package/dist/unwrap.js
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { isPhp, isScript, isWordCharacter } from './lexical.js';
|
|
2
|
+
import { whitespaceOf } from './line-text.js';
|
|
3
|
+
import { enclosingBraces } from './statements.js';
|
|
4
|
+
import { placeAt } from './text-places.js';
|
|
5
|
+
import { skipTriviaBack } from './trivia.js';
|
|
6
|
+
const SCAN_LIMIT = 2_000_000;
|
|
7
|
+
const LOOPS = new Set(['for', 'foreach']);
|
|
8
|
+
function wordBefore(source, end) {
|
|
9
|
+
const to = skipTriviaBack(source, end);
|
|
10
|
+
let from = to;
|
|
11
|
+
while (from > 0 && isWordCharacter(source.charAt(from - 1))) {
|
|
12
|
+
from--;
|
|
13
|
+
}
|
|
14
|
+
return from === to ? null : { text: source.slice(from, to), from };
|
|
15
|
+
}
|
|
16
|
+
function skipSpaces(source, at) {
|
|
17
|
+
let next = at;
|
|
18
|
+
while (/\s/u.test(source.charAt(next))) {
|
|
19
|
+
next++;
|
|
20
|
+
}
|
|
21
|
+
return next;
|
|
22
|
+
}
|
|
23
|
+
function wordAt(source, at) {
|
|
24
|
+
let end = at;
|
|
25
|
+
while (end < at + 12 && isWordCharacter(source.charAt(end))) {
|
|
26
|
+
end++;
|
|
27
|
+
}
|
|
28
|
+
return source.slice(at, end);
|
|
29
|
+
}
|
|
30
|
+
/* The closer of the bracket at `open`. */
|
|
31
|
+
function closerOf(source, open) {
|
|
32
|
+
const closer = source.charAt(open) === '(' ? ')' : '}';
|
|
33
|
+
const text = source.slice(open + 1, Math.min(source.line(source.lineCount - 1).end, open + 1 + SCAN_LIMIT));
|
|
34
|
+
for (let index = text.indexOf(closer); index >= 0; index = text.indexOf(closer, index + 1)) {
|
|
35
|
+
const context = source.context(open + 1 + index);
|
|
36
|
+
if (context.mode === 'code' && context.bracket?.at === open) {
|
|
37
|
+
return open + 1 + index;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
/* The header of the block that opens at `open`, or null for a function, a class, an object and the like. */
|
|
43
|
+
function headerOf(source, open) {
|
|
44
|
+
const before = skipTriviaBack(source, open);
|
|
45
|
+
const last = source.charAt(before - 1);
|
|
46
|
+
if (last === ')') {
|
|
47
|
+
const paren = source.context(before - 1).bracket;
|
|
48
|
+
// The lexer flags the parenthesis of `foreach` and `elseif` as plain, so the word decides.
|
|
49
|
+
const keyword = paren === undefined ? null : wordBefore(source, paren.at);
|
|
50
|
+
if (keyword === null) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
if (LOOPS.has(keyword.text) || keyword.text === 'await') {
|
|
54
|
+
const loop = keyword.text === 'await' ? wordBefore(source, keyword.from) : keyword;
|
|
55
|
+
return loop !== null && LOOPS.has(loop.text) ? { word: 'for', start: loop.from } : null;
|
|
56
|
+
}
|
|
57
|
+
if (keyword.text === 'elseif') {
|
|
58
|
+
return { word: 'if', start: keyword.from, elseStart: keyword.from };
|
|
59
|
+
}
|
|
60
|
+
if (keyword.text === 'if') {
|
|
61
|
+
const before = wordBefore(source, keyword.from);
|
|
62
|
+
return { word: 'if', start: keyword.from, elseStart: before?.text === 'else' ? before.from : undefined };
|
|
63
|
+
}
|
|
64
|
+
if (keyword.text === 'while') {
|
|
65
|
+
return { word: 'while', start: keyword.from };
|
|
66
|
+
}
|
|
67
|
+
return keyword.text === 'catch' ? { word: 'catch', start: keyword.from } : null;
|
|
68
|
+
}
|
|
69
|
+
const word = wordBefore(source, open);
|
|
70
|
+
if (word !== null) {
|
|
71
|
+
return word.text === 'else' || word.text === 'try' || word.text === 'finally' || word.text === 'do' || word.text === 'catch'
|
|
72
|
+
? { word: word.text, start: word.from }
|
|
73
|
+
: null;
|
|
74
|
+
}
|
|
75
|
+
// The lexer takes a brace after `;` or `{` for an object, so only the text before it says that it is a block.
|
|
76
|
+
const inBlock = last === ';' || last === '{' || before === 0 || (last === '}' && source.context(before - 1).bracket?.block === true);
|
|
77
|
+
return inBlock ? { word: 'braces', start: open } : null;
|
|
78
|
+
}
|
|
79
|
+
/* The block that ends right before `end`, past whitespace and comments. */
|
|
80
|
+
function blockEndingAt(source, end) {
|
|
81
|
+
const close = skipTriviaBack(source, end) - 1;
|
|
82
|
+
const open = close >= 0 && source.charAt(close) === '}' ? source.context(close).bracket : undefined;
|
|
83
|
+
if (open === undefined || open.template === true) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
const header = headerOf(source, open.at);
|
|
87
|
+
return header === null ? null : { header, open: open.at, close };
|
|
88
|
+
}
|
|
89
|
+
/* The closer of the block after an optional parenthesis, starting at `at`. */
|
|
90
|
+
function blockAfter(source, at, parenthesis) {
|
|
91
|
+
let next = skipSpaces(source, at);
|
|
92
|
+
if (parenthesis) {
|
|
93
|
+
const close = source.charAt(next) === '(' ? closerOf(source, next) : null;
|
|
94
|
+
if (close === null) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
next = skipSpaces(source, close + 1);
|
|
98
|
+
}
|
|
99
|
+
return source.charAt(next) === '{' ? closerOf(source, next) : null;
|
|
100
|
+
}
|
|
101
|
+
/* The end of an `if` statement whose first block closes at `close`, past its `else if` and `else` clauses. */
|
|
102
|
+
function ifEnd(source, close) {
|
|
103
|
+
let end = close + 1;
|
|
104
|
+
for (;;) {
|
|
105
|
+
const at = skipSpaces(source, end);
|
|
106
|
+
const word = wordAt(source, at);
|
|
107
|
+
let next = null;
|
|
108
|
+
if (word === 'elseif') {
|
|
109
|
+
next = blockAfter(source, at + word.length, true);
|
|
110
|
+
}
|
|
111
|
+
else if (word === 'else') {
|
|
112
|
+
const after = skipSpaces(source, at + word.length);
|
|
113
|
+
next = wordAt(source, after) === 'if' ? blockAfter(source, after + 2, true) : blockAfter(source, at + word.length, false);
|
|
114
|
+
}
|
|
115
|
+
if (next === null) {
|
|
116
|
+
return end;
|
|
117
|
+
}
|
|
118
|
+
end = next + 1;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/* The `try` block that closes at `close` and the `catch` and `finally` blocks behind it. */
|
|
122
|
+
function tryClauses(source, close) {
|
|
123
|
+
const clauses = [{ word: 'try', close }];
|
|
124
|
+
for (;;) {
|
|
125
|
+
const at = skipSpaces(source, clauses.at(-1).close + 1);
|
|
126
|
+
const word = wordAt(source, at);
|
|
127
|
+
if (word !== 'catch' && word !== 'finally') {
|
|
128
|
+
return clauses;
|
|
129
|
+
}
|
|
130
|
+
const closer = blockAfter(source, at + word.length, source.charAt(skipSpaces(source, at + word.length)) === '(');
|
|
131
|
+
if (closer === null) {
|
|
132
|
+
return clauses;
|
|
133
|
+
}
|
|
134
|
+
clauses.push({ word, close: closer });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/* The end of a `do` statement whose block closes at `close`, with its `while` condition. */
|
|
138
|
+
function doEnd(source, close) {
|
|
139
|
+
const at = skipSpaces(source, close + 1);
|
|
140
|
+
if (wordAt(source, at) !== 'while') {
|
|
141
|
+
return close + 1;
|
|
142
|
+
}
|
|
143
|
+
const paren = skipSpaces(source, at + 5);
|
|
144
|
+
const closer = source.charAt(paren) === '(' ? closerOf(source, paren) : null;
|
|
145
|
+
if (closer === null) {
|
|
146
|
+
return close + 1;
|
|
147
|
+
}
|
|
148
|
+
return closer + 1 + (/^[ \t]*;/.exec(source.slice(closer + 1, closer + 16))?.[0].length ?? 0);
|
|
149
|
+
}
|
|
150
|
+
function lineBreakLength(source, at) {
|
|
151
|
+
const char = source.charAt(at);
|
|
152
|
+
if (char === '\r') {
|
|
153
|
+
return source.charAt(at + 1) === '\n' ? 2 : 1;
|
|
154
|
+
}
|
|
155
|
+
return char === '\n' ? 1 : 0;
|
|
156
|
+
}
|
|
157
|
+
/* The construct and the line it leaves blank, as one deletion. */
|
|
158
|
+
function deleteConstruct(source, start, end) {
|
|
159
|
+
const first = source.line(source.lineAt(start));
|
|
160
|
+
const last = source.line(source.lineAt(end));
|
|
161
|
+
return {
|
|
162
|
+
from: source.slice(first.start, start).trim() === '' ? first.start : start,
|
|
163
|
+
to: source.slice(end, last.end).trim() === '' ? last.next : end,
|
|
164
|
+
text: ''
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
/* The edits that keep the body of the block at `open` and drop the rest of the construct that runs from `start` to `end`. */
|
|
168
|
+
function unwrapEdits(source, indentUnit, start, open, close, end) {
|
|
169
|
+
if (source.slice(open + 1, close).trim() === '') {
|
|
170
|
+
return [deleteConstruct(source, start, end)];
|
|
171
|
+
}
|
|
172
|
+
let afterOpen = open + 1;
|
|
173
|
+
while (source.charAt(afterOpen) === ' ' || source.charAt(afterOpen) === '\t') {
|
|
174
|
+
afterOpen++;
|
|
175
|
+
}
|
|
176
|
+
const headerLine = source.line(source.lineAt(start));
|
|
177
|
+
const headerIndent = whitespaceOf(headerLine.text).length;
|
|
178
|
+
// The body takes the place of the header, so the header's own indentation goes too.
|
|
179
|
+
const edits = [
|
|
180
|
+
{ from: source.slice(headerLine.start, start).trim() === '' ? headerLine.start : start, to: afterOpen + lineBreakLength(source, afterOpen), text: '' }
|
|
181
|
+
];
|
|
182
|
+
let delta = 0;
|
|
183
|
+
for (let index = source.lineAt(open) + 1; index < source.lineAt(close); index++) {
|
|
184
|
+
const line = source.line(index);
|
|
185
|
+
if (line.text.trim() === '') {
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
const indent = whitespaceOf(line.text).length;
|
|
189
|
+
if (delta === 0) {
|
|
190
|
+
delta = indent > headerIndent ? indent - headerIndent : indentUnit.length;
|
|
191
|
+
}
|
|
192
|
+
const removed = Math.min(delta, indent);
|
|
193
|
+
if (removed > 0) {
|
|
194
|
+
edits.push({ from: line.start, to: line.start + removed, text: '' });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
const closing = source.line(source.lineAt(close));
|
|
198
|
+
const alone = source.slice(closing.start, close).trim() === '';
|
|
199
|
+
let from = close;
|
|
200
|
+
if (alone && closing.start > 0) {
|
|
201
|
+
from = source.line(source.lineAt(close) - 1).end;
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
while (from > closing.start && /[ \t]/.test(source.charAt(from - 1))) {
|
|
205
|
+
from--;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
edits.push({ from, to: end, text: '' });
|
|
209
|
+
return edits;
|
|
210
|
+
}
|
|
211
|
+
/* The `if` statement an `else` block belongs to, and the block right before the `else`. */
|
|
212
|
+
function ifOf(source, header) {
|
|
213
|
+
let current = header;
|
|
214
|
+
let first = null;
|
|
215
|
+
for (;;) {
|
|
216
|
+
const previous = blockEndingAt(source, current.elseStart ?? current.start);
|
|
217
|
+
if (previous === null || previous.header.word !== 'if') {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
first ??= previous;
|
|
221
|
+
if (previous.header.elseStart === undefined) {
|
|
222
|
+
return { start: previous.header.start, previous: first };
|
|
223
|
+
}
|
|
224
|
+
current = previous.header;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/* The `try` block and its clauses around a `catch` or `finally` block. */
|
|
228
|
+
function tryOf(source, header) {
|
|
229
|
+
let current = header;
|
|
230
|
+
for (;;) {
|
|
231
|
+
const previous = blockEndingAt(source, current.start);
|
|
232
|
+
if (previous === null || (previous.header.word !== 'try' && previous.header.word !== 'catch')) {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
if (previous.header.word === 'try') {
|
|
236
|
+
return { start: previous.header.start, clauses: tryClauses(source, previous.close) };
|
|
237
|
+
}
|
|
238
|
+
current = previous.header;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
function targetsOf(source, header, open, indentUnit) {
|
|
242
|
+
const close = closerOf(source, open);
|
|
243
|
+
if (close === null) {
|
|
244
|
+
return [];
|
|
245
|
+
}
|
|
246
|
+
switch (header.word) {
|
|
247
|
+
case 'if': {
|
|
248
|
+
const end = ifEnd(source, close);
|
|
249
|
+
return [{ kind: 'if', range: { from: header.start, to: end }, edits: unwrapEdits(source, indentUnit, header.start, open, close, end) }];
|
|
250
|
+
}
|
|
251
|
+
case 'else': {
|
|
252
|
+
const chain = ifOf(source, header);
|
|
253
|
+
if (chain === null) {
|
|
254
|
+
return [];
|
|
255
|
+
}
|
|
256
|
+
const range = { from: chain.start, to: close + 1 };
|
|
257
|
+
return [
|
|
258
|
+
{ kind: 'else', range, edits: unwrapEdits(source, indentUnit, chain.start, open, close, close + 1) },
|
|
259
|
+
{ kind: 'removeElse', range, edits: [{ from: chain.previous.close + 1, to: close + 1, text: '' }] }
|
|
260
|
+
];
|
|
261
|
+
}
|
|
262
|
+
case 'for':
|
|
263
|
+
case 'while':
|
|
264
|
+
return [
|
|
265
|
+
{
|
|
266
|
+
kind: header.word,
|
|
267
|
+
range: { from: header.start, to: close + 1 },
|
|
268
|
+
edits: unwrapEdits(source, indentUnit, header.start, open, close, close + 1)
|
|
269
|
+
}
|
|
270
|
+
];
|
|
271
|
+
case 'do': {
|
|
272
|
+
const end = doEnd(source, close);
|
|
273
|
+
return [{ kind: 'do', range: { from: header.start, to: end }, edits: unwrapEdits(source, indentUnit, header.start, open, close, end) }];
|
|
274
|
+
}
|
|
275
|
+
case 'try': {
|
|
276
|
+
const end = tryClauses(source, close).at(-1).close + 1;
|
|
277
|
+
return [{ kind: 'try', range: { from: header.start, to: end }, edits: unwrapEdits(source, indentUnit, header.start, open, close, end) }];
|
|
278
|
+
}
|
|
279
|
+
case 'catch':
|
|
280
|
+
case 'finally': {
|
|
281
|
+
const statement = tryOf(source, header);
|
|
282
|
+
const index = statement?.clauses.findIndex((clause) => clause.close === close) ?? -1;
|
|
283
|
+
if (statement === null || index < 1) {
|
|
284
|
+
return [];
|
|
285
|
+
}
|
|
286
|
+
const others = statement.clauses.filter((clause, position) => position !== index && clause.word !== 'try');
|
|
287
|
+
// A try needs a catch or a finally, so the last of them stays, and a finally needs a catch beside it.
|
|
288
|
+
if (header.word === 'catch' ? others.length === 0 : !others.some((clause) => clause.word === 'catch')) {
|
|
289
|
+
return [];
|
|
290
|
+
}
|
|
291
|
+
return [
|
|
292
|
+
{
|
|
293
|
+
kind: header.word === 'catch' ? 'removeCatch' : 'removeFinally',
|
|
294
|
+
range: { from: statement.start, to: statement.clauses.at(-1).close + 1 },
|
|
295
|
+
edits: [{ from: statement.clauses[index - 1].close + 1, to: close + 1, text: '' }]
|
|
296
|
+
}
|
|
297
|
+
];
|
|
298
|
+
}
|
|
299
|
+
default:
|
|
300
|
+
return [{ kind: 'braces', range: { from: open, to: close + 1 }, edits: unwrapEdits(source, indentUnit, open, open, close, close + 1) }];
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
/*
|
|
304
|
+
* What can be unwrapped or removed around an offset, innermost first: a statement whose body stays and whose header goes,
|
|
305
|
+
* or a clause that goes with its body. Only the blocks of control statements and bare blocks count, not functions, classes or objects.
|
|
306
|
+
*/
|
|
307
|
+
export function unwrapTargets(source, offset, language, indentUnit) {
|
|
308
|
+
const place = placeAt(source, offset, language);
|
|
309
|
+
if (place.kind !== 'code' || !(isScript(place.language) || isPhp(place.language))) {
|
|
310
|
+
return [];
|
|
311
|
+
}
|
|
312
|
+
const targets = [];
|
|
313
|
+
for (const open of enclosingBraces(source, offset)) {
|
|
314
|
+
const header = headerOf(source, open);
|
|
315
|
+
if (header !== null) {
|
|
316
|
+
targets.push(...targetsOf(source, header, open, indentUnit));
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return targets;
|
|
320
|
+
}
|