@adecore/editor-react 0.42.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 (150) hide show
  1. package/dist/CallHierarchyPanel.js +177 -181
  2. package/dist/EditorContextMenu.js +118 -105
  3. package/dist/LanguagePopups.js +7 -0
  4. package/dist/PickPopup.js +245 -104
  5. package/dist/WrapCard.d.ts +8 -0
  6. package/dist/WrapCard.js +172 -0
  7. package/dist/call-hierarchy-model.d.ts +4 -2
  8. package/dist/call-hierarchy-model.js +20 -0
  9. package/dist/call-hierarchy.d.ts +3 -0
  10. package/dist/call-hierarchy.js +54 -22
  11. package/dist/clipboard-history.d.ts +31 -0
  12. package/dist/clipboard-history.js +70 -0
  13. package/dist/code-actions.d.ts +3 -1
  14. package/dist/code-actions.js +50 -10
  15. package/dist/code-vision.js +2 -3
  16. package/dist/completion.d.ts +28 -0
  17. package/dist/completion.js +83 -27
  18. package/dist/context-menu-rows.d.ts +1 -1
  19. package/dist/context-menu-rows.js +4 -0
  20. package/dist/definition-link.js +15 -5
  21. package/dist/document-colors.d.ts +24 -0
  22. package/dist/document-colors.js +194 -0
  23. package/dist/document-links.d.ts +22 -0
  24. package/dist/document-links.js +133 -0
  25. package/dist/document-query.d.ts +15 -0
  26. package/dist/document-query.js +54 -0
  27. package/dist/edit-locations.d.ts +20 -0
  28. package/dist/edit-locations.js +136 -0
  29. package/dist/editor-language.d.ts +23 -1
  30. package/dist/editor-language.js +83 -2
  31. package/dist/emmet.d.ts +13 -0
  32. package/dist/emmet.js +62 -0
  33. package/dist/folding.js +2 -3
  34. package/dist/format-on-type.js +3 -5
  35. package/dist/highlights.js +2 -3
  36. package/dist/history.d.ts +7 -0
  37. package/dist/history.js +79 -1
  38. package/dist/host-types.d.ts +10 -0
  39. package/dist/index.d.ts +13 -2
  40. package/dist/index.js +10 -0
  41. package/dist/inlay-hints.js +2 -2
  42. package/dist/inline-completion.d.ts +39 -0
  43. package/dist/inline-completion.js +221 -0
  44. package/dist/key-priority.d.ts +9 -5
  45. package/dist/key-priority.js +11 -6
  46. package/dist/linked-editing.d.ts +26 -0
  47. package/dist/linked-editing.js +215 -0
  48. package/dist/live-templates.d.ts +16 -0
  49. package/dist/live-templates.js +289 -0
  50. package/dist/locales/en.json +61 -2
  51. package/dist/locales/nl.json +61 -2
  52. package/dist/markup-context.d.ts +55 -0
  53. package/dist/markup-context.js +408 -0
  54. package/dist/markup.d.ts +39 -0
  55. package/dist/markup.js +465 -0
  56. package/dist/navigation.js +4 -6
  57. package/dist/paste-history.d.ts +7 -0
  58. package/dist/paste-history.js +104 -0
  59. package/dist/peek.js +3 -4
  60. package/dist/pick.d.ts +21 -2
  61. package/dist/pick.js +152 -19
  62. package/dist/popups.d.ts +10 -0
  63. package/dist/popups.js +2 -1
  64. package/dist/postfix.d.ts +21 -0
  65. package/dist/postfix.js +212 -0
  66. package/dist/project-language.d.ts +20 -1
  67. package/dist/project-language.js +132 -18
  68. package/dist/rename.js +8 -11
  69. package/dist/rendered-docs.js +1 -2
  70. package/dist/selection-ranges.js +2 -3
  71. package/dist/shortcut-keys.d.ts +24 -0
  72. package/dist/signature.js +3 -6
  73. package/dist/snippet-insert.d.ts +10 -0
  74. package/dist/snippet-insert.js +217 -0
  75. package/dist/snippet-session.d.ts +2 -2
  76. package/dist/snippet-session.js +3 -3
  77. package/dist/snippet.d.ts +7 -1
  78. package/dist/snippet.js +65 -8
  79. package/dist/surround-constructs.d.ts +10 -0
  80. package/dist/surround-constructs.js +100 -0
  81. package/dist/surround.d.ts +18 -0
  82. package/dist/surround.js +399 -0
  83. package/dist/symbol-picker.js +4 -6
  84. package/dist/symbols.js +22 -3
  85. package/dist/template-builtins.d.ts +3 -0
  86. package/dist/template-builtins.js +46 -0
  87. package/dist/template-context.d.ts +12 -0
  88. package/dist/template-context.js +56 -0
  89. package/dist/templates.d.ts +59 -0
  90. package/dist/templates.js +103 -0
  91. package/dist/testing.d.ts +23 -1
  92. package/dist/testing.js +114 -0
  93. package/package.json +7 -6
  94. package/src/CallHierarchyPanel.tsx +26 -11
  95. package/src/EditorContextMenu.tsx +7 -0
  96. package/src/LanguagePopups.tsx +4 -0
  97. package/src/PickPopup.tsx +47 -5
  98. package/src/WrapCard.tsx +64 -0
  99. package/src/call-hierarchy-model.ts +25 -3
  100. package/src/call-hierarchy.ts +62 -20
  101. package/src/clipboard-history.ts +102 -0
  102. package/src/code-actions.ts +48 -11
  103. package/src/code-vision.ts +3 -3
  104. package/src/completion.ts +123 -25
  105. package/src/context-menu-rows.ts +3 -2
  106. package/src/definition-link.ts +17 -5
  107. package/src/document-colors.ts +189 -0
  108. package/src/document-links.ts +129 -0
  109. package/src/document-query.ts +71 -0
  110. package/src/edit-locations.ts +162 -0
  111. package/src/editor-language.ts +86 -3
  112. package/src/emmet-module.d.ts +44 -0
  113. package/src/emmet.ts +73 -0
  114. package/src/folding.ts +3 -3
  115. package/src/format-on-type.ts +5 -5
  116. package/src/highlights.ts +3 -3
  117. package/src/history.ts +81 -1
  118. package/src/host-types.ts +17 -0
  119. package/src/index.ts +28 -3
  120. package/src/inlay-hints.ts +2 -2
  121. package/src/inline-completion.ts +221 -0
  122. package/src/key-priority.ts +11 -6
  123. package/src/linked-editing.ts +199 -0
  124. package/src/live-templates.ts +315 -0
  125. package/src/locales/en.json +61 -2
  126. package/src/locales/nl.json +61 -2
  127. package/src/markup-context.ts +387 -0
  128. package/src/markup.ts +377 -0
  129. package/src/navigation.ts +6 -6
  130. package/src/paste-history.ts +111 -0
  131. package/src/peek.ts +4 -4
  132. package/src/pick.ts +218 -20
  133. package/src/popups.ts +21 -1
  134. package/src/postfix.ts +224 -0
  135. package/src/project-language.ts +139 -19
  136. package/src/rename.ts +11 -11
  137. package/src/rendered-docs.ts +2 -2
  138. package/src/selection-ranges.ts +3 -3
  139. package/src/signature.ts +6 -6
  140. package/src/snippet-insert.ts +258 -0
  141. package/src/snippet-session.ts +9 -4
  142. package/src/snippet.ts +82 -7
  143. package/src/surround-constructs.ts +85 -0
  144. package/src/surround.ts +430 -0
  145. package/src/symbol-picker.ts +6 -6
  146. package/src/symbols.ts +30 -4
  147. package/src/template-builtins.ts +94 -0
  148. package/src/template-context.ts +65 -0
  149. package/src/templates.ts +197 -0
  150. package/src/testing.ts +126 -1
