@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
package/src/postfix.ts
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import type { EditorPosition, EditorPostfixOperand, EditorRange } from '@adecore/editor';
|
|
2
|
+
import type { CompletionItem } from '@adecore/lsp';
|
|
3
|
+
import type { LocalCompletionItem } from './completion.ts';
|
|
4
|
+
import { isIdentifierCharacter } from './completion-model.ts';
|
|
5
|
+
import { comparePositions } from './diagnostics-model.ts';
|
|
6
|
+
import type { EditorLanguage } from './editor-language.ts';
|
|
7
|
+
import { lineTextOf, textBeforeOf } from './line-text.ts';
|
|
8
|
+
import type { PickRow } from './popups.ts';
|
|
9
|
+
import { bodyWithIndentUnit, dedentLines, indentUnitOf, insertSnippet } from './snippet-insert.ts';
|
|
10
|
+
import { compileSnippet } from './snippet.ts';
|
|
11
|
+
import { SNIPPET_KIND, sortAfterServer, templateContextAt } from './template-context.ts';
|
|
12
|
+
import type { PostfixTemplate } from './templates.ts';
|
|
13
|
+
|
|
14
|
+
const MAX_ROW_LENGTH = 80;
|
|
15
|
+
|
|
16
|
+
/* The postfix templates that apply to the expressions before one dot. */
|
|
17
|
+
interface Candidates {
|
|
18
|
+
readonly dot: EditorPosition;
|
|
19
|
+
readonly operands: readonly EditorPostfixOperand[];
|
|
20
|
+
readonly templates: readonly PostfixTemplate[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/* A key Tab can expand, starting at the dot that leads it. */
|
|
24
|
+
interface PostfixKey {
|
|
25
|
+
readonly start: EditorPosition;
|
|
26
|
+
expand(): boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/* The identifier characters after the last dot of `before`, and where that dot is; null when no dot leads them. */
|
|
30
|
+
function typedKey(before: string): { readonly key: string; readonly dot: number } | null {
|
|
31
|
+
let index = before.length;
|
|
32
|
+
|
|
33
|
+
while (index > 0 && isIdentifierCharacter(before[index - 1]!)) {
|
|
34
|
+
index--;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return index > 0 && before[index - 1] === '.' ? { key: before.slice(index), dot: index - 1 } : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/* One line of a chooser row: whitespace collapsed and cut short. */
|
|
41
|
+
function rowLabel(text: string): string {
|
|
42
|
+
const line = text.replace(/\s+/g, ' ').trim();
|
|
43
|
+
|
|
44
|
+
return line.length > MAX_ROW_LENGTH ? `${line.slice(0, MAX_ROW_LENGTH - 1)}…` : line;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/*
|
|
48
|
+
* Postfix completion: a dot and a key after an expression, and Tab or an accepted row wraps the expression in a
|
|
49
|
+
* template. The registry holds the templates and `TemplatesFeature` decides whether Tab is ours or a live template's.
|
|
50
|
+
*/
|
|
51
|
+
export class PostfixFeature {
|
|
52
|
+
private readonly language: EditorLanguage;
|
|
53
|
+
|
|
54
|
+
constructor(language: EditorLanguage) {
|
|
55
|
+
this.language = language;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/* The key before the caret that a template of the registry applies to; null where Tab has no postfix template to expand. */
|
|
59
|
+
key(): PostfixKey | null {
|
|
60
|
+
const { editor, project } = this.language;
|
|
61
|
+
|
|
62
|
+
if (!project.templates.settings.postfix) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const caret = editor.getCaret();
|
|
67
|
+
const typed = typedKey(textBeforeOf(editor, caret));
|
|
68
|
+
|
|
69
|
+
if (typed === null || typed.key === '') {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const candidates = this.candidates({ line: caret.line, character: typed.dot }, (template) => template.key === typed.key);
|
|
74
|
+
|
|
75
|
+
if (candidates === null) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return { start: candidates.dot, expand: () => this.expandKey(candidates, caret) };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/* The rows of the suggestion list after a dot, for the templates whose key the typed characters can lead to. */
|
|
83
|
+
rows(position: EditorPosition): readonly LocalCompletionItem[] {
|
|
84
|
+
const { editor, project } = this.language;
|
|
85
|
+
const { settings } = project.templates;
|
|
86
|
+
const selections = editor.getSelections();
|
|
87
|
+
|
|
88
|
+
if (!settings.postfix || !settings.postfixInCompletion || selections.length !== 1 || comparePositions(selections[0]!.start, selections[0]!.end) !== 0) {
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const typed = typedKey(textBeforeOf(editor, position));
|
|
93
|
+
|
|
94
|
+
if (typed === null) {
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const candidates = this.candidates({ line: position.line, character: typed.dot }, (template) => template.key.startsWith(typed.key));
|
|
99
|
+
|
|
100
|
+
if (candidates === null) {
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const range = { start: { line: position.line, character: typed.dot + 1 }, end: position };
|
|
105
|
+
|
|
106
|
+
return candidates.templates.map((template) => this.row(template, candidates, range, sortAfterServer(template.key)));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private row(template: PostfixTemplate, candidates: Candidates, range: EditorRange, sortText: string): LocalCompletionItem {
|
|
110
|
+
const item: CompletionItem = {
|
|
111
|
+
label: template.key,
|
|
112
|
+
kind: SNIPPET_KIND,
|
|
113
|
+
detail: template.example,
|
|
114
|
+
documentation: template.description,
|
|
115
|
+
filterText: template.key,
|
|
116
|
+
sortText,
|
|
117
|
+
textEdit: { range, newText: template.key }
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
return { item, accept: () => this.expand(template, candidates, this.language.editor.getCaret()) };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/* The templates `matches` lets through that apply to the expressions before `dot`; null when none does. */
|
|
124
|
+
private candidates(dot: EditorPosition, matches: (template: PostfixTemplate) => boolean): Candidates | null {
|
|
125
|
+
const { editor, project } = this.language;
|
|
126
|
+
const operands = editor.postfixOperands?.(dot) ?? [];
|
|
127
|
+
|
|
128
|
+
if (operands.length === 0) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const context = templateContextAt(this.language, dot);
|
|
133
|
+
|
|
134
|
+
if (context.place !== 'code') {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const widest = operands.at(-1)!;
|
|
139
|
+
|
|
140
|
+
const templates = project.templates.postfix.filter(
|
|
141
|
+
(template) =>
|
|
142
|
+
matches(template) &&
|
|
143
|
+
template.languages.some((family) => context.families.includes(family)) &&
|
|
144
|
+
(template.expression !== 'statement' || widest.statement)
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
return templates.length === 0 ? null : { dot, operands, templates };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/* Expands the one template of a key, or lists them when several share it. */
|
|
151
|
+
private expandKey(candidates: Candidates, caret: EditorPosition): boolean {
|
|
152
|
+
if (candidates.templates.length === 1) {
|
|
153
|
+
this.expand(candidates.templates[0]!, candidates, caret);
|
|
154
|
+
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const start = { line: candidates.dot.line, character: candidates.dot.character + 1 };
|
|
159
|
+
|
|
160
|
+
this.language.completion.showItems(
|
|
161
|
+
candidates.templates.map((template) => this.row(template, candidates, { start, end: caret }, template.id)),
|
|
162
|
+
start
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/* Wraps the widest expression for a statement, the only one there is, or the one a person picks from the expressions. */
|
|
169
|
+
private expand(template: PostfixTemplate, candidates: Candidates, caret: EditorPosition): void {
|
|
170
|
+
const { operands } = candidates;
|
|
171
|
+
|
|
172
|
+
if (template.expression === 'statement' || operands.length === 1) {
|
|
173
|
+
void this.run(template, template.expression === 'statement' ? operands.at(-1)! : operands[0]!, caret);
|
|
174
|
+
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const { editor, completion, pick, project } = this.language;
|
|
179
|
+
const rows = operands.map((operand, index): PickRow => ({ id: String(index), label: rowLabel(editor.textInRange(operand.range)), detail: '' }));
|
|
180
|
+
|
|
181
|
+
completion.close();
|
|
182
|
+
|
|
183
|
+
pick.open({
|
|
184
|
+
anchor: caret,
|
|
185
|
+
title: project.i18n.t('editor:language.postfix.expressions'),
|
|
186
|
+
groups: [{ title: null, rows }],
|
|
187
|
+
accept: (id) => void this.run(template, operands[Number(id)]!, caret),
|
|
188
|
+
preview: (id) => Promise.resolve({ removed: [], added: this.rendered(template, operands[Number(id)]!).split('\n'), note: null, kind: 'text' })
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private run(template: PostfixTemplate, operand: EditorPostfixOperand, caret: EditorPosition): Promise<boolean> {
|
|
193
|
+
const { editor } = this.language;
|
|
194
|
+
const expression = this.expressionOf(operand);
|
|
195
|
+
|
|
196
|
+
return insertSnippet(
|
|
197
|
+
this.language,
|
|
198
|
+
bodyWithIndentUnit(template.body, indentUnitOf(editor)),
|
|
199
|
+
[{ range: { start: operand.range.start, end: caret } }],
|
|
200
|
+
(name) => (name === 'EXPR' ? expression : undefined)
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/* What the template would put in place of the expression, tab stops at their placeholders. */
|
|
205
|
+
private rendered(template: PostfixTemplate, operand: EditorPostfixOperand): string {
|
|
206
|
+
const expression = this.expressionOf(operand);
|
|
207
|
+
|
|
208
|
+
return compileSnippet(bodyWithIndentUnit(template.body, indentUnitOf(this.language.editor))).render(
|
|
209
|
+
(name) => (name === 'EXPR' ? expression : undefined),
|
|
210
|
+
undefined,
|
|
211
|
+
{
|
|
212
|
+
indentVariables: true
|
|
213
|
+
}
|
|
214
|
+
).text;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/* The text of an expression, with the indentation of its first line taken off the lines after it. */
|
|
218
|
+
private expressionOf(operand: EditorPostfixOperand): string {
|
|
219
|
+
const { editor } = this.language;
|
|
220
|
+
const indent = /^[\t ]*/.exec(lineTextOf(editor, operand.range.start.line))![0];
|
|
221
|
+
|
|
222
|
+
return dedentLines(editor.textInRange(operand.range).trimEnd(), indent);
|
|
223
|
+
}
|
|
224
|
+
}
|
package/src/project-language.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
} from '@adecore/lsp';
|
|
15
15
|
import type { Editor, EditorPosition } from '@adecore/editor';
|
|
16
16
|
import { ClipboardHistory } from './clipboard-history.ts';
|
|
17
|
+
import { TemplateRegistry } from './templates.ts';
|
|
17
18
|
import { EditLocations } from './edit-locations.ts';
|
|
18
19
|
import { NavigationHistory, type Place } from './navigation-history.ts';
|
|
19
20
|
import { ProjectProblems, serviceProblems } from './project-problems.ts';
|
|
@@ -128,6 +129,7 @@ export class ProjectLanguage {
|
|
|
128
129
|
readonly history = new NavigationHistory();
|
|
129
130
|
readonly edits: EditLocations;
|
|
130
131
|
readonly clipboard: ClipboardHistory;
|
|
132
|
+
readonly templates: TemplateRegistry;
|
|
131
133
|
private readonly holders = new Map<string, Holder>();
|
|
132
134
|
private readonly files: ProjectFiles | null;
|
|
133
135
|
|
|
@@ -150,6 +152,7 @@ export class ProjectLanguage {
|
|
|
150
152
|
this.files = host.files ?? null;
|
|
151
153
|
this.edits = new EditLocations(host.editHistoryLimit);
|
|
152
154
|
this.clipboard = host.clipboard ?? new ClipboardHistory();
|
|
155
|
+
this.templates = host.templates ?? new TemplateRegistry();
|
|
153
156
|
this.panelBodyHeight = host.panelBodyHeight ?? PANEL_BODY_HEIGHT;
|
|
154
157
|
this.problems = new ProjectProblems(host.problemSource ?? serviceProblems(service, (uri) => this.pathOfLocation(uri) ?? uri));
|
|
155
158
|
const shows = service.onShowDocument?.((params) => this.showDocument(params));
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { SnippetTransformQueueError } from './snippet-transform-queue.ts';
|
|
2
|
+
import type { Editor, EditorContentChange, EditorPosition, EditorRange } from '@adecore/editor';
|
|
3
|
+
import { comparePositions, shiftPosition } from './diagnostics-model.ts';
|
|
4
|
+
import type { EditorLanguage } from './editor-language.ts';
|
|
5
|
+
import { lineTextOf, textBeforeOf } from './line-text.ts';
|
|
6
|
+
import { wordRangeAt } from './rename-model.ts';
|
|
7
|
+
import { jobTable } from './snippet-field-transforms.ts';
|
|
8
|
+
import { indentSnippet } from './snippet-indent.ts';
|
|
9
|
+
import { positionInInsertion } from './snippet-session.ts';
|
|
10
|
+
import { SnippetTransformError } from './snippet-transform-client.ts';
|
|
11
|
+
import { MAX_TRANSFORM_JOBS, MAX_TRANSFORM_WORK } from './snippet-transform-protocol.ts';
|
|
12
|
+
import { compileSnippet, tabOrder, type ParsedSnippet, type SnippetTemplate } from './snippet.ts';
|
|
13
|
+
import { createSnippetResolver } from './snippet-variables.ts';
|
|
14
|
+
import { fileUriToPath } from '@adecore/lsp';
|
|
15
|
+
|
|
16
|
+
/* The same limits as an inserted completion item. */
|
|
17
|
+
const MAX_CHARACTERS = 2_000_000;
|
|
18
|
+
const MAX_RANGES = 1_000;
|
|
19
|
+
const TOAST_ID = 'language-completion';
|
|
20
|
+
|
|
21
|
+
export interface SnippetPlacement {
|
|
22
|
+
readonly range: EditorRange;
|
|
23
|
+
|
|
24
|
+
/* The value of `TM_SELECTED_TEXT` at this placement; empty without it. */
|
|
25
|
+
readonly selectedText?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
class LimitError extends Error {}
|
|
29
|
+
|
|
30
|
+
interface Draft {
|
|
31
|
+
readonly placement: SnippetPlacement;
|
|
32
|
+
readonly resolve: (name: string) => string | undefined;
|
|
33
|
+
|
|
34
|
+
/* As rendered, which is the text the variable transforms read. */
|
|
35
|
+
raw: ParsedSnippet;
|
|
36
|
+
|
|
37
|
+
/* Indented to the text before the placement. */
|
|
38
|
+
parsed: ParsedSnippet;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/* Each line's leading tabs become the editor's own indentation unit. */
|
|
42
|
+
export function bodyWithIndentUnit(body: string, unit: string): string {
|
|
43
|
+
return unit === '\t' ? body : body.replace(/^\t+/gm, (tabs) => unit.repeat(tabs.length));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* One level of the editor's indentation: spaces for a tab size, or a tab. */
|
|
47
|
+
export function indentUnitOf(editor: Editor): string {
|
|
48
|
+
const { tabSize, insertSpaces } = editor.getIndentation();
|
|
49
|
+
|
|
50
|
+
return insertSpaces ? ' '.repeat(tabSize) : '\t';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/* The lines after the first without `base` in front of them, or without the shortest indentation among them. */
|
|
54
|
+
export function dedentLines(text: string, base?: string): string {
|
|
55
|
+
const lines = text.split(/\r\n|\r|\n/).slice(1);
|
|
56
|
+
const indents = lines.filter((line) => line.trim() !== '').map((line) => /^[\t ]*/.exec(line)![0].length);
|
|
57
|
+
const removed = base?.length ?? (indents.length === 0 ? 0 : Math.min(...indents));
|
|
58
|
+
|
|
59
|
+
return removed === 0
|
|
60
|
+
? text
|
|
61
|
+
: text.replace(
|
|
62
|
+
/(\r\n|\r|\n)([^\r\n]*)/g,
|
|
63
|
+
(_match, lineBreak: string, line: string) => lineBreak + line.slice(Math.min(removed, /^[\t ]*/.exec(line)![0].length))
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function sizeOf(parsed: ParsedSnippet): number {
|
|
68
|
+
return (
|
|
69
|
+
(parsed.transforms?.length ?? 0) +
|
|
70
|
+
(parsed.fields?.reduce((count, field) => count + field.ranges.length + (field.linked?.length ?? 0), 1) ?? parsed.stops.length)
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/*
|
|
75
|
+
* Inserts a snippet at every placement, the last of them the primary one, and starts a session on its tab stops. The
|
|
76
|
+
* variables are resolved for each placement (`extra` first, then the host's and the built-in ones), the lines are indented
|
|
77
|
+
* to the text before the placement, and a running session takes the insertion as a nested one. False when nothing was inserted.
|
|
78
|
+
*/
|
|
79
|
+
export async function insertSnippet(
|
|
80
|
+
language: EditorLanguage,
|
|
81
|
+
source: string,
|
|
82
|
+
placements: readonly SnippetPlacement[],
|
|
83
|
+
extra?: (name: string) => string | undefined
|
|
84
|
+
): Promise<boolean> {
|
|
85
|
+
const { editor, project, uri, snippets } = language;
|
|
86
|
+
|
|
87
|
+
if (placements.length === 0 || editor.isReadOnly?.() === true) {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const template: SnippetTemplate = compileSnippet(source);
|
|
92
|
+
const now = new Date();
|
|
93
|
+
const token = snippets.token;
|
|
94
|
+
const continuing = snippets.isActive;
|
|
95
|
+
let stale = false;
|
|
96
|
+
const off = editor.onTextChange(() => {
|
|
97
|
+
stale = true;
|
|
98
|
+
});
|
|
99
|
+
const current = (): boolean => !stale && snippets.token === token;
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
let characters = 0;
|
|
103
|
+
let ranges = 0;
|
|
104
|
+
|
|
105
|
+
const drafts = placements.map((placement, index): Draft => {
|
|
106
|
+
const line = lineTextOf(editor, placement.range.start.line);
|
|
107
|
+
const word = wordRangeAt(line, placement.range.start);
|
|
108
|
+
|
|
109
|
+
const resolver = createSnippetResolver(
|
|
110
|
+
{
|
|
111
|
+
uri,
|
|
112
|
+
languageId: language.languageId,
|
|
113
|
+
position: placement.range.start,
|
|
114
|
+
selection: placement.range,
|
|
115
|
+
selectedText: placement.selectedText ?? '',
|
|
116
|
+
currentLine: line,
|
|
117
|
+
currentWord: word === null ? '' : editor.textInRange(word),
|
|
118
|
+
cursorIndex: index,
|
|
119
|
+
cursorCount: placements.length,
|
|
120
|
+
folder: project.host.folder,
|
|
121
|
+
path: fileUriToPath(uri) ?? project.host.pathOfUri?.(uri) ?? undefined
|
|
122
|
+
},
|
|
123
|
+
project.host.resolveSnippetVariable?.bind(project.host),
|
|
124
|
+
{ now }
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const resolve = (name: string): string | undefined => extra?.(name) ?? resolver(name);
|
|
128
|
+
|
|
129
|
+
const raw = template.render(resolve, undefined, { indentVariables: true });
|
|
130
|
+
|
|
131
|
+
return { placement, resolve, raw, parsed: indentSnippet(raw, textBeforeOf(editor, placement.range.start)) };
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
for (const { parsed } of drafts) {
|
|
135
|
+
characters += parsed.text.length;
|
|
136
|
+
ranges += sizeOf(parsed);
|
|
137
|
+
|
|
138
|
+
if (parsed.limited === true || characters > MAX_CHARACTERS || ranges > MAX_RANGES) {
|
|
139
|
+
throw new LimitError();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
await transformVariables(language, template, drafts, current);
|
|
144
|
+
|
|
145
|
+
if (!current()) {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const changes = drafts.map((draft): EditorContentChange => ({ range: draft.placement.range, text: draft.parsed.text }));
|
|
150
|
+
|
|
151
|
+
if (changes.reduce((total, change) => total + change.text.length, 0) > MAX_CHARACTERS) {
|
|
152
|
+
throw new LimitError();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Insertions before one another move it, so each start is mapped through the changes in front of it.
|
|
156
|
+
const starts = changes.map((change) =>
|
|
157
|
+
changes
|
|
158
|
+
.filter((other) => other !== change && comparePositions(other.range.end, change.range.start) <= 0)
|
|
159
|
+
.sort((left, right) => comparePositions(right.range.start, left.range.start))
|
|
160
|
+
.reduce((place, other) => shiftPosition(place, other), change.range.start)
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
if (!(continuing ? snippets.applyEdits(changes, token) : editor.applyEdits(changes))) {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
off();
|
|
168
|
+
place(language, drafts, starts, continuing && snippets.isActive && snippets.token === token);
|
|
169
|
+
editor.focus();
|
|
170
|
+
|
|
171
|
+
return true;
|
|
172
|
+
} catch (error) {
|
|
173
|
+
if (error instanceof LimitError || (error instanceof SnippetTransformError && error.kind === 'range')) {
|
|
174
|
+
project.host.notify?.({ id: TOAST_ID, kind: 'error', title: project.i18n.t('editor:language.completion.tooLarge') });
|
|
175
|
+
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (error instanceof SnippetTransformQueueError) {
|
|
180
|
+
project.host.notify?.({ id: TOAST_ID, kind: 'error', title: project.i18n.t('editor:language.completion.transformBusy') });
|
|
181
|
+
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (error instanceof SnippetTransformError) {
|
|
186
|
+
project.host.notify?.({ id: TOAST_ID, kind: 'error', title: project.i18n.t('editor:language.completion.transformFailed') });
|
|
187
|
+
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
throw error;
|
|
192
|
+
} finally {
|
|
193
|
+
off();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/* Runs the transforms of variables through the worker, once for all placements, and renders them again with the answers. */
|
|
198
|
+
async function transformVariables(language: EditorLanguage, template: SnippetTemplate, drafts: Draft[], current: () => boolean): Promise<void> {
|
|
199
|
+
const { editor, project, snippets } = language;
|
|
200
|
+
const { jobs, slotOf } = jobTable();
|
|
201
|
+
const slots = drafts.map((draft) =>
|
|
202
|
+
(draft.raw.transforms ?? []).map((range) => slotOf({ ...range.transform, value: draft.raw.text.slice(range.start, range.end) }))
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
if (jobs.length === 0 || project.host.snippetTransformWorker === undefined) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (jobs.length > MAX_TRANSFORM_JOBS || jobs.reduce((sum, job) => sum + job.value.length, 0) > MAX_TRANSFORM_WORK) {
|
|
210
|
+
throw new LimitError();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const values = await snippets.transformVariables(jobs, current);
|
|
214
|
+
|
|
215
|
+
if (values === null || !current()) {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
drafts.forEach((draft, index) => {
|
|
220
|
+
const raw = template.render(
|
|
221
|
+
draft.resolve,
|
|
222
|
+
slots[index]!.map((slot) => values[slot]!),
|
|
223
|
+
{ indentVariables: true }
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
const parsed = indentSnippet(raw, textBeforeOf(editor, draft.placement.range.start));
|
|
227
|
+
|
|
228
|
+
if (parsed.limited === true) {
|
|
229
|
+
throw new LimitError();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
draft.raw = raw;
|
|
233
|
+
draft.parsed = parsed;
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/* Starts the session on the tab stops, or puts the caret after the primary insertion when there are none. */
|
|
238
|
+
function place(language: EditorLanguage, drafts: readonly Draft[], starts: readonly EditorPosition[], nested: boolean): void {
|
|
239
|
+
const { editor, snippets } = language;
|
|
240
|
+
const primary = drafts.at(-1)!.parsed;
|
|
241
|
+
|
|
242
|
+
if (primary.stops.length > 0) {
|
|
243
|
+
snippets.beginInstances(
|
|
244
|
+
drafts.map((draft, index) => ({ start: starts[index]!, text: draft.parsed.text, stops: tabOrder(draft.parsed), fields: draft.parsed.fields })),
|
|
245
|
+
{ nested }
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
editor.setSelections(
|
|
252
|
+
drafts.map((draft, index) => {
|
|
253
|
+
const end = positionInInsertion(starts[index]!, draft.parsed.text, draft.parsed.text.length);
|
|
254
|
+
|
|
255
|
+
return { start: end, end };
|
|
256
|
+
})
|
|
257
|
+
);
|
|
258
|
+
}
|
package/src/snippet.ts
CHANGED
|
@@ -40,15 +40,21 @@ export interface ParsedSnippet {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
type Node =
|
|
43
|
-
|
|
43
|
+
// `indent` marks the value of a variable whose lines after the first take the indentation of the line it starts on.
|
|
44
|
+
| { kind: 'text'; text: string; transform?: SnippetTransform; indent?: true }
|
|
44
45
|
| { kind: 'field'; index: number; body?: Node[]; choices?: string[]; transform?: SnippetTransform }
|
|
45
46
|
| { kind: 'variable'; name: string; body?: Node[]; transform?: SnippetTransform };
|
|
46
47
|
|
|
47
48
|
export type SnippetVariableResolver = (name: string) => string | undefined;
|
|
48
49
|
|
|
50
|
+
export interface SnippetRenderOptions {
|
|
51
|
+
/* The way VS Code treats `TM_SELECTED_TEXT`: the lines of a value after its first take the leading whitespace of the output line the variable starts on. */
|
|
52
|
+
readonly indentVariables?: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
49
55
|
export interface SnippetTemplate {
|
|
50
56
|
readonly source: string;
|
|
51
|
-
render(resolve?: SnippetVariableResolver, variableTransforms?: readonly string[]): ParsedSnippet;
|
|
57
|
+
render(resolve?: SnippetVariableResolver, variableTransforms?: readonly string[], options?: SnippetRenderOptions): ParsedSnippet;
|
|
52
58
|
}
|
|
53
59
|
|
|
54
60
|
type FieldNode = Extract<Node, { kind: 'field' }>;
|
|
@@ -235,6 +241,13 @@ class SnippetParser {
|
|
|
235
241
|
}
|
|
236
242
|
}
|
|
237
243
|
|
|
244
|
+
/* The lines of a value after its first, behind the whitespace the last line of `before` starts with; blank lines stay blank. */
|
|
245
|
+
function indentLines(value: string, before: string): string {
|
|
246
|
+
const prefix = /^[\t ]*/.exec(before.slice(before.lastIndexOf('\n') + 1))![0];
|
|
247
|
+
|
|
248
|
+
return prefix === '' ? value : value.replace(/(\r\n|\r|\n)(?=[^\r\n])/g, `$1${prefix}`);
|
|
249
|
+
}
|
|
250
|
+
|
|
238
251
|
function renderSnippet(nodes: readonly Node[], variableTransforms?: readonly string[]): ParsedSnippet {
|
|
239
252
|
const definitions = new Map<number, FieldNode>();
|
|
240
253
|
|
|
@@ -304,7 +317,8 @@ function renderSnippet(nodes: readonly Node[], variableTransforms?: readonly str
|
|
|
304
317
|
|
|
305
318
|
if (node.kind === 'text') {
|
|
306
319
|
const start = text.length;
|
|
307
|
-
const
|
|
320
|
+
const given = node.transform === undefined || variableTransforms === undefined ? node.text : variableTransforms[transforms.length];
|
|
321
|
+
const value = node.indent === true && typeof given === 'string' ? indentLines(given, text) : given;
|
|
308
322
|
|
|
309
323
|
if (typeof value !== 'string') {
|
|
310
324
|
throw new RangeError('The variable transform count does not match the snippet.');
|
|
@@ -453,7 +467,7 @@ function variableIndices(nodes: readonly Node[]): ReadonlyMap<string, number> {
|
|
|
453
467
|
);
|
|
454
468
|
}
|
|
455
469
|
|
|
456
|
-
function resolveVariables(nodes: readonly Node[], indices: ReadonlyMap<string, number>, resolve?: SnippetVariableResolver): Node[] {
|
|
470
|
+
function resolveVariables(nodes: readonly Node[], indices: ReadonlyMap<string, number>, resolve?: SnippetVariableResolver, indentVariables = false): Node[] {
|
|
457
471
|
const unknown = new Map<string, { index: number; body: Node[]; defaulted: boolean; definition: Node }>();
|
|
458
472
|
const values = new Map<string, string | undefined>();
|
|
459
473
|
|
|
@@ -513,7 +527,11 @@ function resolveVariables(nodes: readonly Node[], indices: ReadonlyMap<string, n
|
|
|
513
527
|
return [{ kind: 'text', text: value ?? '', transform: node.transform }];
|
|
514
528
|
}
|
|
515
529
|
|
|
516
|
-
|
|
530
|
+
if (value !== undefined && value !== '') {
|
|
531
|
+
return [{ kind: 'text', text: value, ...(indentVariables ? { indent: true as const } : {}) }];
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
return normalize(node.body ?? [], visiting, depth + 1);
|
|
517
535
|
}
|
|
518
536
|
|
|
519
537
|
if (node.kind === 'field') {
|
|
@@ -565,13 +583,13 @@ export function compileSnippet(source: string): SnippetTemplate {
|
|
|
565
583
|
|
|
566
584
|
return {
|
|
567
585
|
source,
|
|
568
|
-
render: (resolve, variableTransforms) => {
|
|
586
|
+
render: (resolve, variableTransforms, options) => {
|
|
569
587
|
if (nodes === null) {
|
|
570
588
|
return { text: source, stops: [], limited: true };
|
|
571
589
|
}
|
|
572
590
|
|
|
573
591
|
try {
|
|
574
|
-
return renderSnippet(resolveVariables(nodes, indices, resolve), variableTransforms);
|
|
592
|
+
return renderSnippet(resolveVariables(nodes, indices, resolve, options?.indentVariables === true), variableTransforms);
|
|
575
593
|
} catch (error) {
|
|
576
594
|
if (!(error instanceof SnippetLimitError)) {
|
|
577
595
|
throw error;
|
|
@@ -615,3 +633,60 @@ export function tabOrder(parsed: ParsedSnippet): SnippetStop[] {
|
|
|
615
633
|
|
|
616
634
|
return [...visited, first.get(0) ?? { index: 0, start: parsed.text.length, end: parsed.text.length }];
|
|
617
635
|
}
|
|
636
|
+
|
|
637
|
+
/* 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. */
|
|
638
|
+
export function snippetVariables(source: string): ReadonlySet<string> {
|
|
639
|
+
const names = new Set<string>();
|
|
640
|
+
|
|
641
|
+
const scan = (nodes: readonly Node[]): void => {
|
|
642
|
+
for (const node of nodes) {
|
|
643
|
+
if (node.kind === 'variable') {
|
|
644
|
+
names.add(node.name);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
if (node.kind !== 'text' && node.body !== undefined) {
|
|
648
|
+
scan(node.body);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
if (source.length > MAX_SNIPPET_TEXT) {
|
|
654
|
+
return names;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
try {
|
|
658
|
+
scan(new SnippetParser(source).parse());
|
|
659
|
+
} catch {
|
|
660
|
+
return new Set();
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
return names;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/* 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. */
|
|
667
|
+
export function checkSnippet(source: string): string | null {
|
|
668
|
+
if (source.length > MAX_SNIPPET_TEXT) {
|
|
669
|
+
return `The text is longer than ${MAX_SNIPPET_TEXT} characters.`;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
try {
|
|
673
|
+
new SnippetParser(source).parse();
|
|
674
|
+
} catch (error) {
|
|
675
|
+
if (error instanceof MalformedSnippetError) {
|
|
676
|
+
return 'A transform in the text is not valid.';
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
if (error instanceof SnippetLimitError) {
|
|
680
|
+
return 'The text has too many parts or is nested too deeply.';
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
throw error;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
return compileSnippet(source).render().limited === true ? 'The text expands to more than a snippet can hold.' : null;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/* Escapes `\`, `$` and `}`, so the text reads back as itself in a snippet. */
|
|
690
|
+
export function escapeSnippetText(text: string): string {
|
|
691
|
+
return text.replace(/[\\$}]/g, '\\$&');
|
|
692
|
+
}
|