@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.
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ import type { EditSource } from './edit-source.ts';
2
+ export declare function skipTriviaBack(source: EditSource, end: number): number;
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
+ }
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/editor-core",
3
- "version": "0.43.0",
3
+ "version": "0.44.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
package/src/document.ts CHANGED
@@ -40,6 +40,10 @@ import type {
40
40
  TextEdit
41
41
  } from './types.ts';
42
42
  import { TypingContexts } from './typing-context.ts';
43
+ import { placeAt, type TextPlace } from './text-places.ts';
44
+ import { postfixOperands, type PostfixOperand } from './postfix-operands.ts';
45
+ import { surroundShape, type SurroundShape } from './surround-shape.ts';
46
+ import { unwrapTargets, type UnwrapTarget } from './unwrap.ts';
43
47
  import type { TypingContext } from './typing-context.ts';
44
48
  import { todosInLines, type TodoMatch, type TodoOptions } from './todo.ts';
45
49
  import { type LineOrder, planConvertIndents, planLineOrder } from './line-order.ts';
@@ -2150,6 +2154,26 @@ export class DocumentModel {
2150
2154
  return this.applyEdits(plan.edits, { source: 'command', selections });
2151
2155
  }
2152
2156
 
2157
+ /* What the text at an offset is written in, with embedded regions resolved, and whether it is code, a comment or a string. */
2158
+ placeAt(offset: number, language: string): TextPlace {
2159
+ return placeAt(this.editSource(language), offset, language);
2160
+ }
2161
+
2162
+ /* Every expression that ends right before the `.` at `dot`, innermost first; empty where none does. */
2163
+ postfixOperands(dot: number, language: string): PostfixOperand[] {
2164
+ return postfixOperands(this.editSource(language), dot, language);
2165
+ }
2166
+
2167
+ /* What a range can be surrounded with: whole statements, one expression, both or neither. Null where the range is blank. */
2168
+ surroundShape(from: number, to: number, language: string): SurroundShape | null {
2169
+ return surroundShape(this.editSource(language), from, to, language);
2170
+ }
2171
+
2172
+ /* What can be unwrapped or removed around an offset, innermost first, each with the edits that do it. */
2173
+ unwrapTargets(offset: number, language: string, indentUnit: string): UnwrapTarget[] {
2174
+ return unwrapTargets(this.editSource(language), offset, language, indentUnit);
2175
+ }
2176
+
2153
2177
  private editSource(language: string): EditSource {
2154
2178
  return {
2155
2179
  lineCount: this.getLineCount(),
package/src/index.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';
@@ -24,3 +25,7 @@ export type {
24
25
  } from './types.ts';
25
26
 
26
27
  export { changedSpan, changedSpans, type TextSpan } from './text-span.ts';
28
+ export type { TextPlace, TextPlaceKind } from './text-places.ts';
29
+ export type { PostfixOperand } from './postfix-operands.ts';
30
+ export type { SurroundShape } from './surround-shape.ts';
31
+ export type { UnwrapKind, UnwrapTarget } from './unwrap.ts';