@mrclrchtr/supi-lsp 11.4.0 → 12.0.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/README.md +21 -16
- package/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
- package/node_modules/@mrclrchtr/supi-code-runtime/src/api.ts +1 -0
- package/node_modules/@mrclrchtr/supi-code-runtime/src/types.ts +51 -23
- package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
- package/package.json +3 -3
- package/src/api.ts +2 -2
- package/src/client/client-diagnostic-capabilities.ts +13 -3
- package/src/client/client-diagnostics.ts +25 -9
- package/src/client/client-document-sync.ts +7 -5
- package/src/client/client-edit-capture.ts +258 -0
- package/src/client/client.ts +64 -11
- package/src/config/capabilities.ts +8 -0
- package/src/config/operation-support.ts +48 -79
- package/src/manager/manager.ts +3 -1
- package/src/provider/lsp-refactor-provider.ts +21 -35
- package/src/provider/refactor-command-capture.ts +139 -0
- package/src/provider/refactor-planning.ts +202 -47
- package/src/session/route-boundary.ts +26 -0
- package/src/session/runtime-registry.ts +57 -12
- package/src/session/runtime-rename.ts +3 -0
- package/src/session/workspace-lsp-runtime.ts +22 -1
package/README.md
CHANGED
|
@@ -59,8 +59,8 @@ A configured route is not a ready server. Features depend on the active server,
|
|
|
59
59
|
| Hover, definitions, references, implementations, document/workspace symbols | Supported through the server's corresponding LSP methods; availability and coverage differ by server. |
|
|
60
60
|
| Diagnostics | Native pull, the eligible TypeScript request adapter, or ambient push. Push observation alone cannot confirm that a file is clean. See the diagnostic policy below. |
|
|
61
61
|
| `rename_symbol` | Requires server rename support and validated text edits; uses `prepareRename` when advertised. |
|
|
62
|
-
| `
|
|
63
|
-
| `
|
|
62
|
+
| `code_action` | Requires a code action of the requested `refactor*` kind for the range that produces validated text edits, directly or by capturing the one edit its command pushes. Names generated by the server appear unchanged in the plan. |
|
|
63
|
+
| `source_action` | Requires a code action of the requested `source*` kind for the whole document that produces validated text edits, directly or (for `source.organizeImports` and `source.fixAll`) by capturing the one edit its command pushes. |
|
|
64
64
|
|
|
65
65
|
The [operation-support model](src/config/operation-support.ts) separates server advertisement from SuPi planning limits. A server can advertise an action that SuPi cannot safely turn into a plan.
|
|
66
66
|
|
|
@@ -106,7 +106,7 @@ The required [Linux/macOS CI matrix](../../.github/workflows/ci.yml) keeps [sele
|
|
|
106
106
|
|
|
107
107
|
[Bounded config comparisons](__tests__/integration/typescript-config-coverage.test.ts) compare TS6 and native TS7 `--showConfig` root-file coverage for TS/JS defaults, include/exclude, `allowJs`/`checkJs`, inherited patterns (including late-created files), and separate referenced projects. These cases found no false exclusion; filtering is unchanged. **Config coverage state is not TypeScript program membership**: imports may bring excluded files into a program, and references do not merge root-file lists.
|
|
108
108
|
|
|
109
|
-
Native
|
|
109
|
+
Native TypeScript 7 offers `source.removeUnusedImports` through the same safe plan/apply workflow ([#554](https://github.com/mrclrchtr/supi/issues/554)); it removes unused imports only, not arbitrary unused declarations. Native TypeScript 7 advertises no `refactor*` kinds, so `code_action` extraction is unavailable there; no TLS/native feature-parity claim is made. Only the pinned combinations are tested, not every stable TS7 release. Focused qualification passed on macOS arm64 (Node 24.22.1) and Linux arm64 (Debian container, Node 24.22.0), including the native/TS6 public TS/JS matrix. The unused-import follow-up has local macOS arm64 verification only; its Linux verification remains pending. The pinned Linux/macOS CI jobs remain pending until they run; this is not a qualification claim for the complete multi-language baseline.
|
|
110
110
|
|
|
111
111
|
### Java startup and project JDKs
|
|
112
112
|
|
|
@@ -320,21 +320,26 @@ Disable one language with `lsp.servers.<language>.enabled: false`. Use `/supi-ci
|
|
|
320
320
|
- explicit ready, pending, inactive, disabled, and unavailable registry states
|
|
321
321
|
- a `SemanticProvider` adapter for `supi-code-runtime`
|
|
322
322
|
- precise rename and code-action edit conversion
|
|
323
|
-
-
|
|
323
|
+
- code-action kind-prefix filters, exact-title selection, and standard lazy edit resolution
|
|
324
324
|
- negotiated operation support in project-server status
|
|
325
325
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
326
|
+
Code- and source-action planners request exactly the caller's kind with `only` and
|
|
327
|
+
match the returned `CodeAction.kind` or its dotted descendants, never the title.
|
|
328
|
+
Entries outside `only` are dropped before validation. Disabled actions never match;
|
|
329
|
+
an optional exact title narrows the matches. Edit-only actions are validated before
|
|
330
|
+
planning. One eligible command-only action (`refactor*`, `source.organizeImports*`,
|
|
331
|
+
`source.fixAll*`; a command the server lists when it lists any) runs once: SuPi
|
|
332
|
+
advertises `workspace.applyEdit`, refuses every pushed edit with `applied: false`,
|
|
333
|
+
and plans the single edit captured while the command runs or shortly after it
|
|
334
|
+
answers, even when the server then reports the command as failed because the
|
|
335
|
+
edit was refused (clangd does). A timed-out or cancelled command keeps refusing late edits until it
|
|
336
|
+
settles. Touched open documents are re-sent as full text under a new version.
|
|
337
|
+
Running a command may still change server-side state. Resource operations,
|
|
338
|
+
snippets, annotations, malformed actions, and stale responses stay unavailable. Distinct matching plans return an
|
|
339
|
+
ambiguous result with each candidate's kind and title instead of selecting the first
|
|
340
|
+
action. Rename uses `prepareRename` when the server advertises it and otherwise uses
|
|
341
|
+
direct standard rename. Code actions choose generated names; plans preview them as
|
|
342
|
+
the server returns them.
|
|
338
343
|
|
|
339
344
|
Rename responses that need additional document state can trigger one bounded discovery/enrollment retry. SuPi validates edit shape and canonical mutation paths, enrolls at most 64 affected files on the same route, and requests fresh edits. The first response is not a plan. Numeric versions must still match. A null version for an open document requires verified synchronized-content evidence; the plan retains the request fingerprint and client version. Missing snapshots, changed inputs, a larger follow-up edit set, and unsafe edits remain unavailable. Planning and apply revalidate these preconditions; no version is discarded to make an edit fit.
|
|
340
345
|
|
|
@@ -193,31 +193,53 @@ export interface WorkspaceEdit {
|
|
|
193
193
|
}
|
|
194
194
|
|
|
195
195
|
/**
|
|
196
|
-
*
|
|
197
|
-
|
|
198
|
-
export type RefactorOperation =
|
|
199
|
-
| "rename_symbol"
|
|
200
|
-
| "extract_function"
|
|
201
|
-
| "extract_variable"
|
|
202
|
-
| "rename_file"
|
|
203
|
-
| "move_file"
|
|
204
|
-
| "update_imports"
|
|
205
|
-
| "delete_dead_code";
|
|
206
|
-
|
|
207
|
-
/**
|
|
208
|
-
* Operation-aware refactor planning request.
|
|
196
|
+
* Operation-aware refactor planning request. Each operation carries only the
|
|
197
|
+
* scope it needs:
|
|
209
198
|
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
199
|
+
* - `rename_symbol`: one symbol position and the new name.
|
|
200
|
+
* - `code_action`: one file range and a CodeActionKind prefix under `refactor`.
|
|
201
|
+
* - `source_action`: one whole file and a CodeActionKind prefix under `source`.
|
|
202
|
+
*
|
|
203
|
+
* `kind` matches the requested kind and its dotted sub-kinds. `title`, when
|
|
204
|
+
* present, selects one action by its exact title.
|
|
213
205
|
*/
|
|
214
|
-
export
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
206
|
+
export type RefactorRequest =
|
|
207
|
+
| {
|
|
208
|
+
operation: "rename_symbol";
|
|
209
|
+
file: string;
|
|
210
|
+
position: CodePosition;
|
|
211
|
+
newName: string;
|
|
212
|
+
}
|
|
213
|
+
| {
|
|
214
|
+
operation: "code_action";
|
|
215
|
+
file: string;
|
|
216
|
+
range: SourceRange;
|
|
217
|
+
kind: string;
|
|
218
|
+
title?: string;
|
|
219
|
+
}
|
|
220
|
+
| {
|
|
221
|
+
operation: "source_action";
|
|
222
|
+
file: string;
|
|
223
|
+
kind: string;
|
|
224
|
+
title?: string;
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
/** Supported refactor operation names. */
|
|
228
|
+
export type RefactorOperation = RefactorRequest["operation"];
|
|
229
|
+
|
|
230
|
+
/** The code action selected for a precise code- or source-action plan. */
|
|
231
|
+
export interface RefactorActionIdentity {
|
|
232
|
+
/** Server-reported CodeActionKind. */
|
|
233
|
+
kind: string;
|
|
234
|
+
/** Server-reported action title. */
|
|
235
|
+
title: string;
|
|
236
|
+
/**
|
|
237
|
+
* How the edits were obtained: `edit` from the action itself (after
|
|
238
|
+
* `codeAction/resolve` when needed), or `captured-command` from the single
|
|
239
|
+
* `workspace/applyEdit` the server pushed while running the action's command.
|
|
240
|
+
* Captured edits were refused, never applied.
|
|
241
|
+
*/
|
|
242
|
+
provenance?: "edit" | "captured-command";
|
|
221
243
|
}
|
|
222
244
|
|
|
223
245
|
/**
|
|
@@ -228,6 +250,10 @@ export interface DisambiguationCandidate {
|
|
|
228
250
|
file?: string;
|
|
229
251
|
line?: number;
|
|
230
252
|
character?: number;
|
|
253
|
+
/** Server-reported CodeActionKind, for code-action candidates. */
|
|
254
|
+
kind?: string;
|
|
255
|
+
/** Exact action title to pass back as `title`, for code-action candidates. */
|
|
256
|
+
title?: string;
|
|
231
257
|
}
|
|
232
258
|
|
|
233
259
|
/**
|
|
@@ -241,6 +267,8 @@ export type RefactorResult =
|
|
|
241
267
|
| {
|
|
242
268
|
kind: "precise";
|
|
243
269
|
edits: WorkspaceEdit;
|
|
270
|
+
/** The selected code action, for code- and source-action plans. */
|
|
271
|
+
action?: RefactorActionIdentity;
|
|
244
272
|
/** Provider roots authorized by the semantic route. Consumers canonicalize before storage. */
|
|
245
273
|
authorizedMutationRoots: string[];
|
|
246
274
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrclrchtr/supi-lsp",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "12.0.0",
|
|
4
4
|
"description": "Language Server Protocol runtime for SuPi code intelligence",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
"vscode-jsonrpc": "^10.0.0",
|
|
39
39
|
"vscode-languageserver-protocol": "^3.18.5",
|
|
40
40
|
"vscode-languageserver-types": "^3.18.5",
|
|
41
|
-
"@mrclrchtr/supi-code-runtime": "
|
|
42
|
-
"@mrclrchtr/supi-core": "
|
|
41
|
+
"@mrclrchtr/supi-code-runtime": "12.0.0",
|
|
42
|
+
"@mrclrchtr/supi-core": "12.0.0"
|
|
43
43
|
},
|
|
44
44
|
"bundledDependencies": [
|
|
45
45
|
"@mrclrchtr/supi-code-runtime",
|
package/src/api.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Public API surface for the LSP session-scoped service.
|
|
2
2
|
|
|
3
|
+
export type { CommandEditCaptureResult } from "./client/client-edit-capture.ts";
|
|
3
4
|
export { isMissingFileError } from "./client/client-file-state.ts";
|
|
4
5
|
export {
|
|
5
6
|
getExplicitlyDisabledLanguages,
|
|
@@ -7,10 +8,8 @@ export {
|
|
|
7
8
|
loadConfig,
|
|
8
9
|
} from "./config/config.ts";
|
|
9
10
|
export {
|
|
10
|
-
codeActionKindsForOperation,
|
|
11
11
|
getServerOperationSupport,
|
|
12
12
|
isCodeActionKindInRange,
|
|
13
|
-
REFACTOR_CODE_ACTION_KINDS,
|
|
14
13
|
supportsCodeActionResolve,
|
|
15
14
|
supportsCodeActions,
|
|
16
15
|
supportsPrepareRename,
|
|
@@ -91,6 +90,7 @@ export type {
|
|
|
91
90
|
ProcessCrashRecoveryOutcome,
|
|
92
91
|
ProcessCrashRecoveryReport,
|
|
93
92
|
RecoverDiagnosticsResult,
|
|
93
|
+
RoutedCodeActionResponse,
|
|
94
94
|
RoutedMutationDocumentSnapshot,
|
|
95
95
|
RoutedMutationResponse,
|
|
96
96
|
SemanticReadinessResult,
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// dynamic set for `textDocument/diagnostic` is non-empty.
|
|
8
8
|
|
|
9
9
|
import { posix as posixPath } from "node:path";
|
|
10
|
+
import type { CFamilyFileAssociationResolver } from "@mrclrchtr/supi-code-runtime/api";
|
|
10
11
|
import { uriToFile } from "@mrclrchtr/supi-core/path";
|
|
11
12
|
import { detectLanguageId } from "../utils.ts";
|
|
12
13
|
|
|
@@ -51,12 +52,21 @@ export function isValidDocumentSelector(value: unknown): boolean {
|
|
|
51
52
|
);
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
/**
|
|
55
|
-
|
|
55
|
+
/**
|
|
56
|
+
* Match one valid document selector against a file URI.
|
|
57
|
+
*
|
|
58
|
+
* Pass the client's file associations so the language matches the ID sent in
|
|
59
|
+
* `didOpen` (for example, a `.h` file associated with `cpp`).
|
|
60
|
+
*/
|
|
61
|
+
export function isDocumentSelectorApplicable(
|
|
62
|
+
selector: unknown,
|
|
63
|
+
uri: string,
|
|
64
|
+
associations?: CFamilyFileAssociationResolver,
|
|
65
|
+
): boolean {
|
|
56
66
|
if (selector === undefined || selector === null) return true;
|
|
57
67
|
if (!isValidDocumentSelector(selector)) return false;
|
|
58
68
|
if (!Array.isArray(selector) || selector.length === 0) return false;
|
|
59
|
-
const language = detectLanguageId(uriToFile(uri));
|
|
69
|
+
const language = detectLanguageId(uriToFile(uri), associations);
|
|
60
70
|
const scheme = readUriScheme(uri);
|
|
61
71
|
const filePath = uriToFile(uri).replaceAll("\\", "/");
|
|
62
72
|
return selector.some((entry) => {
|
|
@@ -237,22 +237,17 @@ export class ClientDiagnostics {
|
|
|
237
237
|
for (const update of updates) {
|
|
238
238
|
const document = this.#openDocs.get(update.document.uri);
|
|
239
239
|
if (!document) throw new Error("Semantic input changed while synchronization was running.");
|
|
240
|
-
this.#synchronizeTrackedDocument(
|
|
241
|
-
update.document.uri,
|
|
242
|
-
update.document.filePath,
|
|
243
|
-
update.content,
|
|
244
|
-
document,
|
|
245
|
-
);
|
|
240
|
+
this.#synchronizeTrackedDocument(update.document, update.content, document);
|
|
246
241
|
this.#failedUris.delete(update.document.uri);
|
|
247
242
|
}
|
|
248
243
|
}
|
|
249
244
|
|
|
250
245
|
/** Apply one tracked-document change with the shared client state. */
|
|
251
246
|
#synchronizeTrackedDocument(
|
|
252
|
-
uri: string,
|
|
253
|
-
filePath: string,
|
|
247
|
+
{ uri, filePath }: { readonly uri: string; readonly filePath: string },
|
|
254
248
|
content: string,
|
|
255
249
|
document: OpenDocumentState,
|
|
250
|
+
options: { fullText?: boolean } = {},
|
|
256
251
|
): void {
|
|
257
252
|
synchronizeTrackedDocument({
|
|
258
253
|
uri,
|
|
@@ -267,6 +262,7 @@ export class ClientDiagnostics {
|
|
|
267
262
|
markUnversionedSyncMoment: () => this.#unversionedPushSyncMoments.set(uri, Date.now()),
|
|
268
263
|
clearFailedFile: () => this.#failedUris.delete(uri),
|
|
269
264
|
open: () => this.didOpen(filePath, content),
|
|
265
|
+
...(options.fullText ? { fullText: true } : {}),
|
|
270
266
|
});
|
|
271
267
|
}
|
|
272
268
|
|
|
@@ -394,7 +390,27 @@ export class ClientDiagnostics {
|
|
|
394
390
|
const nextFingerprint = fingerprintDocumentContent(content);
|
|
395
391
|
if (doc.contentFingerprint === nextFingerprint) return;
|
|
396
392
|
this.#advanceInputRevision();
|
|
397
|
-
this.#synchronizeTrackedDocument(uri, filePath, content, doc);
|
|
393
|
+
this.#synchronizeTrackedDocument({ uri, filePath }, content, doc);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Re-send an open document's tracked content under a new version, even when
|
|
398
|
+
* it is unchanged. The tracked content matches disk: semantic requests
|
|
399
|
+
* synchronize open documents with disk before they run. Used after a server ran a command whose pushed
|
|
400
|
+
* edit was refused, so the server's model matches the client again.
|
|
401
|
+
* Returns the versions before and after, or null when the document is not open.
|
|
402
|
+
*/
|
|
403
|
+
resynchronizeOpenDocument(filePath: string): { from: number; to: number } | null {
|
|
404
|
+
if (!this.host.isOperational()) return null;
|
|
405
|
+
const uri = fileToUri(filePath);
|
|
406
|
+
const doc = this.#openDocs.get(uri);
|
|
407
|
+
if (!doc) return null;
|
|
408
|
+
const from = doc.version;
|
|
409
|
+
this.#advanceInputRevision();
|
|
410
|
+
// Full text: the server may have changed its own copy, so a range computed
|
|
411
|
+
// from the client's content could no longer cover the whole document.
|
|
412
|
+
this.#synchronizeTrackedDocument({ uri, filePath }, doc.content, doc, { fullText: true });
|
|
413
|
+
return { from, to: doc.version };
|
|
398
414
|
}
|
|
399
415
|
|
|
400
416
|
/** Send a save after a disk change that the input barrier observed. */
|
|
@@ -73,6 +73,8 @@ interface SynchronizeDocumentOptions {
|
|
|
73
73
|
documentSync: NormalizedDocumentSync;
|
|
74
74
|
waiters: DiagnosticWaitRegistry;
|
|
75
75
|
sendNotification: NotificationSender;
|
|
76
|
+
/** Send the whole text without a range, even under incremental sync (LSP allows both). */
|
|
77
|
+
fullText?: boolean;
|
|
76
78
|
}
|
|
77
79
|
|
|
78
80
|
function endPosition(content: string): { line: number; character: number } {
|
|
@@ -117,11 +119,9 @@ export function synchronizeDocument(options: SynchronizeDocumentOptions): void {
|
|
|
117
119
|
retainDocumentSynchronization(options);
|
|
118
120
|
options.document.version = options.version;
|
|
119
121
|
options.document.synchronizationId = options.synchronizationId;
|
|
120
|
-
const changes =
|
|
121
|
-
options.
|
|
122
|
-
options.content,
|
|
123
|
-
options.documentSync.change,
|
|
124
|
-
);
|
|
122
|
+
const changes = options.fullText
|
|
123
|
+
? [{ text: options.content }]
|
|
124
|
+
: contentChanges(options.document.content, options.content, options.documentSync.change);
|
|
125
125
|
options.document.content = options.content;
|
|
126
126
|
options.document.contentFingerprint = fingerprintDocumentContent(options.content);
|
|
127
127
|
if (options.documentSync.change === TextDocumentSyncKind.None) return;
|
|
@@ -161,6 +161,7 @@ export function synchronizeTrackedDocument(options: {
|
|
|
161
161
|
markUnversionedSyncMoment?(): void;
|
|
162
162
|
clearFailedFile?(): void;
|
|
163
163
|
open(): void;
|
|
164
|
+
fullText?: boolean;
|
|
164
165
|
}): void {
|
|
165
166
|
if (!options.document) {
|
|
166
167
|
options.open();
|
|
@@ -177,6 +178,7 @@ export function synchronizeTrackedDocument(options: {
|
|
|
177
178
|
documentSync: options.documentSync,
|
|
178
179
|
waiters: options.waiters,
|
|
179
180
|
sendNotification: options.sendNotification,
|
|
181
|
+
...(options.fullText ? { fullText: true } : {}),
|
|
180
182
|
});
|
|
181
183
|
options.clearFailedFile?.();
|
|
182
184
|
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capture window for server-pushed `workspace/applyEdit` requests.
|
|
3
|
+
*
|
|
4
|
+
* SuPi never applies an edit that a server pushes. While one command runs for
|
|
5
|
+
* preview, each pushed edit is recorded and refused; outside that window every
|
|
6
|
+
* pushed edit is refused as unsolicited (LSP 3.17 `ApplyWorkspaceEditResult`).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
type CodeRequestControl,
|
|
11
|
+
isCodeRequestInterruption,
|
|
12
|
+
} from "@mrclrchtr/supi-code-runtime/api";
|
|
13
|
+
import { uriToFile } from "@mrclrchtr/supi-core/path";
|
|
14
|
+
import type { Command, WorkspaceEdit } from "../config/types.ts";
|
|
15
|
+
import { LSP_REQUEST_TIMEOUT_ERROR_CODE } from "../debug-telemetry.ts";
|
|
16
|
+
import { JsonRpcRequestError } from "./transport.ts";
|
|
17
|
+
|
|
18
|
+
/** Reply for an edit recorded for preview. */
|
|
19
|
+
export const CAPTURED_EDIT_REPLY = {
|
|
20
|
+
applied: false,
|
|
21
|
+
failureReason: "SuPi captured this edit for preview.",
|
|
22
|
+
} as const;
|
|
23
|
+
|
|
24
|
+
/** Reply for an edit that arrives outside a capture window. */
|
|
25
|
+
export const UNSOLICITED_EDIT_REPLY = {
|
|
26
|
+
applied: false,
|
|
27
|
+
failureReason: "SuPi does not accept unsolicited workspace edits.",
|
|
28
|
+
} as const;
|
|
29
|
+
|
|
30
|
+
/** Result of running one command inside a capture window. */
|
|
31
|
+
export type CommandEditCaptureResult =
|
|
32
|
+
| {
|
|
33
|
+
readonly kind: "captured";
|
|
34
|
+
/** The single edit the server pushed while the command ran. */
|
|
35
|
+
readonly edit: WorkspaceEdit;
|
|
36
|
+
/** Open documents re-sent afterwards, with their versions before and after. */
|
|
37
|
+
readonly resynced: readonly { file: string; from: number; to: number }[];
|
|
38
|
+
}
|
|
39
|
+
| { readonly kind: "unavailable"; readonly reason: string };
|
|
40
|
+
|
|
41
|
+
/** Reply for an edit from a command SuPi stopped waiting for. */
|
|
42
|
+
export const ABANDONED_EDIT_REPLY = {
|
|
43
|
+
applied: false,
|
|
44
|
+
failureReason: "SuPi stopped waiting for the command that produced this edit.",
|
|
45
|
+
} as const;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Records pushed edits for at most one capture per client. After a capture
|
|
49
|
+
* stops waiting (timeout, abort, deadline) the window keeps draining until the
|
|
50
|
+
* abandoned command settles, so its late edits are refused and can never be
|
|
51
|
+
* attributed to the next capture.
|
|
52
|
+
*/
|
|
53
|
+
export class EditCaptureWindow {
|
|
54
|
+
#state: "idle" | "capturing" | "draining" = "idle";
|
|
55
|
+
#edits: unknown[] = [];
|
|
56
|
+
#onEdit: (() => void) | undefined;
|
|
57
|
+
|
|
58
|
+
/** Open the window. Returns false while another capture runs or drains. */
|
|
59
|
+
open(): boolean {
|
|
60
|
+
if (this.#state !== "idle") return false;
|
|
61
|
+
this.#state = "capturing";
|
|
62
|
+
this.#edits = [];
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Stop capturing and return the edits pushed so far. */
|
|
67
|
+
close(): unknown[] {
|
|
68
|
+
const edits = this.#edits;
|
|
69
|
+
this.#edits = [];
|
|
70
|
+
this.#onEdit = undefined;
|
|
71
|
+
if (this.#state === "capturing") this.#state = "idle";
|
|
72
|
+
return edits;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Stop capturing but refuse new captures until `settled` resolves. */
|
|
76
|
+
drain(settled: Promise<void>): unknown[] {
|
|
77
|
+
const edits = this.close();
|
|
78
|
+
this.#state = "draining";
|
|
79
|
+
void settled.then(() => {
|
|
80
|
+
if (this.#state === "draining") this.#state = "idle";
|
|
81
|
+
});
|
|
82
|
+
return edits;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Wait up to `ms` for the first edit when none has arrived yet. */
|
|
86
|
+
async waitForEdit(ms: number): Promise<void> {
|
|
87
|
+
if (this.#edits.length > 0 || ms <= 0) return;
|
|
88
|
+
await new Promise<void>((resolve) => {
|
|
89
|
+
const timer = setTimeout(resolve, ms);
|
|
90
|
+
this.#onEdit = () => {
|
|
91
|
+
clearTimeout(timer);
|
|
92
|
+
resolve();
|
|
93
|
+
};
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Answer one `workspace/applyEdit` request; the edit is never applied. */
|
|
98
|
+
handleApplyEdit(
|
|
99
|
+
params: unknown,
|
|
100
|
+
): typeof CAPTURED_EDIT_REPLY | typeof UNSOLICITED_EDIT_REPLY | typeof ABANDONED_EDIT_REPLY {
|
|
101
|
+
if (this.#state === "draining") return ABANDONED_EDIT_REPLY;
|
|
102
|
+
if (this.#state === "idle") return UNSOLICITED_EDIT_REPLY;
|
|
103
|
+
const edit =
|
|
104
|
+
typeof params === "object" && params !== null
|
|
105
|
+
? (params as { edit?: unknown }).edit
|
|
106
|
+
: undefined;
|
|
107
|
+
this.#edits.push(edit);
|
|
108
|
+
this.#onEdit?.();
|
|
109
|
+
return CAPTURED_EDIT_REPLY;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Collect the file URIs a captured edit touches, for resynchronization. */
|
|
114
|
+
export function editedDocumentUris(edit: unknown): string[] {
|
|
115
|
+
if (typeof edit !== "object" || edit === null) return [];
|
|
116
|
+
const uris = new Set<string>();
|
|
117
|
+
const { changes, documentChanges } = edit as {
|
|
118
|
+
changes?: unknown;
|
|
119
|
+
documentChanges?: unknown;
|
|
120
|
+
};
|
|
121
|
+
if (typeof changes === "object" && changes !== null) {
|
|
122
|
+
for (const uri of Object.keys(changes)) uris.add(uri);
|
|
123
|
+
}
|
|
124
|
+
if (Array.isArray(documentChanges)) {
|
|
125
|
+
for (const change of documentChanges) {
|
|
126
|
+
const uri = (change as { textDocument?: { uri?: unknown } } | null)?.textDocument?.uri;
|
|
127
|
+
if (typeof uri === "string") uris.add(uri);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return [...uris];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Default wait for an edit a server pushes just after answering the command. */
|
|
134
|
+
const DEFAULT_GRACE_MS = 250;
|
|
135
|
+
|
|
136
|
+
/** Collaborators for one command capture on one client. */
|
|
137
|
+
export interface CommandEditCaptureOptions {
|
|
138
|
+
readonly command: Command;
|
|
139
|
+
/** Upper bound for the command to finish. */
|
|
140
|
+
readonly timeoutMs: number;
|
|
141
|
+
/** Wait for an edit pushed after the command answers; LSP allows that order. */
|
|
142
|
+
readonly graceMs?: number;
|
|
143
|
+
/** Upper bound for refusing late edits from an abandoned command. */
|
|
144
|
+
readonly drainMs?: number;
|
|
145
|
+
readonly control?: CodeRequestControl;
|
|
146
|
+
readonly window: EditCaptureWindow;
|
|
147
|
+
/** Send one request and expose when its transport lifetime ends. */
|
|
148
|
+
sendRequestOwned(
|
|
149
|
+
method: string,
|
|
150
|
+
params: unknown,
|
|
151
|
+
options: { timeoutMs: number } & CodeRequestControl,
|
|
152
|
+
): { result: Promise<unknown>; settled: Promise<void> };
|
|
153
|
+
/** Re-send one open document; null when it is not open. */
|
|
154
|
+
resynchronize(file: string): { from: number; to: number } | null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Run one command inside a capture window. Exactly one pushed edit becomes the
|
|
159
|
+
* result; none, several, a timeout, or a server error are unavailable.
|
|
160
|
+
* Cancellation and deadline expiry propagate. Every touched open document is
|
|
161
|
+
* resynced, and an abandoned command keeps the window draining.
|
|
162
|
+
*/
|
|
163
|
+
export async function runCommandEditCapture(
|
|
164
|
+
options: CommandEditCaptureOptions,
|
|
165
|
+
): Promise<CommandEditCaptureResult> {
|
|
166
|
+
const { command, timeoutMs, control, window } = options;
|
|
167
|
+
if (!window.open()) {
|
|
168
|
+
return {
|
|
169
|
+
kind: "unavailable",
|
|
170
|
+
reason: "Another server command capture is running on this client; retry shortly.",
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
const request = options.sendRequestOwned(
|
|
174
|
+
"workspace/executeCommand",
|
|
175
|
+
{ command: command.command, ...(command.arguments ? { arguments: command.arguments } : {}) },
|
|
176
|
+
{ ...control, timeoutMs },
|
|
177
|
+
);
|
|
178
|
+
let serverError: string | undefined;
|
|
179
|
+
try {
|
|
180
|
+
await request.result;
|
|
181
|
+
} catch (error) {
|
|
182
|
+
if (!(error instanceof JsonRpcRequestError) || isCodeRequestInterruption(error, control)) {
|
|
183
|
+
return abandonCapture(options, request.settled, error);
|
|
184
|
+
}
|
|
185
|
+
// The server answered with an error, so the command is over. clangd
|
|
186
|
+
// reports that the refused edit "was not applied"; the edit still counts.
|
|
187
|
+
serverError = error.message;
|
|
188
|
+
}
|
|
189
|
+
if (serverError === undefined) await window.waitForEdit(options.graceMs ?? DEFAULT_GRACE_MS);
|
|
190
|
+
const edits = window.close();
|
|
191
|
+
const resynced = resynchronizeEdits(edits, options.resynchronize);
|
|
192
|
+
if (edits.length === 0) {
|
|
193
|
+
return {
|
|
194
|
+
kind: "unavailable",
|
|
195
|
+
reason:
|
|
196
|
+
serverError === undefined
|
|
197
|
+
? "The server command produced no edit; there may be nothing to change."
|
|
198
|
+
: `The server command failed: ${serverError}`,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
if (edits.length > 1) {
|
|
202
|
+
return {
|
|
203
|
+
kind: "unavailable",
|
|
204
|
+
reason: `The server command produced ${edits.length} edits; only one can be previewed.`,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
return { kind: "captured", edit: edits[0] as WorkspaceEdit, resynced };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Close or drain the window after SuPi stopped waiting for the command, then report or rethrow. */
|
|
211
|
+
function abandonCapture(
|
|
212
|
+
options: CommandEditCaptureOptions,
|
|
213
|
+
settled: Promise<void>,
|
|
214
|
+
error: unknown,
|
|
215
|
+
): CommandEditCaptureResult {
|
|
216
|
+
const { window, timeoutMs, control } = options;
|
|
217
|
+
const drainMs = options.drainMs ?? timeoutMs;
|
|
218
|
+
const edits = drainMs > 0 ? window.drain(boundedSettle(settled, drainMs)) : window.close();
|
|
219
|
+
resynchronizeEdits(edits, options.resynchronize);
|
|
220
|
+
if (isCodeRequestInterruption(error, control)) throw error;
|
|
221
|
+
return {
|
|
222
|
+
kind: "unavailable",
|
|
223
|
+
reason: isTimeout(error)
|
|
224
|
+
? `The server command did not finish within ${timeoutMs} ms.`
|
|
225
|
+
: `The server command could not run: ${error instanceof Error ? error.message : String(error)}`,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function boundedSettle(settled: Promise<void>, ms: number): Promise<void> {
|
|
230
|
+
return Promise.race([settled, new Promise<void>((resolve) => setTimeout(resolve, ms))]);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function resynchronizeEdits(
|
|
234
|
+
edits: readonly unknown[],
|
|
235
|
+
resynchronize: CommandEditCaptureOptions["resynchronize"],
|
|
236
|
+
): { file: string; from: number; to: number }[] {
|
|
237
|
+
const uris = new Set(edits.flatMap((edit) => editedDocumentUris(edit)));
|
|
238
|
+
const resynced: { file: string; from: number; to: number }[] = [];
|
|
239
|
+
for (const uri of uris) {
|
|
240
|
+
let file: string;
|
|
241
|
+
try {
|
|
242
|
+
file = uriToFile(uri);
|
|
243
|
+
} catch {
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
const versions = resynchronize(file);
|
|
247
|
+
if (versions) resynced.push({ file, ...versions });
|
|
248
|
+
}
|
|
249
|
+
return resynced;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function isTimeout(error: unknown): boolean {
|
|
253
|
+
return (
|
|
254
|
+
typeof error === "object" &&
|
|
255
|
+
error !== null &&
|
|
256
|
+
(error as { code?: unknown }).code === LSP_REQUEST_TIMEOUT_ERROR_CODE
|
|
257
|
+
);
|
|
258
|
+
}
|