@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.
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
@@ -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 {};
@@ -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;
@@ -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));
@@ -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 value = node.transform === undefined || variableTransforms === undefined ? node.text : variableTransforms[transforms.length];
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
- return value !== undefined && value !== '' ? [{
412
- kind: 'text',
413
- text: value
414
- }] : normalize(node.body ?? [], visiting, depth + 1);
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[];