@adecore/lsp 0.22.1 → 0.23.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.
- package/dist/completion-defaults.d.ts +4 -0
- package/dist/completion-defaults.js +83 -0
- package/dist/document.d.ts +5 -1
- package/dist/document.js +14 -0
- package/dist/file-operations.d.ts +9 -1
- package/dist/file-operations.js +12 -4
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/protocol.d.ts +29 -0
- package/dist/service.d.ts +11 -1
- package/dist/session.d.ts +11 -3
- package/dist/session.js +47 -4
- package/dist/workspace-operations.d.ts +45 -0
- package/dist/workspace-operations.js +232 -0
- package/package.json +1 -1
- package/src/completion-defaults.ts +97 -0
- package/src/document.ts +21 -0
- package/src/file-operations.ts +25 -5
- package/src/index.ts +14 -1
- package/src/protocol.ts +42 -0
- package/src/service.ts +22 -0
- package/src/session.ts +62 -5
- package/src/workspace-operations.ts +341 -0
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:
|
|
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: {
|
|
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
|
+
}
|