@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.
@@ -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
+ }
@@ -0,0 +1,126 @@
1
+ import type { EditSource } from './edit-source.ts';
2
+ import { editRegions } from './edit-regions.ts';
3
+ import { vueRegionAt } from './languages.ts';
4
+ import type { TypingContext } from './typing-context.ts';
5
+
6
+ export type TextPlaceKind = 'code' | 'comment' | 'string';
7
+
8
+ export interface TextPlace {
9
+ /* The language the offset is written in, with embedded regions resolved: `typescript` in `<script lang="ts">` of a Vue file, `css` in `<style>`, `html` in PHP markup. */
10
+ readonly language: string;
11
+ readonly kind: TextPlaceKind;
12
+ }
13
+
14
+ const SCAN_LIMIT = 2_000_000;
15
+ const JSX_LANGUAGES = /^(?:typescriptreact|javascriptreact|tsx|jsx)$/;
16
+ const VUE_TAG = /^<\/?(?:template|script|style)\b/;
17
+
18
+ function kindOf(context: TypingContext): TextPlaceKind {
19
+ switch (context.mode) {
20
+ case 'line-comment':
21
+ case 'block-comment':
22
+ case 'html-comment':
23
+ return 'comment';
24
+
25
+ case 'quote':
26
+ case 'template':
27
+ case 'heredoc':
28
+ case 'regex':
29
+ return 'string';
30
+
31
+ case 'html':
32
+ if (context.markup === 'comment') {
33
+ return 'comment';
34
+ }
35
+
36
+ return context.markup === 'value' ? 'string' : 'code';
37
+
38
+ default:
39
+ return 'code';
40
+ }
41
+ }
42
+
43
+ /* The language of a `<script>` or `<style>` of a Vue file, read from the `lang` attribute of the tag that opened the region. */
44
+ function vueRegionLanguage(source: EditSource, line: number, region: 'script' | 'style'): string {
45
+ for (let index = line; index >= 0; index--) {
46
+ const text = source.line(index).text;
47
+
48
+ if (!text.startsWith(`<${region}`)) {
49
+ continue;
50
+ }
51
+
52
+ const lang = /\blang\s*=\s*["']?([\w-]+)/.exec(text)?.[1]?.toLowerCase();
53
+
54
+ if (region === 'style') {
55
+ return lang === 'scss' || lang === 'less' ? lang : 'css';
56
+ }
57
+
58
+ return lang === 'ts' || lang === 'tsx' ? 'typescript' : 'javascript';
59
+ }
60
+
61
+ return region === 'style' ? 'css' : 'javascript';
62
+ }
63
+
64
+ function vueLanguage(source: EditSource, offset: number): string {
65
+ const line = source.lineAt(offset);
66
+ const region = VUE_TAG.test(source.line(line).text) ? null : vueRegionAt((index) => source.line(index).text, line);
67
+
68
+ return region === 'script' || region === 'style' ? vueRegionLanguage(source, line, region) : 'html';
69
+ }
70
+
71
+ /* Whether a `<script>` or `<style>` element is open at the offset in a plain markup document. */
72
+ function markupLanguage(source: EditSource, offset: number): string {
73
+ let open: string | null = null;
74
+
75
+ for (const match of source.slice(0, offset).matchAll(/<(\/)?(script|style)\b[^>]*?(\/)?>/gi)) {
76
+ open = match[1] === undefined && match[3] === undefined ? match[2]!.toLowerCase() : null;
77
+ }
78
+
79
+ if (open === null) {
80
+ return 'html';
81
+ }
82
+
83
+ return open === 'script' ? 'javascript' : 'css';
84
+ }
85
+
86
+ function phpLanguage(context: TypingContext): string {
87
+ if (context.embedded === 'javascript') {
88
+ return 'javascript';
89
+ }
90
+
91
+ if (context.mode !== 'html') {
92
+ return 'php';
93
+ }
94
+
95
+ return context.markup === 'raw' && context.element === 'style' ? 'css' : 'html';
96
+ }
97
+
98
+ /* What the text at an offset is written in and whether it is code, a comment or a string. */
99
+ export function placeAt(source: EditSource, offset: number, language: string): TextPlace {
100
+ const id = language.toLowerCase();
101
+
102
+ if (source.line(source.lineCount - 1).end > SCAN_LIMIT) {
103
+ return { language, kind: 'code' };
104
+ }
105
+
106
+ const context = source.context(offset);
107
+ const kind = kindOf(context);
108
+
109
+ if (id === 'php') {
110
+ return { language: phpLanguage(context), kind };
111
+ }
112
+
113
+ if (id === 'vue') {
114
+ return { language: vueLanguage(source, offset), kind };
115
+ }
116
+
117
+ if (id === 'html' || id === 'xhtml') {
118
+ return { language: markupLanguage(source, offset), kind };
119
+ }
120
+
121
+ if (JSX_LANGUAGES.test(id) && editRegions(source, id)(source.lineAt(offset), offset) === 'jsx') {
122
+ return { language: 'html', kind };
123
+ }
124
+
125
+ return { language, kind };
126
+ }
package/src/trivia.ts ADDED
@@ -0,0 +1,29 @@
1
+ import type { EditSource } from './edit-source.ts';
2
+
3
+ /* The end of the last token before `end`, past the whitespace and the comments between them. */
4
+ export function skipTriviaBack(source: EditSource, end: number): number {
5
+ let at = end;
6
+
7
+ while (at > 0) {
8
+ const char = source.charAt(at - 1);
9
+
10
+ if (/\s/u.test(char)) {
11
+ at--;
12
+
13
+ continue;
14
+ }
15
+
16
+ // The lexer leaves a block comment at its `*`, so the state before the closing `/` is already code.
17
+ const inside = source.context(char === '/' && at > 1 && source.charAt(at - 2) === '*' ? at - 2 : at - 1);
18
+
19
+ if ((inside.mode === 'line-comment' || inside.mode === 'block-comment') && inside.commentStart !== undefined && inside.commentStart < at) {
20
+ at = inside.commentStart;
21
+
22
+ continue;
23
+ }
24
+
25
+ break;
26
+ }
27
+
28
+ return at;
29
+ }
package/src/types.ts CHANGED
@@ -147,6 +147,8 @@ export interface CommandOptions {
147
147
  smartSemicolon?: boolean;
148
148
  /* In PHP a `-` typed after a variable, a member or a `)` or `]` becomes `->`. On unless false. */
149
149
  smartArrow?: boolean;
150
+ /* In JavaScript and TypeScript a `${` typed in a quoted string makes it a template literal, in a second undo step. On unless false. */
151
+ templateLiterals?: boolean;
150
152
  /* Tab steps over a closer the editor inserted. On unless false. */
151
153
  tabOutOfClosers?: boolean;
152
154
  /* Enter computes the indentation of the new line, continues comments and closes braces. On unless false. */
@@ -1,4 +1,5 @@
1
1
  import type { EditSource } from './edit-source.ts';
2
+ import type { TextEdit } from './types.ts';
2
3
 
3
4
  /* A change of text in one place and where the caret stands in what replaces it. */
4
5
  export interface HandlerEdit {
@@ -169,3 +170,85 @@ export function surround(selected: string, typed: string): { text: string; inner
169
170
 
170
171
  return { text: typed + inner + matchingDelimiter(typed), inner };
171
172
  }
173
+
174
+ const scriptLanguages = /^(javascript|typescript|jsx|tsx|javascriptreact|typescriptreact|js|ts|mjs|cjs|mts|cts)$/i;
175
+ const moduleSpecifier = /(?:\bfrom|\bimport|\brequire)\s*\(?\s*$/;
176
+
177
+ /*
178
+ * The quotes of the string a `${` is typed in when that makes it a template literal: a single- or
179
+ * double-quoted string of a script, closed on its line and not the specifier of a module.
180
+ */
181
+ export function templateString(source: EditSource, offset: number, language: string): { open: number; close: number } | null {
182
+ const line = source.lineAt(offset);
183
+ const script = scriptLanguages.test(language) || (/^vue$/i.test(language) && source.region(line) === 'script');
184
+
185
+ const { start, end } = source.line(line);
186
+ const before = source.slice(start, offset);
187
+
188
+ if (!script || !before.endsWith('$') || escapedIn(before, before.length - 1)) {
189
+ return null;
190
+ }
191
+
192
+ const context = source.context(offset);
193
+
194
+ if (context.mode !== 'quote' || context.escaped || (context.quote !== "'" && context.quote !== '"')) {
195
+ return null;
196
+ }
197
+
198
+ let open = before.length - 1;
199
+
200
+ while (open >= 0 && (before[open] !== context.quote || escapedIn(before, open))) {
201
+ open--;
202
+ }
203
+
204
+ if (open < 0 || moduleSpecifier.test(before.slice(0, open))) {
205
+ return null;
206
+ }
207
+
208
+ const after = source.slice(offset, end);
209
+
210
+ for (let at = 0; at < after.length; at++) {
211
+ if (after[at] === '\\') {
212
+ at++;
213
+ } else if (after[at] === context.quote) {
214
+ return { open: start + open, close: offset + at };
215
+ }
216
+ }
217
+
218
+ return null;
219
+ }
220
+
221
+ function escapedIn(text: string, index: number): boolean {
222
+ let slashes = 0;
223
+
224
+ while (index - slashes - 1 >= 0 && text[index - slashes - 1] === '\\') {
225
+ slashes++;
226
+ }
227
+
228
+ return slashes % 2 === 1;
229
+ }
230
+
231
+ /* 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. */
232
+ export function templateLiteralEdits(source: EditSource, open: number, close: number): TextEdit[] {
233
+ const quote = source.charAt(open);
234
+ const body = source.slice(open + 1, close);
235
+ const edits: TextEdit[] = [{ from: open, to: open + 1, text: '`' }];
236
+
237
+ for (let index = 0; index < body.length; index++) {
238
+ const at = open + 1 + index;
239
+
240
+ if (body[index] === '\\') {
241
+ if (body[index + 1] === quote) {
242
+ edits.push({ from: at, to: at + 1, text: '' });
243
+ }
244
+
245
+ index++;
246
+ } else if (body[index] === '`') {
247
+ edits.push({ from: at, to: at + 1, text: '\\`' });
248
+ }
249
+ }
250
+
251
+ edits.push({ from: close, to: close + 1, text: '`' });
252
+
253
+ return edits;
254
+ }