@@ -1,7 +1,21 @@
1
1
  import { disposeSnippetTransforms } from './snippet-transform-queue.ts';
2
2
  import i18next, { type i18n } from 'i18next';
3
- import { fileUriToPath, pathToFileUri, type ApplyWorkspaceEditResult, type ContentChange, type LanguageService, type WorkspaceEdit } from '@adecore/lsp';
3
+ import {
4
+ fileUriToPath,
5
+ pathToFileUri,
6
+ unservedLanguageService,
7
+ type ApplyWorkspaceEditResult,
8
+ type ContentChange,
9
+ type Disposable,
10
+ type LanguageService,
11
+ type ShowDocumentParams,
12
+ type ShowDocumentResult,
13
+ type WorkspaceEdit
14
+ } from '@adecore/lsp';
4
15
  import type { Editor, EditorPosition } from '@adecore/editor';
16
+ import { ClipboardHistory } from './clipboard-history.ts';
17
+ import { TemplateRegistry } from './templates.ts';
18
+ import { EditLocations } from './edit-locations.ts';
5
19
  import { NavigationHistory, type Place } from './navigation-history.ts';
6
20
  import { ProjectProblems, serviceProblems } from './project-problems.ts';
7
21
  import { applyWorkspaceEdit, type ProjectFiles } from './workspace-edit.ts';
