@adecore/lsp 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/session.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { ErrorCodes, JsonRpcConnection, LspError, type RequestHandler } from './connection.ts';
2
2
  import { LspDocument } from './document.ts';
3
- import { renamesTaken, type RenamedFile } from './file-operations.ts';
3
+ import { createsTaken, deletesTaken, renamesTaken, type CreatedFile, type DeletedFile, type RenamedFile } from './file-operations.ts';
4
4
  import { globMatch } from './glob.ts';
5
+ import type { ResourceOperationKind } from './workspace-operations.ts';
5
6
  import type {
6
7
  ApplyWorkspaceEditParams,
7
8
  ApplyWorkspaceEditResult,
@@ -10,6 +11,7 @@ import type {
10
11
  Disposable,
11
12
  DocumentItem,
12
13
  FileEvent,
14
+ FileOperation,
13
15
  FileOperationFilter,
14
16
  FileSystemWatcher,
15
17
  InitializeResult,
@@ -38,6 +40,14 @@ export interface LspSessionOptions {
38
40
  clientInfo?: { name: string; version?: string };
39
41
  timeoutMs?: number;
40
42
  snippetSupport?: boolean;
43
+ insertTextModes?: readonly (1 | 2)[];
44
+ completionItemDefaults?: readonly string[];
45
+ /*
46
+ * The resource operations the host carries out for a workspace edit, announced to servers. Defaults to create
47
+ * and rename. The failure handling is `abort`: the client stops at the first change that fails, and the changes
48
+ * that already ran are not rolled back.
49
+ */
50
+ resourceOperations?: readonly ResourceOperationKind[];
41
51
  onConfiguration?: (item: ConfigurationItem) => unknown | Promise<unknown>;
42
52
  onApplyEdit?: (params: ApplyWorkspaceEditParams) => ApplyWorkspaceEditResult | Promise<ApplyWorkspaceEditResult>;
43
53
  onShowMessage?: (params: { type: number; message: string; actions?: { title: string }[] }) => { title: string } | null | Promise<{ title: string } | null>;
@@ -67,6 +77,7 @@ const PROVIDERS: Record<string, keyof ServerCapabilities> = {
67
77
  'workspace/executeCommand': 'executeCommandProvider',
68
78
  'textDocument/formatting': 'documentFormattingProvider',
69
79
  'textDocument/rangeFormatting': 'documentRangeFormattingProvider',
80
+ 'textDocument/onTypeFormatting': 'documentOnTypeFormattingProvider',
70
81
  'textDocument/foldingRange': 'foldingRangeProvider',
71
82
  'textDocument/selectionRange': 'selectionRangeProvider',
72
83
  'textDocument/semanticTokens/full': 'semanticTokensProvider',
@@ -74,6 +85,9 @@ const PROVIDERS: Record<string, keyof ServerCapabilities> = {
74
85
  'textDocument/semanticTokens/range': 'semanticTokensProvider',
75
86
  'textDocument/inlayHint': 'inlayHintProvider',
76
87
  'inlayHint/resolve': 'inlayHintProvider',
88
+ 'textDocument/prepareCallHierarchy': 'callHierarchyProvider',
89
+ 'callHierarchy/incomingCalls': 'callHierarchyProvider',
90
+ 'callHierarchy/outgoingCalls': 'callHierarchyProvider',
77
91
  'textDocument/diagnostic': 'diagnosticProvider'
78
92
  };
79
93
 
@@ -83,6 +97,8 @@ const REGISTRATION_METHODS: Record<string, string> = {
83
97
  'codeAction/resolve': 'textDocument/codeAction',
84
98
  'codeLens/resolve': 'textDocument/codeLens',
85
99
  'inlayHint/resolve': 'textDocument/inlayHint',
100
+ 'callHierarchy/incomingCalls': 'textDocument/prepareCallHierarchy',
101
+ 'callHierarchy/outgoingCalls': 'textDocument/prepareCallHierarchy',
86
102
  'textDocument/prepareRename': 'textDocument/rename',
87
103
  'workspaceSymbol/resolve': 'workspace/symbol',
88
104
  'textDocument/semanticTokens/full': 'textDocument/semanticTokens',
@@ -364,8 +380,8 @@ export class LspSession {
364
380
  return this.notify('workspace/didChangeWatchedFiles', { changes });
365
381
  }
366
382
 
367
- /* The files a server asked to hear about when they are renamed, from its capabilities and its registrations. */
368
- fileOperationFilters(operation: 'willRename' | 'didRename'): FileOperationFilter[] {
383
+ /* The files a server asked to hear about when they are renamed, created or deleted, from its capabilities and its registrations. */
384
+ fileOperationFilters(operation: FileOperation): FileOperationFilter[] {
369
385
  const fileOperations = (this.serverCapabilities.workspace as { fileOperations?: Record<string, ProviderOptions> } | undefined)?.fileOperations;
370
386
  const method = `workspace/${operation}Files`;
371
387
  return [
@@ -390,6 +406,34 @@ export class LspSession {
390
406
  }
391
407
  }
392
408
 
409
+ /* What the server would change before these files are made, or null when it takes none of them or has nothing to say. */
410
+ async willCreateFiles(files: readonly CreatedFile[], options?: RequestOptions): Promise<WorkspaceEdit | null> {
411
+ const taken = createsTaken(this.fileOperationFilters('willCreate'), files);
412
+ return taken.length === 0 || this.phase !== 'ready' ? null : this.request<WorkspaceEdit | null>('workspace/willCreateFiles', { files: taken }, options);
413
+ }
414
+
415
+ /* Tells the server which of these files were made, those its filters take. */
416
+ async didCreateFiles(files: readonly CreatedFile[]): Promise<void> {
417
+ const taken = createsTaken(this.fileOperationFilters('didCreate'), files);
418
+ if (taken.length > 0 && this.phase === 'ready') {
419
+ await this.notify('workspace/didCreateFiles', { files: taken });
420
+ }
421
+ }
422
+
423
+ /* What the server would change before these files are deleted, or null when it takes none of them or has nothing to say. */
424
+ async willDeleteFiles(files: readonly DeletedFile[], options?: RequestOptions): Promise<WorkspaceEdit | null> {
425
+ const taken = deletesTaken(this.fileOperationFilters('willDelete'), files);
426
+ return taken.length === 0 || this.phase !== 'ready' ? null : this.request<WorkspaceEdit | null>('workspace/willDeleteFiles', { files: taken }, options);
427
+ }
428
+
429
+ /* Tells the server which of these files were deleted, those its filters take. */
430
+ async didDeleteFiles(files: readonly DeletedFile[]): Promise<void> {
431
+ const taken = deletesTaken(this.fileOperationFilters('didDelete'), files);
432
+ if (taken.length > 0 && this.phase === 'ready') {
433
+ await this.notify('workspace/didDeleteFiles', { files: taken });
434
+ }
435
+ }
436
+
393
437
  /* Registrations and the refresh requests of the server both land here, since each can change what a document may ask. */
394
438
  onCapabilitiesChanged(listener: () => void): Disposable {
395
439
  this.capabilityListeners.add(listener);
@@ -481,8 +525,16 @@ function clientCapabilities(options: LspSessionOptions): object {
481
525
  general: { positionEncodings: ['utf-16'] },
482
526
  workspace: {
483
527
  applyEdit: !!options.onApplyEdit,
484
- workspaceEdit: { documentChanges: true, resourceOperations: ['create', 'rename'], failureHandling: 'abort' },
485
- fileOperations: { dynamicRegistration: true, willRename: true, didRename: true },
528
+ workspaceEdit: { documentChanges: true, resourceOperations: [...(options.resourceOperations ?? ['create', 'rename'])], failureHandling: 'abort' },
529
+ fileOperations: {
530
+ dynamicRegistration: true,
531
+ willRename: true,
532
+ didRename: true,
533
+ willCreate: true,
534
+ didCreate: true,
535
+ willDelete: true,
536
+ didDelete: true
537
+ },
486
538
  configuration: true,
487
539
  workspaceFolders: true,
488
540
  executeCommand: dynamic,
@@ -506,8 +558,11 @@ function clientCapabilities(options: LspSessionOptions): object {
506
558
  completion: {
507
559
  ...dynamic,
508
560
  contextSupport: true,
561
+ insertTextMode: 1,
562
+ ...(options.completionItemDefaults?.length ? { completionList: { itemDefaults: [...options.completionItemDefaults] } } : {}),
509
563
  completionItem: {
510
564
  snippetSupport: options.snippetSupport ?? false,
565
+ ...(options.insertTextModes?.length ? { insertTextModeSupport: { valueSet: [...options.insertTextModes] } } : {}),
511
566
  commitCharactersSupport: true,
512
567
  documentationFormat: ['markdown', 'plaintext'],
513
568
  deprecatedSupport: true,
@@ -561,6 +616,7 @@ function clientCapabilities(options: LspSessionOptions): object {
561
616
  codeLens: dynamic,
562
617
  formatting: dynamic,
563
618
  rangeFormatting: dynamic,
619
+ onTypeFormatting: dynamic,
564
620
  foldingRange: { ...dynamic, lineFoldingOnly: false, foldingRange: { collapsedText: false } },
565
621
  selectionRange: dynamic,
566
622
  semanticTokens: {
@@ -608,6 +664,7 @@ function clientCapabilities(options: LspSessionOptions): object {
608
664
  multilineTokenSupport: false
609
665
  },
610
666
  inlayHint: { ...dynamic, resolveSupport: { properties: ['tooltip', 'textEdits', 'label.tooltip', 'label.location', 'label.command'] } },
667
+ callHierarchy: { dynamicRegistration: false },
611
668
  diagnostic: { ...dynamic, relatedDocumentSupport: true }
612
669
  }
613
670
  };
@@ -0,0 +1,341 @@
1
+ import { ErrorCodes, LspError } from './connection.ts';
2
+ import { applyTextEdits } from './edits.ts';
3
+ import type { TextEdit, WorkspaceEdit } from './protocol.ts';
4
+
5
+ type DocumentChange = NonNullable<WorkspaceEdit['documentChanges']>[number];
6
+
7
+ /* The resource operations of the protocol; a host declares the ones it can carry out. */
8
+ export type ResourceOperationKind = 'create' | 'rename' | 'delete';
9
+
10
+ /* A text file as the host has it right now. */
11
+ export interface WorkspaceEditText {
12
+ readonly text: string;
13
+ /* The version the language servers hold the document at; null when the host cannot tell. */
14
+ readonly version: number | null;
15
+ /* Whether an editor holds the file. Only then does a version in an edit mean anything. */
16
+ readonly open: boolean;
17
+ }
18
+
19
+ /* Where `planWorkspaceOperations` reads the world from. Nothing in it is changed. */
20
+ export interface WorkspaceEditSource {
21
+ /* The text of a file; null when there is no such text file, which also covers folders and files that are not text. */
22
+ read(uri: string): Promise<WorkspaceEditText | null>;
23
+ /*
24
+ * Whether anything is at the URI: a file of any kind or a folder. Asked only after `read` answered null.
25
+ * Without it, what `read` has no text for is of unknown existence: a rename or delete of it is planned and
26
+ * left to the host (`ignoreIfNotExists` cannot be honored), and a create or rename onto it is planned as no
27
+ * overwrite, so the host has to refuse a target that is there.
28
+ */
29
+ exists?(uri: string): Promise<boolean>;
30
+ /* Whether the URI is a folder. Without it, anything that exists and is no text file may be a folder, so deleting it needs `recursive`. */
31
+ isFolder?(uri: string): Promise<boolean>;
32
+ /* The operations the host can carry out; an edit that asks for another is refused. */
33
+ readonly operations: readonly ResourceOperationKind[];
34
+ }
35
+
36
+ /* Text edits to one file, with the text before and after. */
37
+ export interface PlannedTextStep {
38
+ readonly kind: 'edits';
39
+ /* The URI the edit names, which is where the file stands once the earlier steps ran. */
40
+ readonly uri: string;
41
+ /* Where the text was read from, which differs from `uri` when an earlier step moved the file. */
42
+ readonly readUri: string;
43
+ readonly edits: readonly TextEdit[];
44
+ readonly before: string;
45
+ readonly after: string;
46
+ /* The version the source reported when the file was read; null when it did not say. */
47
+ readonly version: number | null;
48
+ readonly open: boolean;
49
+ }
50
+
51
+ /* A file to make. `text` is what every later edit of the same plan leaves in it. */
52
+ export interface PlannedCreateStep {
53
+ readonly kind: 'create';
54
+ readonly uri: string;
55
+ readonly text: string;
56
+ /* The file is there and is emptied: its text becomes `text`. */
57
+ readonly overwrite: boolean;
58
+ }
59
+
60
+ export interface PlannedRenameStep {
61
+ readonly kind: 'rename';
62
+ readonly oldUri: string;
63
+ readonly newUri: string;
64
+ /* The target is there and is replaced. */
65
+ readonly overwrite: boolean;
66
+ }
67
+
68
+ export interface PlannedDeleteStep {
69
+ readonly kind: 'delete';
70
+ readonly uri: string;
71
+ readonly recursive: boolean;
72
+ }
73
+
74
+ export type WorkspaceEditStep = PlannedTextStep | PlannedCreateStep | PlannedRenameStep | PlannedDeleteStep;
75
+
76
+ /* The steps of a workspace edit in the order they run, all checked against the source. */
77
+ export interface WorkspaceEditPlan {
78
+ readonly steps: readonly WorkspaceEditStep[];
79
+ }
80
+
81
+ interface Probe {
82
+ readonly text: WorkspaceEditText | null;
83
+ /* null when the source cannot say. */
84
+ readonly exists: boolean | null;
85
+ }
86
+
87
+ /* What stands at a URI while the plan is made: a text file, something that exists and is no text file (which may be a folder), or something that may not be there at all. */
88
+ interface Node {
89
+ kind: 'text' | 'other' | 'maybe';
90
+ text: string;
91
+ version: number | null;
92
+ open: boolean;
93
+ /* Where it was read from; null for a file the plan makes. */
94
+ readUri: string | null;
95
+ /* The create step that holds the text of a file the plan makes, so later edits land in it. */
96
+ create: MutableCreate | null;
97
+ }
98
+
99
+ interface MutableCreate {
100
+ readonly kind: 'create';
101
+ readonly uri: string;
102
+ text: string;
103
+ readonly overwrite: boolean;
104
+ }
105
+
106
+ /* The key a URI is known by: a folder spelled with a trailing slash is the same folder, except the root. */
107
+ function keyOf(uri: string): string {
108
+ const root = /^[^/:]+:\/\/[^/]*\/+$/.test(uri);
109
+ return root ? uri : uri.replace(/\/+$/, '');
110
+ }
111
+
112
+ function isUnder(uri: string, folder: string): boolean {
113
+ return uri.startsWith(folder.endsWith('/') ? folder : `${folder}/`);
114
+ }
115
+
116
+ function ancestorsOf(uri: string): string[] {
117
+ const found: string[] = [];
118
+ let end = uri.lastIndexOf('/');
119
+ while (end > 0) {
120
+ found.push(uri.slice(0, end));
121
+ end = uri.lastIndexOf('/', end - 1);
122
+ }
123
+ return found;
124
+ }
125
+
126
+ /*
127
+ * Plans a workspace edit with its resource operations without changing anything, and refuses (an `LspError`)
128
+ * the first thing that does not fit. Folders are matched by URI prefix, as written, with a trailing slash
129
+ * ignored: the URIs of one edit are expected to be spelled the same. Each URI is read from `source` at most once.
130
+ *
131
+ * Checks, in the order the edit gives its changes:
132
+ * - A version in a text edit must equal the source's version. A file no editor holds has no version to check,
133
+ * so a version on it is refused with `ContentModified`. When the source reports no version for an open file,
134
+ * the version is not enforced and the host's check of the text stands in. A file the plan makes is not checked.
135
+ * - An operation outside `source.operations` is refused.
136
+ * - A create over a file that is there is refused, unless it says `overwrite` (planned as an overwrite) or
137
+ * `ignoreIfExists` (left out). A rename onto a target that is there works the same way; a rename of
138
+ * something that is not there is always refused. Without `source.exists`, that is left to the host.
139
+ * - A delete of something that is not there is refused unless it says `ignoreIfNotExists`. Emptiness of a
140
+ * folder cannot be told, so deleting a folder, or anything that exists and is no text file when the source
141
+ * cannot say which, needs `recursive`.
142
+ * - Text edits to a URI after it was deleted or renamed away are refused. Edits to a renamed file are read
143
+ * from where it stood, and edits to a file the plan makes become its text.
144
+ */
145
+ export async function planWorkspaceOperations(edit: WorkspaceEdit, source: WorkspaceEditSource): Promise<WorkspaceEditPlan> {
146
+ const probes = new Map<string, Promise<Probe>>();
147
+ const folders = new Map<string, Promise<boolean>>();
148
+ // null is a tombstone: the URI, and everything under it, is gone.
149
+ const overlay = new Map<string, Node | null>();
150
+ // Renames in order, to find where a file under a moved folder was read from.
151
+ const moves: Array<{ oldUri: string; newUri: string }> = [];
152
+ const steps: WorkspaceEditStep[] = [];
153
+
154
+ const probe = (uri: string): Promise<Probe> => {
155
+ let found = probes.get(uri);
156
+ if (found === undefined) {
157
+ found = (async () => {
158
+ const text = await source.read(uri);
159
+ return { text, exists: text !== null ? true : source.exists === undefined ? null : await source.exists(uri) };
160
+ })();
161
+ probes.set(uri, found);
162
+ }
163
+ return found;
164
+ };
165
+
166
+ const folderAt = (uri: string): Promise<boolean> => {
167
+ let found = folders.get(uri);
168
+ if (found === undefined) {
169
+ found = source.isFolder === undefined ? Promise.resolve(true) : source.isFolder(uri);
170
+ folders.set(uri, found);
171
+ }
172
+ return found;
173
+ };
174
+
175
+ const originOf = (uri: string): string => {
176
+ let origin = uri;
177
+ for (let i = moves.length - 1; i >= 0; i--) {
178
+ const move = moves[i]!;
179
+ if (origin === move.newUri || isUnder(origin, move.newUri)) {
180
+ origin = move.oldUri + origin.slice(move.newUri.length);
181
+ }
182
+ }
183
+ return origin;
184
+ };
185
+
186
+ const lookup = async (uri: string): Promise<Node | null> => {
187
+ if (overlay.has(uri)) {
188
+ return overlay.get(uri)!;
189
+ }
190
+ for (const ancestor of ancestorsOf(uri)) {
191
+ if (overlay.has(ancestor)) {
192
+ const node = overlay.get(ancestor)!;
193
+ if (node === null || node.kind === 'text') {
194
+ return null;
195
+ }
196
+ break;
197
+ }
198
+ }
199
+ const origin = originOf(uri);
200
+ const found = await probe(origin);
201
+ let node: Node | null = null;
202
+ if (found.text !== null) {
203
+ node = { kind: 'text', text: found.text.text, version: found.text.version, open: found.text.open, readUri: origin, create: null };
204
+ } else if (found.exists !== false) {
205
+ node = { kind: found.exists === null ? 'maybe' : 'other', text: '', version: null, open: false, readUri: origin, create: null };
206
+ }
207
+ if (node !== null) {
208
+ overlay.set(uri, node);
209
+ }
210
+ return node;
211
+ };
212
+
213
+ const demand = (kind: ResourceOperationKind, uri: string): void => {
214
+ if (!source.operations.includes(kind)) {
215
+ const verb = kind === 'create' ? 'Creating' : kind === 'rename' ? 'Moving' : 'Deleting';
216
+ throw new LspError(`${verb} ${uri} is not supported here`);
217
+ }
218
+ };
219
+
220
+ const forget = (uri: string): void => {
221
+ for (const key of [...overlay.keys()]) {
222
+ if (key === uri || isUnder(key, uri)) {
223
+ overlay.delete(key);
224
+ }
225
+ }
226
+ };
227
+
228
+ const keyed = (change: DocumentChange): DocumentChange => {
229
+ if ('textDocument' in change) {
230
+ return { ...change, textDocument: { ...change.textDocument, uri: keyOf(change.textDocument.uri) } };
231
+ }
232
+ if (change.kind === 'rename') {
233
+ return { ...change, oldUri: keyOf(change.oldUri), newUri: keyOf(change.newUri) };
234
+ }
235
+ return { ...change, uri: keyOf(change.uri) };
236
+ };
237
+
238
+ const changes = edit.documentChanges ?? Object.entries(edit.changes ?? {}).map(([uri, edits]) => ({ textDocument: { uri, version: null }, edits }));
239
+ for (const original of changes) {
240
+ const change = keyed(original);
241
+ if ('textDocument' in change) {
242
+ const { uri, version } = change.textDocument;
243
+ const node = await lookup(uri);
244
+ if (node === null || node.kind !== 'text') {
245
+ throw new LspError(`${uri} cannot be read as text`);
246
+ }
247
+ if (version !== null && node.create === null) {
248
+ if (!node.open) {
249
+ throw new LspError(`The version of ${uri} cannot be checked because no editor holds it`, ErrorCodes.ContentModified);
250
+ }
251
+ if (node.version !== null && node.version !== version) {
252
+ throw new LspError(`Workspace edit version mismatch: ${uri}`, ErrorCodes.ContentModified);
253
+ }
254
+ }
255
+ const after = applyTextEdits(node.text, change.edits);
256
+ if (node.create !== null) {
257
+ node.create.text = after;
258
+ } else {
259
+ steps.push({
260
+ kind: 'edits',
261
+ uri,
262
+ readUri: node.readUri!,
263
+ edits: change.edits,
264
+ before: node.text,
265
+ after,
266
+ version: node.version,
267
+ open: node.open
268
+ });
269
+ }
270
+ node.text = after;
271
+ } else if (change.kind === 'create') {
272
+ demand('create', change.uri);
273
+ const found = await lookup(change.uri);
274
+ const there = found?.kind === 'maybe' ? null : found;
275
+ const overwrite = change.options?.overwrite === true;
276
+ if (there !== null && !overwrite) {
277
+ if (change.options?.ignoreIfExists === true) {
278
+ continue;
279
+ }
280
+ throw new LspError(`${change.uri} already exists`);
281
+ }
282
+ if (there !== null && there.kind === 'other') {
283
+ throw new LspError(`${change.uri} already exists and is not a text file`);
284
+ }
285
+ const made: MutableCreate = { kind: 'create', uri: change.uri, text: '', overwrite: there !== null };
286
+ steps.push(made);
287
+ overlay.set(change.uri, { kind: 'text', text: '', version: null, open: false, readUri: null, create: made });
288
+ } else if (change.kind === 'rename') {
289
+ demand('rename', change.oldUri);
290
+ const { oldUri, newUri } = change;
291
+ const from = await lookup(oldUri);
292
+ if (from === null) {
293
+ throw new LspError(`${oldUri} does not exist`);
294
+ }
295
+ if (oldUri === newUri) {
296
+ continue;
297
+ }
298
+ const target = await lookup(newUri);
299
+ const taken = target?.kind === 'maybe' ? null : target;
300
+ const overwrite = change.options?.overwrite === true;
301
+ if (taken !== null && !overwrite) {
302
+ if (change.options?.ignoreIfExists === true) {
303
+ continue;
304
+ }
305
+ throw new LspError(`${newUri} already exists`);
306
+ }
307
+ const carried: Array<[string, Node | null]> = [];
308
+ for (const [key, node] of overlay) {
309
+ if (key !== oldUri && isUnder(key, oldUri)) {
310
+ carried.push([newUri + key.slice(oldUri.length), node]);
311
+ }
312
+ }
313
+ forget(newUri);
314
+ forget(oldUri);
315
+ overlay.set(oldUri, null);
316
+ overlay.set(newUri, from);
317
+ for (const [key, node] of carried) {
318
+ overlay.set(key, node);
319
+ }
320
+ moves.push({ oldUri, newUri });
321
+ steps.push({ kind: 'rename', oldUri, newUri, overwrite: taken !== null });
322
+ } else {
323
+ demand('delete', change.uri);
324
+ const found = await lookup(change.uri);
325
+ if (found === null) {
326
+ if (change.options?.ignoreIfNotExists === true) {
327
+ continue;
328
+ }
329
+ throw new LspError(`${change.uri} does not exist`);
330
+ }
331
+ const recursive = change.options?.recursive === true;
332
+ if (found.kind !== 'text' && !recursive && (await folderAt(found.readUri ?? change.uri))) {
333
+ throw new LspError(`${change.uri} is a folder, or cannot be told from one, and is only deleted with recursive set`);
334
+ }
335
+ forget(change.uri);
336
+ overlay.set(change.uri, null);
337
+ steps.push({ kind: 'delete', uri: change.uri, recursive });
338
+ }
339
+ }
340
+ return { steps: steps.map((step) => (step.kind === 'create' ? { ...step } : step)) };
341
+ }