@adecore/editor-react 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.
Files changed (63) hide show
  1. package/dist/PickPopup.js +94 -73
  2. package/dist/completion.d.ts +28 -0
  3. package/dist/completion.js +76 -15
  4. package/dist/editor-language.d.ts +4 -0
  5. package/dist/editor-language.js +6 -0
  6. package/dist/host-types.d.ts +4 -0
  7. package/dist/index.d.ts +2 -0
  8. package/dist/index.js +2 -0
  9. package/dist/key-priority.d.ts +1 -0
  10. package/dist/key-priority.js +2 -0
  11. package/dist/live-templates.d.ts +16 -0
  12. package/dist/live-templates.js +289 -0
  13. package/dist/locales/en.json +30 -0
  14. package/dist/locales/nl.json +30 -0
  15. package/dist/markup-context.d.ts +18 -0
  16. package/dist/markup-context.js +122 -5
  17. package/dist/markup.d.ts +3 -0
  18. package/dist/markup.js +16 -4
  19. package/dist/pick.d.ts +1 -0
  20. package/dist/pick.js +11 -1
  21. package/dist/popups.d.ts +1 -0
  22. package/dist/postfix.d.ts +21 -0
  23. package/dist/postfix.js +212 -0
  24. package/dist/project-language.d.ts +2 -0
  25. package/dist/project-language.js +3 -0
  26. package/dist/shortcut-keys.d.ts +5 -0
  27. package/dist/snippet-insert.d.ts +10 -0
  28. package/dist/snippet-insert.js +217 -0
  29. package/dist/snippet.d.ts +7 -1
  30. package/dist/snippet.js +65 -8
  31. package/dist/surround-constructs.d.ts +10 -0
  32. package/dist/surround-constructs.js +100 -0
  33. package/dist/surround.d.ts +18 -0
  34. package/dist/surround.js +399 -0
  35. package/dist/template-builtins.d.ts +3 -0
  36. package/dist/template-builtins.js +46 -0
  37. package/dist/template-context.d.ts +12 -0
  38. package/dist/template-context.js +56 -0
  39. package/dist/templates.d.ts +59 -0
  40. package/dist/templates.js +103 -0
  41. package/package.json +6 -6
  42. package/src/PickPopup.tsx +7 -0
  43. package/src/completion.ts +111 -13
  44. package/src/editor-language.ts +6 -0
  45. package/src/host-types.ts +7 -0
  46. package/src/index.ts +16 -0
  47. package/src/key-priority.ts +2 -0
  48. package/src/live-templates.ts +315 -0
  49. package/src/locales/en.json +30 -0
  50. package/src/locales/nl.json +30 -0
  51. package/src/markup-context.ts +131 -6
  52. package/src/markup.ts +15 -5
  53. package/src/pick.ts +17 -1
  54. package/src/popups.ts +2 -0
  55. package/src/postfix.ts +224 -0
  56. package/src/project-language.ts +3 -0
  57. package/src/snippet-insert.ts +258 -0
  58. package/src/snippet.ts +82 -7
  59. package/src/surround-constructs.ts +85 -0
  60. package/src/surround.ts +430 -0
  61. package/src/template-builtins.ts +94 -0
  62. package/src/template-context.ts +65 -0
  63. 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
+ }
@@ -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
- | { kind: 'text'; text: string; transform?: SnippetTransform }
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 value = node.transform === undefined || variableTransforms === undefined ? node.text : variableTransforms[transforms.length];
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
- return value !== undefined && value !== '' ? [{ kind: 'text', text: value }] : normalize(node.body ?? [], visiting, depth + 1);
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
+ }