@adecore/editor-react 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/PickPopup.js +94 -73
- package/dist/completion.d.ts +28 -0
- package/dist/completion.js +76 -15
- package/dist/editor-language.d.ts +4 -0
- package/dist/editor-language.js +6 -0
- package/dist/host-types.d.ts +4 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/key-priority.d.ts +1 -0
- package/dist/key-priority.js +2 -0
- package/dist/live-templates.d.ts +16 -0
- package/dist/live-templates.js +289 -0
- package/dist/locales/en.json +30 -0
- package/dist/locales/nl.json +30 -0
- package/dist/markup-context.d.ts +18 -0
- package/dist/markup-context.js +122 -5
- package/dist/markup.d.ts +3 -0
- package/dist/markup.js +16 -4
- package/dist/pick.d.ts +1 -0
- package/dist/pick.js +11 -1
- package/dist/popups.d.ts +1 -0
- package/dist/postfix.d.ts +21 -0
- package/dist/postfix.js +212 -0
- package/dist/project-language.d.ts +2 -0
- package/dist/project-language.js +3 -0
- package/dist/shortcut-keys.d.ts +5 -0
- package/dist/snippet-insert.d.ts +10 -0
- package/dist/snippet-insert.js +217 -0
- package/dist/snippet.d.ts +7 -1
- package/dist/snippet.js +65 -8
- package/dist/surround-constructs.d.ts +10 -0
- package/dist/surround-constructs.js +100 -0
- package/dist/surround.d.ts +18 -0
- package/dist/surround.js +399 -0
- package/dist/template-builtins.d.ts +3 -0
- package/dist/template-builtins.js +46 -0
- package/dist/template-context.d.ts +12 -0
- package/dist/template-context.js +56 -0
- package/dist/templates.d.ts +59 -0
- package/dist/templates.js +103 -0
- package/package.json +6 -6
- package/src/PickPopup.tsx +7 -0
- package/src/completion.ts +111 -13
- package/src/editor-language.ts +6 -0
- package/src/host-types.ts +7 -0
- package/src/index.ts +16 -0
- package/src/key-priority.ts +2 -0
- package/src/live-templates.ts +315 -0
- package/src/locales/en.json +30 -0
- package/src/locales/nl.json +30 -0
- package/src/markup-context.ts +131 -6
- package/src/markup.ts +15 -5
- package/src/pick.ts +17 -1
- package/src/popups.ts +2 -0
- package/src/postfix.ts +224 -0
- package/src/project-language.ts +3 -0
- package/src/snippet-insert.ts +258 -0
- package/src/snippet.ts +82 -7
- package/src/surround-constructs.ts +85 -0
- package/src/surround.ts +430 -0
- package/src/template-builtins.ts +94 -0
- package/src/template-context.ts +65 -0
- package/src/templates.ts +197 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { EditorPosition } from '@adecore/editor';
|
|
2
|
+
import type { LocalCompletionItem } from './completion.ts';
|
|
3
|
+
import type { EditorLanguage } from './editor-language.ts';
|
|
4
|
+
interface PostfixKey {
|
|
5
|
+
readonly start: EditorPosition;
|
|
6
|
+
expand(): boolean;
|
|
7
|
+
}
|
|
8
|
+
export declare class PostfixFeature {
|
|
9
|
+
private readonly language;
|
|
10
|
+
constructor(language: EditorLanguage);
|
|
11
|
+
key(): PostfixKey | null;
|
|
12
|
+
rows(position: EditorPosition): readonly LocalCompletionItem[];
|
|
13
|
+
private row;
|
|
14
|
+
private candidates;
|
|
15
|
+
private expandKey;
|
|
16
|
+
private expand;
|
|
17
|
+
private run;
|
|
18
|
+
private rendered;
|
|
19
|
+
private expressionOf;
|
|
20
|
+
}
|
|
21
|
+
export {};
|
package/dist/postfix.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { isIdentifierCharacter } from './completion-model.js';
|
|
2
|
+
import { comparePositions } from './diagnostics-model.js';
|
|
3
|
+
import { lineTextOf, textBeforeOf } from './line-text.js';
|
|
4
|
+
import { bodyWithIndentUnit, dedentLines, indentUnitOf, insertSnippet } from './snippet-insert.js';
|
|
5
|
+
import { compileSnippet } from './snippet.js';
|
|
6
|
+
import { SNIPPET_KIND, sortAfterServer, templateContextAt } from './template-context.js';
|
|
7
|
+
const MAX_ROW_LENGTH = 80;
|
|
8
|
+
/* The identifier characters after the last dot of `before`, and where that dot is; null when no dot leads them. */
|
|
9
|
+
function typedKey(before) {
|
|
10
|
+
let index = before.length;
|
|
11
|
+
while (index > 0 && isIdentifierCharacter(before[index - 1])) {
|
|
12
|
+
index--;
|
|
13
|
+
}
|
|
14
|
+
return index > 0 && before[index - 1] === '.' ? {
|
|
15
|
+
key: before.slice(index),
|
|
16
|
+
dot: index - 1
|
|
17
|
+
} : null;
|
|
18
|
+
}
|
|
19
|
+
/* One line of a chooser row: whitespace collapsed and cut short. */
|
|
20
|
+
function rowLabel(text) {
|
|
21
|
+
const line = text.replace(/\s+/g, ' ').trim();
|
|
22
|
+
return line.length > MAX_ROW_LENGTH ? `${line.slice(0, MAX_ROW_LENGTH - 1)}…` : line;
|
|
23
|
+
}
|
|
24
|
+
/*
|
|
25
|
+
* Postfix completion: a dot and a key after an expression, and Tab or an accepted row wraps the expression in a
|
|
26
|
+
* template. The registry holds the templates and `TemplatesFeature` decides whether Tab is ours or a live template's.
|
|
27
|
+
*/
|
|
28
|
+
export class PostfixFeature {
|
|
29
|
+
language;
|
|
30
|
+
constructor(language) {
|
|
31
|
+
this.language = language;
|
|
32
|
+
}
|
|
33
|
+
/* The key before the caret that a template of the registry applies to; null where Tab has no postfix template to expand. */
|
|
34
|
+
key() {
|
|
35
|
+
const {
|
|
36
|
+
editor,
|
|
37
|
+
project
|
|
38
|
+
} = this.language;
|
|
39
|
+
if (!project.templates.settings.postfix) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
const caret = editor.getCaret();
|
|
43
|
+
const typed = typedKey(textBeforeOf(editor, caret));
|
|
44
|
+
if (typed === null || typed.key === '') {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
const candidates = this.candidates({
|
|
48
|
+
line: caret.line,
|
|
49
|
+
character: typed.dot
|
|
50
|
+
}, template => template.key === typed.key);
|
|
51
|
+
if (candidates === null) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
start: candidates.dot,
|
|
56
|
+
expand: () => this.expandKey(candidates, caret)
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/* The rows of the suggestion list after a dot, for the templates whose key the typed characters can lead to. */
|
|
60
|
+
rows(position) {
|
|
61
|
+
const {
|
|
62
|
+
editor,
|
|
63
|
+
project
|
|
64
|
+
} = this.language;
|
|
65
|
+
const {
|
|
66
|
+
settings
|
|
67
|
+
} = project.templates;
|
|
68
|
+
const selections = editor.getSelections();
|
|
69
|
+
if (!settings.postfix || !settings.postfixInCompletion || selections.length !== 1 || comparePositions(selections[0].start, selections[0].end) !== 0) {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
const typed = typedKey(textBeforeOf(editor, position));
|
|
73
|
+
if (typed === null) {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
const candidates = this.candidates({
|
|
77
|
+
line: position.line,
|
|
78
|
+
character: typed.dot
|
|
79
|
+
}, template => template.key.startsWith(typed.key));
|
|
80
|
+
if (candidates === null) {
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
const range = {
|
|
84
|
+
start: {
|
|
85
|
+
line: position.line,
|
|
86
|
+
character: typed.dot + 1
|
|
87
|
+
},
|
|
88
|
+
end: position
|
|
89
|
+
};
|
|
90
|
+
return candidates.templates.map(template => this.row(template, candidates, range, sortAfterServer(template.key)));
|
|
91
|
+
}
|
|
92
|
+
row(template, candidates, range, sortText) {
|
|
93
|
+
const item = {
|
|
94
|
+
label: template.key,
|
|
95
|
+
kind: SNIPPET_KIND,
|
|
96
|
+
detail: template.example,
|
|
97
|
+
documentation: template.description,
|
|
98
|
+
filterText: template.key,
|
|
99
|
+
sortText,
|
|
100
|
+
textEdit: {
|
|
101
|
+
range,
|
|
102
|
+
newText: template.key
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
return {
|
|
106
|
+
item,
|
|
107
|
+
accept: () => this.expand(template, candidates, this.language.editor.getCaret())
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/* The templates `matches` lets through that apply to the expressions before `dot`; null when none does. */
|
|
111
|
+
candidates(dot, matches) {
|
|
112
|
+
const {
|
|
113
|
+
editor,
|
|
114
|
+
project
|
|
115
|
+
} = this.language;
|
|
116
|
+
const operands = editor.postfixOperands?.(dot) ?? [];
|
|
117
|
+
if (operands.length === 0) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
const context = templateContextAt(this.language, dot);
|
|
121
|
+
if (context.place !== 'code') {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
const widest = operands.at(-1);
|
|
125
|
+
const templates = project.templates.postfix.filter(template => matches(template) && template.languages.some(family => context.families.includes(family)) && (template.expression !== 'statement' || widest.statement));
|
|
126
|
+
return templates.length === 0 ? null : {
|
|
127
|
+
dot,
|
|
128
|
+
operands,
|
|
129
|
+
templates
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/* Expands the one template of a key, or lists them when several share it. */
|
|
133
|
+
expandKey(candidates, caret) {
|
|
134
|
+
if (candidates.templates.length === 1) {
|
|
135
|
+
this.expand(candidates.templates[0], candidates, caret);
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
const start = {
|
|
139
|
+
line: candidates.dot.line,
|
|
140
|
+
character: candidates.dot.character + 1
|
|
141
|
+
};
|
|
142
|
+
this.language.completion.showItems(candidates.templates.map(template => this.row(template, candidates, {
|
|
143
|
+
start,
|
|
144
|
+
end: caret
|
|
145
|
+
}, template.id)), start);
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
/* Wraps the widest expression for a statement, the only one there is, or the one a person picks from the expressions. */
|
|
149
|
+
expand(template, candidates, caret) {
|
|
150
|
+
const {
|
|
151
|
+
operands
|
|
152
|
+
} = candidates;
|
|
153
|
+
if (template.expression === 'statement' || operands.length === 1) {
|
|
154
|
+
void this.run(template, template.expression === 'statement' ? operands.at(-1) : operands[0], caret);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const {
|
|
158
|
+
editor,
|
|
159
|
+
completion,
|
|
160
|
+
pick,
|
|
161
|
+
project
|
|
162
|
+
} = this.language;
|
|
163
|
+
const rows = operands.map((operand, index) => ({
|
|
164
|
+
id: String(index),
|
|
165
|
+
label: rowLabel(editor.textInRange(operand.range)),
|
|
166
|
+
detail: ''
|
|
167
|
+
}));
|
|
168
|
+
completion.close();
|
|
169
|
+
pick.open({
|
|
170
|
+
anchor: caret,
|
|
171
|
+
title: project.i18n.t('editor:language.postfix.expressions'),
|
|
172
|
+
groups: [{
|
|
173
|
+
title: null,
|
|
174
|
+
rows
|
|
175
|
+
}],
|
|
176
|
+
accept: id => void this.run(template, operands[Number(id)], caret),
|
|
177
|
+
preview: id => Promise.resolve({
|
|
178
|
+
removed: [],
|
|
179
|
+
added: this.rendered(template, operands[Number(id)]).split('\n'),
|
|
180
|
+
note: null,
|
|
181
|
+
kind: 'text'
|
|
182
|
+
})
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
run(template, operand, caret) {
|
|
186
|
+
const {
|
|
187
|
+
editor
|
|
188
|
+
} = this.language;
|
|
189
|
+
const expression = this.expressionOf(operand);
|
|
190
|
+
return insertSnippet(this.language, bodyWithIndentUnit(template.body, indentUnitOf(editor)), [{
|
|
191
|
+
range: {
|
|
192
|
+
start: operand.range.start,
|
|
193
|
+
end: caret
|
|
194
|
+
}
|
|
195
|
+
}], name => name === 'EXPR' ? expression : undefined);
|
|
196
|
+
}
|
|
197
|
+
/* What the template would put in place of the expression, tab stops at their placeholders. */
|
|
198
|
+
rendered(template, operand) {
|
|
199
|
+
const expression = this.expressionOf(operand);
|
|
200
|
+
return compileSnippet(bodyWithIndentUnit(template.body, indentUnitOf(this.language.editor))).render(name => name === 'EXPR' ? expression : undefined, undefined, {
|
|
201
|
+
indentVariables: true
|
|
202
|
+
}).text;
|
|
203
|
+
}
|
|
204
|
+
/* The text of an expression, with the indentation of its first line taken off the lines after it. */
|
|
205
|
+
expressionOf(operand) {
|
|
206
|
+
const {
|
|
207
|
+
editor
|
|
208
|
+
} = this.language;
|
|
209
|
+
const indent = /^[\t ]*/.exec(lineTextOf(editor, operand.range.start.line))[0];
|
|
210
|
+
return dedentLines(editor.textInRange(operand.range).trimEnd(), indent);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
@@ -2,6 +2,7 @@ import { type i18n } from 'i18next';
|
|
|
2
2
|
import { type ApplyWorkspaceEditResult, type LanguageService, type WorkspaceEdit } from '@adecore/lsp';
|
|
3
3
|
import type { Editor, EditorPosition } from '@adecore/editor';
|
|
4
4
|
import { ClipboardHistory } from './clipboard-history.ts';
|
|
5
|
+
import { TemplateRegistry } from './templates.ts';
|
|
5
6
|
import { EditLocations } from './edit-locations.ts';
|
|
6
7
|
import { NavigationHistory, type Place } from './navigation-history.ts';
|
|
7
8
|
import { ProjectProblems } from './project-problems.ts';
|
|
@@ -28,6 +29,7 @@ export declare class ProjectLanguage {
|
|
|
28
29
|
readonly history: NavigationHistory;
|
|
29
30
|
readonly edits: EditLocations;
|
|
30
31
|
readonly clipboard: ClipboardHistory;
|
|
32
|
+
readonly templates: TemplateRegistry;
|
|
31
33
|
private readonly holders;
|
|
32
34
|
private readonly files;
|
|
33
35
|
panelBodyHeight: number;
|
package/dist/project-language.js
CHANGED
|
@@ -2,6 +2,7 @@ import { disposeSnippetTransforms } from './snippet-transform-queue.js';
|
|
|
2
2
|
import i18next from 'i18next';
|
|
3
3
|
import { fileUriToPath, pathToFileUri, unservedLanguageService } from '@adecore/lsp';
|
|
4
4
|
import { ClipboardHistory } from './clipboard-history.js';
|
|
5
|
+
import { TemplateRegistry } from './templates.js';
|
|
5
6
|
import { EditLocations } from './edit-locations.js';
|
|
6
7
|
import { NavigationHistory } from './navigation-history.js';
|
|
7
8
|
import { ProjectProblems, serviceProblems } from './project-problems.js';
|
|
@@ -91,6 +92,7 @@ export class ProjectLanguage {
|
|
|
91
92
|
history = new NavigationHistory();
|
|
92
93
|
edits;
|
|
93
94
|
clipboard;
|
|
95
|
+
templates;
|
|
94
96
|
holders = new Map();
|
|
95
97
|
files;
|
|
96
98
|
/* The body height of the peek and call hierarchy panels, shared by the editors of the project. */
|
|
@@ -110,6 +112,7 @@ export class ProjectLanguage {
|
|
|
110
112
|
this.files = host.files ?? null;
|
|
111
113
|
this.edits = new EditLocations(host.editHistoryLimit);
|
|
112
114
|
this.clipboard = host.clipboard ?? new ClipboardHistory();
|
|
115
|
+
this.templates = host.templates ?? new TemplateRegistry();
|
|
113
116
|
this.panelBodyHeight = host.panelBodyHeight ?? PANEL_BODY_HEIGHT;
|
|
114
117
|
this.problems = new ProjectProblems(host.problemSource ?? serviceProblems(service, uri => this.pathOfLocation(uri) ?? uri));
|
|
115
118
|
const shows = service.onShowDocument?.(params => this.showDocument(params));
|
package/dist/shortcut-keys.d.ts
CHANGED
|
@@ -52,6 +52,7 @@ export declare function shortcutsOf(keymap: Keymap, apple: boolean): {
|
|
|
52
52
|
historyForward: LanguageShortcut | null;
|
|
53
53
|
increaseFontSize: LanguageShortcut | null;
|
|
54
54
|
inlineEdit: LanguageShortcut | null;
|
|
55
|
+
insertLiveTemplate: LanguageShortcut | null;
|
|
55
56
|
joinLines: LanguageShortcut | null;
|
|
56
57
|
lastEditLocation: LanguageShortcut | null;
|
|
57
58
|
matchBrace: LanguageShortcut | null;
|
|
@@ -83,6 +84,7 @@ export declare function shortcutsOf(keymap: Keymap, apple: boolean): {
|
|
|
83
84
|
resetFontSize: LanguageShortcut | null;
|
|
84
85
|
reverseLines: LanguageShortcut | null;
|
|
85
86
|
save: LanguageShortcut | null;
|
|
87
|
+
saveAsLiveTemplate: LanguageShortcut | null;
|
|
86
88
|
selectAll: LanguageShortcut | null;
|
|
87
89
|
selectAllOccurrences: LanguageShortcut | null;
|
|
88
90
|
selectNextOccurrence: LanguageShortcut | null;
|
|
@@ -101,6 +103,8 @@ export declare function shortcutsOf(keymap: Keymap, apple: boolean): {
|
|
|
101
103
|
startNewLine: LanguageShortcut | null;
|
|
102
104
|
startNewLineBefore: LanguageShortcut | null;
|
|
103
105
|
suggestInline: LanguageShortcut | null;
|
|
106
|
+
surroundWith: LanguageShortcut | null;
|
|
107
|
+
surroundWithLiveTemplate: LanguageShortcut | null;
|
|
104
108
|
textEnd: LanguageShortcut | null;
|
|
105
109
|
textStart: LanguageShortcut | null;
|
|
106
110
|
toggleBlockComment: LanguageShortcut | null;
|
|
@@ -113,6 +117,7 @@ export declare function shortcutsOf(keymap: Keymap, apple: boolean): {
|
|
|
113
117
|
undo: LanguageShortcut | null;
|
|
114
118
|
uniqueLines: LanguageShortcut | null;
|
|
115
119
|
unselectOccurrence: LanguageShortcut | null;
|
|
120
|
+
unwrap: LanguageShortcut | null;
|
|
116
121
|
wordLeft: LanguageShortcut | null;
|
|
117
122
|
wordRight: LanguageShortcut | null;
|
|
118
123
|
wrapWithAbbreviation: LanguageShortcut | null;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Editor, EditorRange } from '@adecore/editor';
|
|
2
|
+
import type { EditorLanguage } from './editor-language.ts';
|
|
3
|
+
export interface SnippetPlacement {
|
|
4
|
+
readonly range: EditorRange;
|
|
5
|
+
readonly selectedText?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function bodyWithIndentUnit(body: string, unit: string): string;
|
|
8
|
+
export declare function indentUnitOf(editor: Editor): string;
|
|
9
|
+
export declare function dedentLines(text: string, base?: string): string;
|
|
10
|
+
export declare function insertSnippet(language: EditorLanguage, source: string, placements: readonly SnippetPlacement[], extra?: (name: string) => string | undefined): Promise<boolean>;
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { SnippetTransformQueueError } from './snippet-transform-queue.js';
|
|
2
|
+
import { comparePositions, shiftPosition } from './diagnostics-model.js';
|
|
3
|
+
import { lineTextOf, textBeforeOf } from './line-text.js';
|
|
4
|
+
import { wordRangeAt } from './rename-model.js';
|
|
5
|
+
import { jobTable } from './snippet-field-transforms.js';
|
|
6
|
+
import { indentSnippet } from './snippet-indent.js';
|
|
7
|
+
import { positionInInsertion } from './snippet-session.js';
|
|
8
|
+
import { SnippetTransformError } from './snippet-transform-client.js';
|
|
9
|
+
import { MAX_TRANSFORM_JOBS, MAX_TRANSFORM_WORK } from './snippet-transform-protocol.js';
|
|
10
|
+
import { compileSnippet, tabOrder } from './snippet.js';
|
|
11
|
+
import { createSnippetResolver } from './snippet-variables.js';
|
|
12
|
+
import { fileUriToPath } from '@adecore/lsp';
|
|
13
|
+
/* The same limits as an inserted completion item. */
|
|
14
|
+
const MAX_CHARACTERS = 2_000_000;
|
|
15
|
+
const MAX_RANGES = 1_000;
|
|
16
|
+
const TOAST_ID = 'language-completion';
|
|
17
|
+
class LimitError extends Error {}
|
|
18
|
+
/* Each line's leading tabs become the editor's own indentation unit. */
|
|
19
|
+
export function bodyWithIndentUnit(body, unit) {
|
|
20
|
+
return unit === '\t' ? body : body.replace(/^\t+/gm, tabs => unit.repeat(tabs.length));
|
|
21
|
+
}
|
|
22
|
+
/* One level of the editor's indentation: spaces for a tab size, or a tab. */
|
|
23
|
+
export function indentUnitOf(editor) {
|
|
24
|
+
const {
|
|
25
|
+
tabSize,
|
|
26
|
+
insertSpaces
|
|
27
|
+
} = editor.getIndentation();
|
|
28
|
+
return insertSpaces ? ' '.repeat(tabSize) : '\t';
|
|
29
|
+
}
|
|
30
|
+
/* The lines after the first without `base` in front of them, or without the shortest indentation among them. */
|
|
31
|
+
export function dedentLines(text, base) {
|
|
32
|
+
const lines = text.split(/\r\n|\r|\n/).slice(1);
|
|
33
|
+
const indents = lines.filter(line => line.trim() !== '').map(line => /^[\t ]*/.exec(line)[0].length);
|
|
34
|
+
const removed = base?.length ?? (indents.length === 0 ? 0 : Math.min(...indents));
|
|
35
|
+
return removed === 0 ? text : text.replace(/(\r\n|\r|\n)([^\r\n]*)/g, (_match, lineBreak, line) => lineBreak + line.slice(Math.min(removed, /^[\t ]*/.exec(line)[0].length)));
|
|
36
|
+
}
|
|
37
|
+
function sizeOf(parsed) {
|
|
38
|
+
return (parsed.transforms?.length ?? 0) + (parsed.fields?.reduce((count, field) => count + field.ranges.length + (field.linked?.length ?? 0), 1) ?? parsed.stops.length);
|
|
39
|
+
}
|
|
40
|
+
/*
|
|
41
|
+
* Inserts a snippet at every placement, the last of them the primary one, and starts a session on its tab stops. The
|
|
42
|
+
* variables are resolved for each placement (`extra` first, then the host's and the built-in ones), the lines are indented
|
|
43
|
+
* to the text before the placement, and a running session takes the insertion as a nested one. False when nothing was inserted.
|
|
44
|
+
*/
|
|
45
|
+
export async function insertSnippet(language, source, placements, extra) {
|
|
46
|
+
const {
|
|
47
|
+
editor,
|
|
48
|
+
project,
|
|
49
|
+
uri,
|
|
50
|
+
snippets
|
|
51
|
+
} = language;
|
|
52
|
+
if (placements.length === 0 || editor.isReadOnly?.() === true) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
const template = compileSnippet(source);
|
|
56
|
+
const now = new Date();
|
|
57
|
+
const token = snippets.token;
|
|
58
|
+
const continuing = snippets.isActive;
|
|
59
|
+
let stale = false;
|
|
60
|
+
const off = editor.onTextChange(() => {
|
|
61
|
+
stale = true;
|
|
62
|
+
});
|
|
63
|
+
const current = () => !stale && snippets.token === token;
|
|
64
|
+
try {
|
|
65
|
+
let characters = 0;
|
|
66
|
+
let ranges = 0;
|
|
67
|
+
const drafts = placements.map((placement, index) => {
|
|
68
|
+
const line = lineTextOf(editor, placement.range.start.line);
|
|
69
|
+
const word = wordRangeAt(line, placement.range.start);
|
|
70
|
+
const resolver = createSnippetResolver({
|
|
71
|
+
uri,
|
|
72
|
+
languageId: language.languageId,
|
|
73
|
+
position: placement.range.start,
|
|
74
|
+
selection: placement.range,
|
|
75
|
+
selectedText: placement.selectedText ?? '',
|
|
76
|
+
currentLine: line,
|
|
77
|
+
currentWord: word === null ? '' : editor.textInRange(word),
|
|
78
|
+
cursorIndex: index,
|
|
79
|
+
cursorCount: placements.length,
|
|
80
|
+
folder: project.host.folder,
|
|
81
|
+
path: fileUriToPath(uri) ?? project.host.pathOfUri?.(uri) ?? undefined
|
|
82
|
+
}, project.host.resolveSnippetVariable?.bind(project.host), {
|
|
83
|
+
now
|
|
84
|
+
});
|
|
85
|
+
const resolve = name => extra?.(name) ?? resolver(name);
|
|
86
|
+
const raw = template.render(resolve, undefined, {
|
|
87
|
+
indentVariables: true
|
|
88
|
+
});
|
|
89
|
+
return {
|
|
90
|
+
placement,
|
|
91
|
+
resolve,
|
|
92
|
+
raw,
|
|
93
|
+
parsed: indentSnippet(raw, textBeforeOf(editor, placement.range.start))
|
|
94
|
+
};
|
|
95
|
+
});
|
|
96
|
+
for (const {
|
|
97
|
+
parsed
|
|
98
|
+
} of drafts) {
|
|
99
|
+
characters += parsed.text.length;
|
|
100
|
+
ranges += sizeOf(parsed);
|
|
101
|
+
if (parsed.limited === true || characters > MAX_CHARACTERS || ranges > MAX_RANGES) {
|
|
102
|
+
throw new LimitError();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
await transformVariables(language, template, drafts, current);
|
|
106
|
+
if (!current()) {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
const changes = drafts.map(draft => ({
|
|
110
|
+
range: draft.placement.range,
|
|
111
|
+
text: draft.parsed.text
|
|
112
|
+
}));
|
|
113
|
+
if (changes.reduce((total, change) => total + change.text.length, 0) > MAX_CHARACTERS) {
|
|
114
|
+
throw new LimitError();
|
|
115
|
+
}
|
|
116
|
+
// Insertions before one another move it, so each start is mapped through the changes in front of it.
|
|
117
|
+
const starts = changes.map(change => changes.filter(other => other !== change && comparePositions(other.range.end, change.range.start) <= 0).sort((left, right) => comparePositions(right.range.start, left.range.start)).reduce((place, other) => shiftPosition(place, other), change.range.start));
|
|
118
|
+
if (!(continuing ? snippets.applyEdits(changes, token) : editor.applyEdits(changes))) {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
off();
|
|
122
|
+
place(language, drafts, starts, continuing && snippets.isActive && snippets.token === token);
|
|
123
|
+
editor.focus();
|
|
124
|
+
return true;
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (error instanceof LimitError || error instanceof SnippetTransformError && error.kind === 'range') {
|
|
127
|
+
project.host.notify?.({
|
|
128
|
+
id: TOAST_ID,
|
|
129
|
+
kind: 'error',
|
|
130
|
+
title: project.i18n.t('editor:language.completion.tooLarge')
|
|
131
|
+
});
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
if (error instanceof SnippetTransformQueueError) {
|
|
135
|
+
project.host.notify?.({
|
|
136
|
+
id: TOAST_ID,
|
|
137
|
+
kind: 'error',
|
|
138
|
+
title: project.i18n.t('editor:language.completion.transformBusy')
|
|
139
|
+
});
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
if (error instanceof SnippetTransformError) {
|
|
143
|
+
project.host.notify?.({
|
|
144
|
+
id: TOAST_ID,
|
|
145
|
+
kind: 'error',
|
|
146
|
+
title: project.i18n.t('editor:language.completion.transformFailed')
|
|
147
|
+
});
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
throw error;
|
|
151
|
+
} finally {
|
|
152
|
+
off();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/* Runs the transforms of variables through the worker, once for all placements, and renders them again with the answers. */
|
|
156
|
+
async function transformVariables(language, template, drafts, current) {
|
|
157
|
+
const {
|
|
158
|
+
editor,
|
|
159
|
+
project,
|
|
160
|
+
snippets
|
|
161
|
+
} = language;
|
|
162
|
+
const {
|
|
163
|
+
jobs,
|
|
164
|
+
slotOf
|
|
165
|
+
} = jobTable();
|
|
166
|
+
const slots = drafts.map(draft => (draft.raw.transforms ?? []).map(range => slotOf({
|
|
167
|
+
...range.transform,
|
|
168
|
+
value: draft.raw.text.slice(range.start, range.end)
|
|
169
|
+
})));
|
|
170
|
+
if (jobs.length === 0 || project.host.snippetTransformWorker === undefined) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (jobs.length > MAX_TRANSFORM_JOBS || jobs.reduce((sum, job) => sum + job.value.length, 0) > MAX_TRANSFORM_WORK) {
|
|
174
|
+
throw new LimitError();
|
|
175
|
+
}
|
|
176
|
+
const values = await snippets.transformVariables(jobs, current);
|
|
177
|
+
if (values === null || !current()) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
drafts.forEach((draft, index) => {
|
|
181
|
+
const raw = template.render(draft.resolve, slots[index].map(slot => values[slot]), {
|
|
182
|
+
indentVariables: true
|
|
183
|
+
});
|
|
184
|
+
const parsed = indentSnippet(raw, textBeforeOf(editor, draft.placement.range.start));
|
|
185
|
+
if (parsed.limited === true) {
|
|
186
|
+
throw new LimitError();
|
|
187
|
+
}
|
|
188
|
+
draft.raw = raw;
|
|
189
|
+
draft.parsed = parsed;
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
/* Starts the session on the tab stops, or puts the caret after the primary insertion when there are none. */
|
|
193
|
+
function place(language, drafts, starts, nested) {
|
|
194
|
+
const {
|
|
195
|
+
editor,
|
|
196
|
+
snippets
|
|
197
|
+
} = language;
|
|
198
|
+
const primary = drafts.at(-1).parsed;
|
|
199
|
+
if (primary.stops.length > 0) {
|
|
200
|
+
snippets.beginInstances(drafts.map((draft, index) => ({
|
|
201
|
+
start: starts[index],
|
|
202
|
+
text: draft.parsed.text,
|
|
203
|
+
stops: tabOrder(draft.parsed),
|
|
204
|
+
fields: draft.parsed.fields
|
|
205
|
+
})), {
|
|
206
|
+
nested
|
|
207
|
+
});
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
editor.setSelections(drafts.map((draft, index) => {
|
|
211
|
+
const end = positionInInsertion(starts[index], draft.parsed.text, draft.parsed.text.length);
|
|
212
|
+
return {
|
|
213
|
+
start: end,
|
|
214
|
+
end
|
|
215
|
+
};
|
|
216
|
+
}));
|
|
217
|
+
}
|
package/dist/snippet.d.ts
CHANGED
|
@@ -28,11 +28,17 @@ export interface ParsedSnippet {
|
|
|
28
28
|
})[];
|
|
29
29
|
}
|
|
30
30
|
export type SnippetVariableResolver = (name: string) => string | undefined;
|
|
31
|
+
export interface SnippetRenderOptions {
|
|
32
|
+
readonly indentVariables?: boolean;
|
|
33
|
+
}
|
|
31
34
|
export interface SnippetTemplate {
|
|
32
35
|
readonly source: string;
|
|
33
|
-
render(resolve?: SnippetVariableResolver, variableTransforms?: readonly string[]): ParsedSnippet;
|
|
36
|
+
render(resolve?: SnippetVariableResolver, variableTransforms?: readonly string[], options?: SnippetRenderOptions): ParsedSnippet;
|
|
34
37
|
}
|
|
35
38
|
export declare const MAX_SNIPPET_TEXT = 100000;
|
|
36
39
|
export declare function compileSnippet(source: string): SnippetTemplate;
|
|
37
40
|
export declare function parseSnippet(snippet: string, resolve?: SnippetVariableResolver): ParsedSnippet;
|
|
38
41
|
export declare function tabOrder(parsed: ParsedSnippet): SnippetStop[];
|
|
42
|
+
export declare function snippetVariables(source: string): ReadonlySet<string>;
|
|
43
|
+
export declare function checkSnippet(source: string): string | null;
|
|
44
|
+
export declare function escapeSnippetText(text: string): string;
|
package/dist/snippet.js
CHANGED
|
@@ -168,6 +168,11 @@ class SnippetParser {
|
|
|
168
168
|
return values;
|
|
169
169
|
}
|
|
170
170
|
}
|
|
171
|
+
/* The lines of a value after its first, behind the whitespace the last line of `before` starts with; blank lines stay blank. */
|
|
172
|
+
function indentLines(value, before) {
|
|
173
|
+
const prefix = /^[\t ]*/.exec(before.slice(before.lastIndexOf('\n') + 1))[0];
|
|
174
|
+
return prefix === '' ? value : value.replace(/(\r\n|\r|\n)(?=[^\r\n])/g, `$1${prefix}`);
|
|
175
|
+
}
|
|
171
176
|
function renderSnippet(nodes, variableTransforms) {
|
|
172
177
|
const definitions = new Map();
|
|
173
178
|
const collect = nodes => {
|
|
@@ -216,7 +221,8 @@ function renderSnippet(nodes, variableTransforms) {
|
|
|
216
221
|
const order = occurrence++;
|
|
217
222
|
if (node.kind === 'text') {
|
|
218
223
|
const start = text.length;
|
|
219
|
-
const
|
|
224
|
+
const given = node.transform === undefined || variableTransforms === undefined ? node.text : variableTransforms[transforms.length];
|
|
225
|
+
const value = node.indent === true && typeof given === 'string' ? indentLines(given, text) : given;
|
|
220
226
|
if (typeof value !== 'string') {
|
|
221
227
|
throw new RangeError('The variable transform count does not match the snippet.');
|
|
222
228
|
}
|
|
@@ -352,7 +358,7 @@ function variableIndices(nodes) {
|
|
|
352
358
|
return [name, highest];
|
|
353
359
|
}));
|
|
354
360
|
}
|
|
355
|
-
function resolveVariables(nodes, indices, resolve) {
|
|
361
|
+
function resolveVariables(nodes, indices, resolve, indentVariables = false) {
|
|
356
362
|
const unknown = new Map();
|
|
357
363
|
const values = new Map();
|
|
358
364
|
const valueOf = name => {
|
|
@@ -408,10 +414,16 @@ function resolveVariables(nodes, indices, resolve) {
|
|
|
408
414
|
transform: node.transform
|
|
409
415
|
}];
|
|
410
416
|
}
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
417
|
+
if (value !== undefined && value !== '') {
|
|
418
|
+
return [{
|
|
419
|
+
kind: 'text',
|
|
420
|
+
text: value,
|
|
421
|
+
...(indentVariables ? {
|
|
422
|
+
indent: true
|
|
423
|
+
} : {})
|
|
424
|
+
}];
|
|
425
|
+
}
|
|
426
|
+
return normalize(node.body ?? [], visiting, depth + 1);
|
|
415
427
|
}
|
|
416
428
|
if (node.kind === 'field') {
|
|
417
429
|
return [{
|
|
@@ -464,7 +476,7 @@ export function compileSnippet(source) {
|
|
|
464
476
|
}
|
|
465
477
|
return {
|
|
466
478
|
source,
|
|
467
|
-
render: (resolve, variableTransforms) => {
|
|
479
|
+
render: (resolve, variableTransforms, options) => {
|
|
468
480
|
if (nodes === null) {
|
|
469
481
|
return {
|
|
470
482
|
text: source,
|
|
@@ -473,7 +485,7 @@ export function compileSnippet(source) {
|
|
|
473
485
|
};
|
|
474
486
|
}
|
|
475
487
|
try {
|
|
476
|
-
return renderSnippet(resolveVariables(nodes, indices, resolve), variableTransforms);
|
|
488
|
+
return renderSnippet(resolveVariables(nodes, indices, resolve, options?.indentVariables === true), variableTransforms);
|
|
477
489
|
} catch (error) {
|
|
478
490
|
if (!(error instanceof SnippetLimitError)) {
|
|
479
491
|
throw error;
|
|
@@ -518,4 +530,49 @@ export function tabOrder(parsed) {
|
|
|
518
530
|
start: parsed.text.length,
|
|
519
531
|
end: parsed.text.length
|
|
520
532
|
}];
|
|
533
|
+
}
|
|
534
|
+
/* The names of the variables a snippet uses, the editable ones that are no built-in too. A source that does not parse names none. */
|
|
535
|
+
export function snippetVariables(source) {
|
|
536
|
+
const names = new Set();
|
|
537
|
+
const scan = nodes => {
|
|
538
|
+
for (const node of nodes) {
|
|
539
|
+
if (node.kind === 'variable') {
|
|
540
|
+
names.add(node.name);
|
|
541
|
+
}
|
|
542
|
+
if (node.kind !== 'text' && node.body !== undefined) {
|
|
543
|
+
scan(node.body);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
};
|
|
547
|
+
if (source.length > MAX_SNIPPET_TEXT) {
|
|
548
|
+
return names;
|
|
549
|
+
}
|
|
550
|
+
try {
|
|
551
|
+
scan(new SnippetParser(source).parse());
|
|
552
|
+
} catch {
|
|
553
|
+
return new Set();
|
|
554
|
+
}
|
|
555
|
+
return names;
|
|
556
|
+
}
|
|
557
|
+
/* Why a snippet cannot be used, in English for a host to show, or null: it does not parse, or it is over the limits an insertion keeps. */
|
|
558
|
+
export function checkSnippet(source) {
|
|
559
|
+
if (source.length > MAX_SNIPPET_TEXT) {
|
|
560
|
+
return `The text is longer than ${MAX_SNIPPET_TEXT} characters.`;
|
|
561
|
+
}
|
|
562
|
+
try {
|
|
563
|
+
new SnippetParser(source).parse();
|
|
564
|
+
} catch (error) {
|
|
565
|
+
if (error instanceof MalformedSnippetError) {
|
|
566
|
+
return 'A transform in the text is not valid.';
|
|
567
|
+
}
|
|
568
|
+
if (error instanceof SnippetLimitError) {
|
|
569
|
+
return 'The text has too many parts or is nested too deeply.';
|
|
570
|
+
}
|
|
571
|
+
throw error;
|
|
572
|
+
}
|
|
573
|
+
return compileSnippet(source).render().limited === true ? 'The text expands to more than a snippet can hold.' : null;
|
|
574
|
+
}
|
|
575
|
+
/* Escapes `\`, `$` and `}`, so the text reads back as itself in a snippet. */
|
|
576
|
+
export function escapeSnippetText(text) {
|
|
577
|
+
return text.replace(/[\\$}]/g, '\\$&');
|
|
521
578
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { CommentSyntax } from '@adecore/editor';
|
|
2
|
+
import type { TemplateLanguage } from './templates.ts';
|
|
3
|
+
export interface Construct {
|
|
4
|
+
readonly id: string;
|
|
5
|
+
readonly label: string;
|
|
6
|
+
readonly body: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function statementConstructs(families: readonly TemplateLanguage[]): readonly Construct[];
|
|
9
|
+
export declare function expressionConstructs(families: readonly TemplateLanguage[]): readonly Construct[];
|
|
10
|
+
export declare function regionConstructs(syntax: CommentSyntax): readonly Construct[];
|