@@ -16,6 +30,8 @@ export interface LanguageDocumentHandle {
16
30
  release(): void;
17
31
  }
18
32
 
33
+ const UNSERVED = unservedLanguageService();
34
+
19
35
  /* Sends one editor's edits to the document, holding them until the document is open. */
20
36
  class DocumentSync {
21
37
  readonly ready: Promise<void>;
@@ -76,10 +92,18 @@ class DocumentSync {
76
92
 
77
93
  interface Holder {
78
94
  readonly editors: Editor[];
79
- sync: DocumentSync;
95
+ /* The editors a server serves, in the order they came; the first one sends the changes. */
96
+ readonly served: Editor[];
97
+ /* Null while no editor of the document is served, so nothing of it is open in the service. */
98
+ sync: DocumentSync | null;
80
99
  languageId: string;
81
100
  }
82
101
 
102
+ export interface AcquireOptions {
103
+ /* False registers the editor as open without telling the service, for a document no server serves; true by default. */
104
+ readonly serve?: boolean;
105
+ }
106
+
83
107
  /* A file or folder for a file operation notification: a bare path is a file. */
84
108
  export type FileOperationTarget = string | { readonly path: string; readonly directory?: boolean };
85
109
 
@@ -103,6 +127,9 @@ export class ProjectLanguage {
103
127
  readonly folder: string;
104
128
  readonly problems: ProjectProblems;
105
129
  readonly history = new NavigationHistory();
130
+ readonly edits: EditLocations;
131
+ readonly clipboard: ClipboardHistory;
132
+ readonly templates: TemplateRegistry;
106
133
  private readonly holders = new Map<string, Holder>();
107
134
  private readonly files: ProjectFiles | null;
108
135
 
@@ -113,6 +140,9 @@ export class ProjectLanguage {
113
140
  private pendingCaret: Place | null = null;
114
141
  private lastPlace: Place | null = null;
115
142
  private disposed = false;
143
+ private readonly subscriptions: readonly Disposable[];
144
+ /* The result id the server gave each document in its last workspace report, which the next asks with. */
145
+ private readonly workspaceResults = new Map<string, string>();
116
146
 
117
147
  constructor(service: LanguageService, host: LanguageHost = {}) {
118
148
  this.service = service;
@@ -120,8 +150,14 @@ export class ProjectLanguage {
120
150
  this.i18n = host.i18n ?? i18next;
121
151
  this.folder = host.folder ?? '';
122
152
  this.files = host.files ?? null;
153
+ this.edits = new EditLocations(host.editHistoryLimit);
154
+ this.clipboard = host.clipboard ?? new ClipboardHistory();
155
+ this.templates = host.templates ?? new TemplateRegistry();
123
156
  this.panelBodyHeight = host.panelBodyHeight ?? PANEL_BODY_HEIGHT;
124
157
  this.problems = new ProjectProblems(host.problemSource ?? serviceProblems(service, (uri) => this.pathOfLocation(uri) ?? uri));
158
+ const shows = service.onShowDocument?.((params) => this.showDocument(params));
159
+ const messages = host.onServerMessage === undefined ? undefined : service.onServerMessage?.((message) => host.onServerMessage!(message));
160
+ this.subscriptions = [shows, messages].flatMap((subscription) => (subscription === undefined ? [] : [subscription]));
125
161
  }
126
162
 
127
163
  /* Open panels of other editors of the project follow a resize. */
@@ -182,20 +218,73 @@ export class ProjectLanguage {
182
218
 
183
219
  /* Tells the servers that files were deleted, once they are. */
184
220
  didDeleteFiles(targets: readonly FileOperationTarget[]): Promise<void> {
185
- return this.service.didDeleteFiles?.(filesOf(targets)) ?? Promise.resolve();
221
+ const files = filesOf(targets);
222
+
223
+ this.edits.forget((uri) => files.some((file) => (file.directory ? uri.startsWith(`${file.uri}/`) : uri === file.uri)));
224
+
225
+ return this.service.didDeleteFiles?.(files) ?? Promise.resolve();
186
226
  }
187
227
 
188
- /* The text of a file as it stands now: an open document's, else a draft's or the machine's; null when it is no text file. */
228
+ /*
229
+ * The text of a file as it stands now: an open document's, else a draft's or the machine's; null when it is no
230
+ * text file. A document of a scheme a server owns, such as a decompiled class, is read from that server.
231
+ */
189
232
  async readText(uri: string): Promise<string | null> {
190
- const open = this.holders.get(uri)?.sync.text;
233
+ const open = this.holders.get(uri)?.editors[0]?.getText();
191
234
 
192
235
  if (open !== undefined) {
193
236
  return open;
194
237
  }
195
238
 
196
239
  const path = fileUriToPath(uri);
240
+ if (path === null) {
241
+ return (await this.service.textDocumentContent?.(uri).catch(() => null)) ?? null;
242
+ }
197
243
 
198
- return path === null || this.files === null ? null : ((await this.files.read(path))?.text ?? null);
244
+ return this.files === null ? null : ((await this.files.read(path))?.text ?? null);
245
+ }
246
+
247
+ /*
248
+ * Asks the server of `uri` for the problems of every file it knows and lists them in `problems`, for a server that
249
+ * offers workspace diagnostics. A file the server reports as unchanged keeps what it had. False when no server offers them.
250
+ */
251
+ async pullWorkspaceProblems(uri: string, options: { readonly signal?: AbortSignal } = {}): Promise<boolean> {
252
+ const { service } = this;
253
+ if (service.workspaceDiagnostics === undefined || !service.supports('workspace/diagnostic', uri)) {
254
+ return false;
255
+ }
256
+ const previous = [...this.workspaceResults].map(([key, value]) => ({ uri: key, value }));
257
+ const report = await service.workspaceDiagnostics(uri, previous, options);
258
+ const server = this.host.serverNames?.(uri)[0] ?? 'workspace';
259
+ for (const item of report.items) {
260
+ if (item.resultId !== undefined) {
261
+ this.workspaceResults.set(item.uri, item.resultId);
262
+ }
263
+ if (item.kind === 'full') {
264
+ this.problems.report(this.pathOfLocation(item.uri) ?? item.uri, server, item.items);
265
+ }
266
+ }
267
+ return true;
268
+ }
269
+
270
+ /* Shows what a server asks for: a web address outside the editor, anything else as a place of the host. */
271
+ private async showDocument(params: ShowDocumentParams): Promise<ShowDocumentResult> {
272
+ const web = /^(https?|mailto):/i.test(params.uri);
273
+ if (params.external === true || web) {
274
+ if (this.host.openExternal !== undefined) {
275
+ this.host.openExternal(params.uri);
276
+ return { success: true };
277
+ }
278
+ if (typeof window === 'undefined') {
279
+ return { success: false };
280
+ }
281
+ return { success: window.open(params.uri, '_blank', 'noopener,noreferrer') !== null };
282
+ }
283
+ if (this.host.openPlace === undefined) {
284
+ return { success: false };
285
+ }
286
+ this.openPlace({ uri: params.uri, position: params.selection?.start ?? { line: 0, character: 0 } });
287
+ return { success: true };
199
288
  }
200
289
 
201
290
  /*
@@ -234,27 +323,39 @@ export class ProjectLanguage {
234
323
  return pending.position;
235
324
  }
236
325
 
326
+ /* The editors that hold a document, one per uri in the order the documents were first opened. */
327
+ openEditors(): readonly { uri: string; editor: Editor }[] {
328
+ return [...this.holders].flatMap(([uri, holder]) => (holder.editors[0] === undefined ? [] : [{ uri, editor: holder.editors[0] }]));
329
+ }
330
+
237
331
  /* Opens `uri` for the editor, or joins the editors that already hold it. */
238
- acquire(uri: string, languageId: string, editor: Editor): LanguageDocumentHandle {
332
+ acquire(uri: string, languageId: string, editor: Editor, options: AcquireOptions = {}): LanguageDocumentHandle {
239
333
  if (this.disposed) {
240
334
  throw new Error('The project language is disposed');
241
335
  }
242
336
 
337
+ const serve = options.serve ?? true;
243
338
  let holder = this.holders.get(uri);
244
339
 
245
340
  if (holder === undefined) {
246
- holder = { editors: [], sync: new DocumentSync(this.service, uri, languageId, editor), languageId };
341
+ holder = { editors: [], served: [], sync: null, languageId };
247
342
  this.holders.set(uri, holder);
248
343
  }
249
344
 
250
345
  holder.editors.push(editor);
346
+
347
+ if (serve) {
348
+ holder.served.push(editor);
349
+ holder.sync ??= new DocumentSync(this.service, uri, languageId, editor);
350
+ }
351
+
251
352
  let released = false;
252
353
 
253
354
  return {
254
355
  uri,
255
- service: this.service,
356
+ service: serve ? this.service : UNSERVED,
256
357
  get ready() {
257
- return holder.sync.ready;
358
+ return serve ? (holder.sync?.ready ?? Promise.resolve()) : Promise.resolve();
258
359
  },
259
360
  release: () => {
260
361
  if (!released) {
@@ -273,26 +374,38 @@ export class ProjectLanguage {
273
374
  }
274
375
 
275
376
  holder.editors.splice(holder.editors.indexOf(editor), 1);
276
- const next = holder.editors[0];
277
377
 
278
- if (next === undefined) {
279
- holder.sync.dispose();
378
+ if (holder.served.includes(editor)) {
379
+ holder.served.splice(holder.served.indexOf(editor), 1);
380
+ }
381
+
382
+ const { sync } = holder;
383
+
384
+ if (holder.editors.length === 0) {
280
385
  this.holders.delete(uri);
386
+ }
281
387
 
282
- void holder.sync.ready
388
+ if (sync === null || sync.editor !== editor) {
389
+ return;
390
+ }
391
+
392
+ sync.dispose();
393
+ const next = holder.served[0];
394
+
395
+ // The editor that sent the changes is gone; the next served one takes over and opens the document with its own text.
396
+ holder.sync = next === undefined ? null : new DocumentSync(this.service, uri, holder.languageId, next);
397
+
398
+ if (holder.sync === null) {
399
+ void sync.ready
283
400
  .catch(() => undefined)
284
401
  .then(() => {
285
- if (this.holders.has(uri)) {
402
+ if (this.holders.get(uri)?.sync) {
286
403
  return undefined;
287
404
  }
288
405
 
289
406
  return this.service.closeDocument(uri);
290
407
  })
291
408
  .catch(() => undefined);
292
- } else if (holder.sync.editor === editor) {
293
- // The editor that sent the changes is gone; the next one takes over and opens the document with its own text.
294
- holder.sync.dispose();
295
- holder.sync = new DocumentSync(this.service, uri, holder.languageId, next);
296
409
  }
297
410
  }
298
411
 
@@ -302,9 +415,16 @@ export class ProjectLanguage {
302
415
  }
303
416
 
304
417
  this.disposed = true;
418
+ for (const subscription of this.subscriptions) {
419
+ subscription.dispose();
420
+ }
305
421
  disposeSnippetTransforms(this);
306
422
 
307
423
  for (const [uri, holder] of this.holders) {
424
+ if (holder.sync === null) {
425
+ continue;
426
+ }
427
+
308
428
  holder.sync.dispose();
309
429
 
310
430
  void holder.sync.ready
package/src/rename.ts CHANGED
@@ -67,9 +67,9 @@ export class RenameFeature {
67
67
  }
68
68
 
69
69
  async start(): Promise<void> {
70
- const { editor, project, uri } = this.language;
70
+ const { editor, uri } = this.language;
71
71
 
72
- if (!project.service.supports(METHOD, uri)) {
72
+ if (!this.language.service.supports(METHOD, uri)) {
73
73
  this.tell('error', this.say('unavailable'));
74
74
 
75
75
  return;
@@ -77,11 +77,11 @@ export class RenameFeature {
77
77
 
78
78
  this.cancel();
79
79
  const caret = editor.getCaret();
80
- const prepare = project.service.providerOptions(METHOD, uri)?.prepareProvider === true;
80
+ const prepare = this.language.service.providerOptions(METHOD, uri)?.prepareProvider === true;
81
81
  let answer: PrepareRenameResult;
82
82
 
83
83
  try {
84
- answer = prepare ? await project.service.prepareRename(uri, caret) : { defaultBehavior: true };
84
+ answer = prepare ? await this.language.service.prepareRename(uri, caret) : { defaultBehavior: true };
85
85
  } catch (error) {
86
86
  if (!(error instanceof StaleResultError)) {
87
87
  this.tell('error', this.say('nothing'));
@@ -127,7 +127,7 @@ export class RenameFeature {
127
127
  const { project, uri } = this.language;
128
128
 
129
129
  try {
130
- const edit = await project.service.rename(uri, session.target.range.start, next);
130
+ const edit = await this.language.service.rename(uri, session.target.range.start, next);
131
131
 
132
132
  if (this.session !== session) {
133
133
  return;
@@ -218,14 +218,14 @@ export class RenameFeature {
218
218
 
219
219
  /* Lights up the places the name has in this file, and counts them all; what the servers listed, or null when they did not. */
220
220
  private async light(session: Session): Promise<readonly Location[] | null> {
221
- const { editor, project, uri } = this.language;
221
+ const { editor, uri } = this.language;
222
222
 
223
- if (!project.service.supports('textDocument/references', uri)) {
223
+ if (!this.language.service.supports('textDocument/references', uri)) {
224
224
  return null;
225
225
  }
226
226
 
227
227
  try {
228
- const locations = await project.service.references(uri, session.target.range.start, true);
228
+ const locations = await this.language.service.references(uri, session.target.range.start, true);
229
229
 
230
230
  if (this.session !== session || locations === null) {
231
231
  return null;
@@ -277,14 +277,14 @@ export class RenameFeature {
277
277
 
278
278
  /* So a suggester knows a function from a variable; a rename on a use finds the kind through the declaration's place in this file. */
279
279
  private async kindOf(session: Session, locations: readonly Location[]): Promise<string | undefined> {
280
- const { project, uri } = this.language;
280
+ const { uri } = this.language;
281
281
 
282
- if (!project.service.supports('textDocument/documentSymbol', uri)) {
282
+ if (!this.language.service.supports('textDocument/documentSymbol', uri)) {
283
283
  return undefined;
284
284
  }
285
285
 
286
286
  try {
287
- const symbols = await project.service.documentSymbols(uri, { signal: session.names.signal, parallel: true });
287
+ const symbols = await this.language.service.documentSymbols(uri, { signal: session.names.signal, parallel: true });
288
288
 
289
289
  return renameKindOf(symbols, session.original, [session.target.range, ...occurrencesOf(locations, uri).inFile]);
290
290
  } catch {
@@ -254,9 +254,9 @@ export class RenderedDocsFeature {
254
254
 
255
255
  /* Whether names in `{@link}` and `@see` can be followed, which takes a server that finds symbols by name. */
256
256
  get canFollow(): boolean {
257
- const { project, uri } = this.language;
257
+ const { uri } = this.language;
258
258
 
259
- return project.service.supports('workspace/symbol', uri);
259
+ return this.language.service.supports('workspace/symbol', uri);
260
260
  }
261
261
 
262
262
  /* Goes to what a link in a comment names. */
@@ -21,14 +21,14 @@ export function selectionChainOf(selection: SelectionRange): EditorRange[] {
21
21
  */
22
22
  export class SelectionRangesFeature {
23
23
  constructor(language: EditorLanguage) {
24
- const { editor, project, uri } = language;
24
+ const { editor, uri } = language;
25
25
 
26
26
  editor.setSelectionRanges(async (positions) => {
27
- if (!project.service.supports(METHOD, uri)) {
27
+ if (!language.service.supports(METHOD, uri)) {
28
28
  return null;
29
29
  }
30
30
 
31
- const chains = await project.service.selectionRanges(uri, positions);
31
+ const chains = await language.service.selectionRanges(uri, positions);
32
32
 
33
33
  return chains === null || chains.length !== positions.length ? null : chains.map(selectionChainOf);
34
34
  });
package/src/signature.ts CHANGED
@@ -111,8 +111,8 @@ export class SignatureFeature {
111
111
  }
112
112
 
113
113
  private options(): { triggers: readonly string[]; retriggers: readonly string[] } | null {
114
- const { project, uri } = this.language;
115
- const options = project.service.providerOptions(METHOD, uri);
114
+ const { uri } = this.language;
115
+ const options = this.language.service.providerOptions(METHOD, uri);
116
116
 
117
117
  return options === undefined ? null : { triggers: options.triggerCharacters ?? [], retriggers: options.retriggerCharacters ?? [] };
118
118
  }
@@ -147,9 +147,9 @@ export class SignatureFeature {
147
147
  }
148
148
 
149
149
  private ask(context: Omit<SignatureHelpContext, 'activeSignatureHelp'>): void {
150
- const { project, uri } = this.language;
150
+ const { uri } = this.language;
151
151
 
152
- if (!project.service.supports(METHOD, uri)) {
152
+ if (!this.language.service.supports(METHOD, uri)) {
153
153
  return;
154
154
  }
155
155
 
@@ -160,7 +160,7 @@ export class SignatureFeature {
160
160
  }
161
161
 
162
162
  private async fetch(context: SignatureHelpContext, request: number): Promise<void> {
163
- const { editor, project, uri } = this.language;
163
+ const { editor, uri } = this.language;
164
164
  const caret = editor.getCaret();
165
165
  this.controller?.abort();
166
166
  const controller = new AbortController();
@@ -168,7 +168,7 @@ export class SignatureFeature {
168
168
  let help: SignatureHelp | null;
169
169
 
170
170
  try {
171
- help = await project.service.signatureHelp(uri, caret, context, { signal: controller.signal });
171
+ help = await this.language.service.signatureHelp(uri, caret, context, { signal: controller.signal });
172
172
  } catch {
173
173
  return;
174
174
  }
@@ -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
+ }