@mrclrchtr/supi-lsp 11.3.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 +22 -17
- package/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
- package/node_modules/@mrclrchtr/supi-code-runtime/src/api.ts +3 -1
- package/node_modules/@mrclrchtr/supi-code-runtime/src/declaration-level.ts +34 -0
- package/node_modules/@mrclrchtr/supi-code-runtime/src/types.ts +64 -28
- package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
- package/node_modules/semver/classes/range.js +2 -2
- package/node_modules/semver/internal/constants.js +30 -0
- package/node_modules/semver/internal/trim-comparator-whitespace.js +438 -0
- package/node_modules/semver/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/config/tsconfig-scope.ts +10 -0
- package/src/manager/manager-typescript-coverage.ts +118 -0
- package/src/manager/manager.ts +67 -3
- package/src/manager/typescript-file-local.ts +87 -0
- 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/provider/semantic-symbol-mapper.ts +22 -17
- package/src/session/route-boundary.ts +26 -0
- package/src/session/runtime-registry.ts +106 -16
- package/src/session/runtime-rename.ts +3 -0
- package/src/session/workspace-lsp-runtime.ts +22 -1
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import { collectExtendedProjectConfigs } from "../config/tsconfig-extends.ts";
|
|
3
|
+
import { normalizeTsconfigPath } from "../config/tsconfig-path.ts";
|
|
4
|
+
import { getFileScopeDecision, projectConfigIncludesFile } from "../config/tsconfig-scope.ts";
|
|
5
|
+
import { type AutomaticLspPathPolicy, walkAutomaticLspTree } from "../workspace-path-policy.ts";
|
|
6
|
+
|
|
7
|
+
/** Directory depth searched for project configs below a TypeScript route root. */
|
|
8
|
+
const PROJECT_CONFIG_SCAN_DEPTH = 8;
|
|
9
|
+
/** Project config paths named in one coverage reason. */
|
|
10
|
+
const MAX_NAMED_PROJECTS = 3;
|
|
11
|
+
/** File names tsserver loads as projects; `tsconfig.*.json` variants are never auto-loaded. */
|
|
12
|
+
const LOADABLE_PROJECT_CONFIGS = new Set(["tsconfig.json", "jsconfig.json"]);
|
|
13
|
+
|
|
14
|
+
/** Find loadable project configs under a route root, honoring the automatic path policy. */
|
|
15
|
+
export function discoverTypeScriptProjectConfigs(
|
|
16
|
+
root: string,
|
|
17
|
+
policy: AutomaticLspPathPolicy,
|
|
18
|
+
): string[] {
|
|
19
|
+
const configs: string[] = [];
|
|
20
|
+
walkAutomaticLspTree(policy, root, PROJECT_CONFIG_SCAN_DEPTH, (directory, entries) => {
|
|
21
|
+
for (const entry of entries) {
|
|
22
|
+
if (entry.isFile() && LOADABLE_PROJECT_CONFIGS.has(entry.name)) {
|
|
23
|
+
configs.push(path.join(directory, entry.name));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return false;
|
|
27
|
+
});
|
|
28
|
+
return configs;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Project configs whose program includes `file` but show no sign of being loaded.
|
|
33
|
+
*
|
|
34
|
+
* tsserver answers project-wide requests such as references and
|
|
35
|
+
* implementations only from loaded projects, and loads a project only when one
|
|
36
|
+
* of its files is open. An unloaded including project (typically a test
|
|
37
|
+
* config that includes `../src`) can hold matches the answer omits.
|
|
38
|
+
*
|
|
39
|
+
* A project counts as loaded when it is the nearest config of an open file or
|
|
40
|
+
* of a file in the answer (`resultFiles`): tsserver also loads projects on its
|
|
41
|
+
* own during a search, and a match from a project's own file proves it was searched.
|
|
42
|
+
*
|
|
43
|
+
* Base configs extended by another discovered config are skipped: their
|
|
44
|
+
* default include usually spans the whole tree while they own only root
|
|
45
|
+
* tooling files, so counting them would mark nearly every answer partial.
|
|
46
|
+
*/
|
|
47
|
+
export function findUnloadedTypeScriptProjects(options: {
|
|
48
|
+
readonly file: string;
|
|
49
|
+
readonly cwd: string;
|
|
50
|
+
readonly openFiles: readonly string[];
|
|
51
|
+
readonly resultFiles?: readonly string[];
|
|
52
|
+
readonly configs: readonly string[];
|
|
53
|
+
}): string[] {
|
|
54
|
+
const baseConfigs = new Set(
|
|
55
|
+
options.configs.flatMap((config) => [...collectExtendedProjectConfigs(config)]),
|
|
56
|
+
);
|
|
57
|
+
const loaded = new Set<string>();
|
|
58
|
+
for (const loadedFile of [...options.openFiles, ...(options.resultFiles ?? [])]) {
|
|
59
|
+
const decision = getFileScopeDecision(loadedFile, options.cwd);
|
|
60
|
+
if (decision.status === "included" && decision.configPath) {
|
|
61
|
+
loaded.add(normalizeTsconfigPath(decision.configPath));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return options.configs.filter((config) => {
|
|
65
|
+
const key = normalizeTsconfigPath(config);
|
|
66
|
+
return (
|
|
67
|
+
!loaded.has(key) && !baseConfigs.has(key) && projectConfigIncludesFile(config, options.file)
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Other projects that tsserver's implementation search skips for `file`.
|
|
74
|
+
*
|
|
75
|
+
* tsserver 6 and earlier answer `textDocument/implementation` from the file's
|
|
76
|
+
* default project only, unlike references, which span loaded projects. Any
|
|
77
|
+
* other non-base project may implement the target through an import, and
|
|
78
|
+
* opening its files does not change the answer.
|
|
79
|
+
*/
|
|
80
|
+
export function findSkippedImplementationProjects(options: {
|
|
81
|
+
readonly file: string;
|
|
82
|
+
readonly cwd: string;
|
|
83
|
+
readonly configs: readonly string[];
|
|
84
|
+
}): string[] {
|
|
85
|
+
const baseConfigs = new Set(
|
|
86
|
+
options.configs.flatMap((config) => [...collectExtendedProjectConfigs(config)]),
|
|
87
|
+
);
|
|
88
|
+
const decision = getFileScopeDecision(options.file, options.cwd);
|
|
89
|
+
const own = decision.configPath ? normalizeTsconfigPath(decision.configPath) : undefined;
|
|
90
|
+
return options.configs.filter((config) => {
|
|
91
|
+
const key = normalizeTsconfigPath(config);
|
|
92
|
+
return key !== own && !baseConfigs.has(key);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Partial-result reason for tsserver's single-project implementation search. */
|
|
97
|
+
export function singleProjectImplementationReason(skipped: readonly string[]): string {
|
|
98
|
+
const noun = skipped.length === 1 ? "1 other project is" : `${skipped.length} other projects are`;
|
|
99
|
+
return (
|
|
100
|
+
"TypeScript coverage is limited: the TypeScript 6 or earlier server (tsserver) " +
|
|
101
|
+
`searches implementations only in this file's project; ${noun} not searched. ` +
|
|
102
|
+
"Use references to find uses in other projects, or use TypeScript 7."
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Actionable partial-result reason naming a bounded set of unloaded projects. */
|
|
107
|
+
export function typeScriptCoverageGapReason(unloaded: readonly string[], cwd: string): string {
|
|
108
|
+
const named = unloaded
|
|
109
|
+
.slice(0, MAX_NAMED_PROJECTS)
|
|
110
|
+
.map((config) => path.relative(cwd, config) || path.basename(config));
|
|
111
|
+
const more = unloaded.length > named.length ? `, +${unloaded.length - named.length} more` : "";
|
|
112
|
+
const noun = unloaded.length === 1 ? "project that includes" : "projects that include";
|
|
113
|
+
return (
|
|
114
|
+
`TypeScript coverage is limited: ${unloaded.length} ${noun} this file ` +
|
|
115
|
+
`${unloaded.length === 1 ? "is" : "are"} not loaded (${named.join(", ")}${more}). ` +
|
|
116
|
+
"Open a file from each project and retry."
|
|
117
|
+
);
|
|
118
|
+
}
|
package/src/manager/manager.ts
CHANGED
|
@@ -128,6 +128,13 @@ import type {
|
|
|
128
128
|
OutstandingDiagnosticSummaryEntry,
|
|
129
129
|
ServerStatus,
|
|
130
130
|
} from "./manager-types.ts";
|
|
131
|
+
import {
|
|
132
|
+
discoverTypeScriptProjectConfigs,
|
|
133
|
+
findSkippedImplementationProjects,
|
|
134
|
+
findUnloadedTypeScriptProjects,
|
|
135
|
+
singleProjectImplementationReason,
|
|
136
|
+
typeScriptCoverageGapReason,
|
|
137
|
+
} from "./manager-typescript-coverage.ts";
|
|
131
138
|
import {
|
|
132
139
|
recoverWorkspaceDiagnostics as recoverWorkspaceDiagnosticsImpl,
|
|
133
140
|
type WorkspaceRecoveryResult,
|
|
@@ -141,6 +148,7 @@ import {
|
|
|
141
148
|
workspaceSymbolCollectionResult,
|
|
142
149
|
workspaceSymbolCoverageGap,
|
|
143
150
|
} from "./manager-workspace-symbol.ts";
|
|
151
|
+
import { isFileLocalTopLevelDeclaration } from "./typescript-file-local.ts";
|
|
144
152
|
|
|
145
153
|
type UnavailableReason = "missing-command" | "start-failed" | "runtime-error";
|
|
146
154
|
|
|
@@ -412,6 +420,8 @@ export class LspManager {
|
|
|
412
420
|
private warmedSemanticProjects = new Set<string>();
|
|
413
421
|
/** In-flight warm-up probes keyed by project key so concurrent callers share one probe. */
|
|
414
422
|
private pendingWarmProbes = new Map<string, Promise<void>>();
|
|
423
|
+
/** Loadable TypeScript project configs per route root; cleared when a config changes. */
|
|
424
|
+
private typeScriptProjectConfigs = new Map<string, string[]>();
|
|
415
425
|
/** One process-crash recovery budget and shared replacement per route. */
|
|
416
426
|
private processCrashRecoveries = new Map<string, ProcessCrashRecoveryState>();
|
|
417
427
|
/** Client generations that completed the initialize handshake. */
|
|
@@ -572,6 +582,53 @@ export class LspManager {
|
|
|
572
582
|
);
|
|
573
583
|
}
|
|
574
584
|
|
|
585
|
+
/**
|
|
586
|
+
* Coverage gap for a project-wide TypeScript request answered by `client`.
|
|
587
|
+
*
|
|
588
|
+
* Returns a partial-result reason when a TypeScript project that includes
|
|
589
|
+
* `filePath` owns neither an open file nor a file in `resultFiles`, so
|
|
590
|
+
* tsserver may not have searched it; otherwise null. Non-TypeScript routes
|
|
591
|
+
* always return null.
|
|
592
|
+
*
|
|
593
|
+
* Implementation requests not known to use the native backend report every
|
|
594
|
+
* other project instead: tsserver searches only the file's own project.
|
|
595
|
+
*
|
|
596
|
+
* No gap applies when `position` (zero-based) names a top-level declaration
|
|
597
|
+
* its module does not export: no other project can reference it.
|
|
598
|
+
*/
|
|
599
|
+
typeScriptProjectCoverageGap(
|
|
600
|
+
client: LspClient,
|
|
601
|
+
filePath: string,
|
|
602
|
+
request: "references" | "implementation",
|
|
603
|
+
evidence: {
|
|
604
|
+
readonly resultFiles?: readonly string[];
|
|
605
|
+
readonly position?: { readonly line: number; readonly character: number };
|
|
606
|
+
} = {},
|
|
607
|
+
): string | null {
|
|
608
|
+
const { resultFiles = [], position } = evidence;
|
|
609
|
+
if (client.name !== "typescript") return null;
|
|
610
|
+
const rootKey = path.resolve(client.root);
|
|
611
|
+
let configs = this.typeScriptProjectConfigs.get(rootKey);
|
|
612
|
+
if (!configs) {
|
|
613
|
+
configs = discoverTypeScriptProjectConfigs(rootKey, this.automaticPathPolicy);
|
|
614
|
+
this.typeScriptProjectConfigs.set(rootKey, configs);
|
|
615
|
+
}
|
|
616
|
+
const file = resolveSessionPath(this.cwd, filePath);
|
|
617
|
+
if (position && isFileLocalTopLevelDeclaration(file, position)) return null;
|
|
618
|
+
if (request === "implementation" && client.typescriptSelection?.backend !== "native") {
|
|
619
|
+
const skipped = findSkippedImplementationProjects({ file, cwd: this.cwd, configs });
|
|
620
|
+
return skipped.length > 0 ? singleProjectImplementationReason(skipped) : null;
|
|
621
|
+
}
|
|
622
|
+
const unloaded = findUnloadedTypeScriptProjects({
|
|
623
|
+
file,
|
|
624
|
+
cwd: this.cwd,
|
|
625
|
+
openFiles: client.openFiles,
|
|
626
|
+
resultFiles,
|
|
627
|
+
configs,
|
|
628
|
+
});
|
|
629
|
+
return unloaded.length > 0 ? typeScriptCoverageGapReason(unloaded, this.cwd) : null;
|
|
630
|
+
}
|
|
631
|
+
|
|
575
632
|
/** Get or create an LSP client for the given file. */
|
|
576
633
|
async getClientForFile(
|
|
577
634
|
filePath: string,
|
|
@@ -1050,7 +1107,9 @@ export class LspManager {
|
|
|
1050
1107
|
if (client.status !== "running" || !client.ready) return;
|
|
1051
1108
|
const key = clientKey(client.name, client.root);
|
|
1052
1109
|
const recovery = this.processCrashRecoveries.get(key);
|
|
1053
|
-
|
|
1110
|
+
// An in-flight recovery owns its record until it settles; removing it here
|
|
1111
|
+
// would make that recovery report cancellation for a live replacement.
|
|
1112
|
+
if (recovery && !recovery.pending) {
|
|
1054
1113
|
recovery.attemptConsumed = false;
|
|
1055
1114
|
recovery.statusReason = undefined;
|
|
1056
1115
|
recovery.failureMessage = undefined;
|
|
@@ -2397,7 +2456,7 @@ export class LspManager {
|
|
|
2397
2456
|
*/
|
|
2398
2457
|
noteWorkspaceChanges(changes: FileEvent[]): void {
|
|
2399
2458
|
if (changes.length > 0) this.invalidationEpoch++;
|
|
2400
|
-
invalidateProjectConfigCaches(changes);
|
|
2459
|
+
if (invalidateProjectConfigCaches(changes)) this.typeScriptProjectConfigs.clear();
|
|
2401
2460
|
this.notifyWorkspaceFileChanges(changes);
|
|
2402
2461
|
}
|
|
2403
2462
|
|
|
@@ -2586,6 +2645,7 @@ export class LspManager {
|
|
|
2586
2645
|
this.warmedWorkspaceSymbolFiles.clear();
|
|
2587
2646
|
this.warmedSemanticProjects.clear();
|
|
2588
2647
|
this.pendingWarmProbes.clear();
|
|
2648
|
+
this.typeScriptProjectConfigs.clear();
|
|
2589
2649
|
this.pendingRestarts.clear();
|
|
2590
2650
|
}
|
|
2591
2651
|
/** Get status of all servers. */
|
|
@@ -3437,15 +3497,19 @@ export class LspManager {
|
|
|
3437
3497
|
}
|
|
3438
3498
|
}
|
|
3439
3499
|
|
|
3440
|
-
|
|
3500
|
+
/** Invalidate parsed project configs; returns whether any config changed. */
|
|
3501
|
+
function invalidateProjectConfigCaches(changes: readonly FileEvent[]): boolean {
|
|
3502
|
+
let changed = false;
|
|
3441
3503
|
for (const change of changes) {
|
|
3442
3504
|
const configPath = uriToFile(change.uri);
|
|
3443
3505
|
if (!isProjectConfigFileName(path.basename(configPath))) continue;
|
|
3506
|
+
changed = true;
|
|
3444
3507
|
invalidateTsconfigCacheForConfig(configPath);
|
|
3445
3508
|
if (change.type === FileChangeType.Created) {
|
|
3446
3509
|
invalidateTsconfigCacheForConfigDir(path.dirname(configPath));
|
|
3447
3510
|
}
|
|
3448
3511
|
}
|
|
3512
|
+
return changed;
|
|
3449
3513
|
}
|
|
3450
3514
|
|
|
3451
3515
|
function collectClientDiagnosticEvidenceDocuments(
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Whether the identifier at `position` names a top-level declaration that only
|
|
6
|
+
* its own file can reference: the file is an ES module and the declaration is
|
|
7
|
+
* neither exported by a modifier nor by an export clause or assignment.
|
|
8
|
+
*
|
|
9
|
+
* Other TypeScript projects can then hold no references or implementations,
|
|
10
|
+
* so a project coverage gap cannot apply. Script files (no import or export),
|
|
11
|
+
* members, unreadable files, and non-declaration positions return false.
|
|
12
|
+
*
|
|
13
|
+
* @param position Zero-based LSP position.
|
|
14
|
+
*/
|
|
15
|
+
export function isFileLocalTopLevelDeclaration(
|
|
16
|
+
filePath: string,
|
|
17
|
+
position: { readonly line: number; readonly character: number },
|
|
18
|
+
): boolean {
|
|
19
|
+
let text: string;
|
|
20
|
+
try {
|
|
21
|
+
text = fs.readFileSync(filePath, "utf8");
|
|
22
|
+
} catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
const sourceFile = ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true);
|
|
26
|
+
if (!ts.isExternalModule(sourceFile)) return false;
|
|
27
|
+
let offset: number;
|
|
28
|
+
try {
|
|
29
|
+
offset = ts.getPositionOfLineAndCharacter(sourceFile, position.line, position.character);
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
const statement = sourceFile.statements.find(
|
|
34
|
+
(candidate) => candidate.getStart(sourceFile) <= offset && offset < candidate.end,
|
|
35
|
+
);
|
|
36
|
+
if (!statement) return false;
|
|
37
|
+
const name = topLevelDeclarationNameAt(statement, offset, sourceFile);
|
|
38
|
+
return name !== null && !hasExportModifier(statement) && !exportedByName(sourceFile, name);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Name of the declaration `statement` declares at `offset`, or null. */
|
|
42
|
+
function topLevelDeclarationNameAt(
|
|
43
|
+
statement: ts.Statement,
|
|
44
|
+
offset: number,
|
|
45
|
+
sourceFile: ts.SourceFile,
|
|
46
|
+
): string | null {
|
|
47
|
+
const names: ts.Identifier[] = [];
|
|
48
|
+
if (
|
|
49
|
+
ts.isFunctionDeclaration(statement) ||
|
|
50
|
+
ts.isClassDeclaration(statement) ||
|
|
51
|
+
ts.isInterfaceDeclaration(statement) ||
|
|
52
|
+
ts.isTypeAliasDeclaration(statement) ||
|
|
53
|
+
ts.isEnumDeclaration(statement)
|
|
54
|
+
) {
|
|
55
|
+
if (statement.name) names.push(statement.name);
|
|
56
|
+
} else if (ts.isVariableStatement(statement)) {
|
|
57
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
58
|
+
if (ts.isIdentifier(declaration.name)) names.push(declaration.name);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const hit = names.find((name) => name.getStart(sourceFile) <= offset && offset <= name.end);
|
|
62
|
+
return hit ? hit.text : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function hasExportModifier(statement: ts.Statement): boolean {
|
|
66
|
+
return (
|
|
67
|
+
ts.canHaveModifiers(statement) &&
|
|
68
|
+
(ts.getModifiers(statement) ?? []).some(
|
|
69
|
+
(modifier) =>
|
|
70
|
+
modifier.kind === ts.SyntaxKind.ExportKeyword ||
|
|
71
|
+
modifier.kind === ts.SyntaxKind.DeclareKeyword,
|
|
72
|
+
)
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** `export { name }`, `export default name`, or `export = name` in this file. */
|
|
77
|
+
function exportedByName(sourceFile: ts.SourceFile, name: string): boolean {
|
|
78
|
+
return sourceFile.statements.some((statement) => {
|
|
79
|
+
if (ts.isExportAssignment(statement)) {
|
|
80
|
+
return ts.isIdentifier(statement.expression) && statement.expression.text === name;
|
|
81
|
+
}
|
|
82
|
+
if (!ts.isExportDeclaration(statement) || statement.moduleSpecifier) return false;
|
|
83
|
+
const clause = statement.exportClause;
|
|
84
|
+
if (!clause || !ts.isNamedExports(clause)) return false;
|
|
85
|
+
return clause.elements.some((element) => (element.propertyName ?? element.name).text === name);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
@@ -7,10 +7,10 @@ import type {
|
|
|
7
7
|
import type { WorkspaceLspRuntime } from "../session/runtime-registry.ts";
|
|
8
8
|
import {
|
|
9
9
|
collectCodeActionResults,
|
|
10
|
-
|
|
11
|
-
isUpdateImportsCodeAction,
|
|
12
|
-
runFilteredCodeActionRefactor,
|
|
10
|
+
runCodeActionRefactor,
|
|
13
11
|
runRenameRefactor,
|
|
12
|
+
runSourceActionRefactor,
|
|
13
|
+
wholeDocumentRange,
|
|
14
14
|
} from "./refactor-planning.ts";
|
|
15
15
|
|
|
16
16
|
/** Build optional refactor methods for the semantic provider adapter. */
|
|
@@ -45,12 +45,6 @@ async function planRefactor(
|
|
|
45
45
|
): Promise<RefactorResult> {
|
|
46
46
|
switch (request.operation) {
|
|
47
47
|
case "rename_symbol":
|
|
48
|
-
if (!request.newName) {
|
|
49
|
-
return {
|
|
50
|
-
kind: "unavailable",
|
|
51
|
-
reason: 'Refactor operation "rename_symbol" requires `newName`.',
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
48
|
return runRenameRefactor({
|
|
55
49
|
lsp,
|
|
56
50
|
file: request.file,
|
|
@@ -58,39 +52,31 @@ async function planRefactor(
|
|
|
58
52
|
newName: request.newName,
|
|
59
53
|
control,
|
|
60
54
|
});
|
|
61
|
-
case "
|
|
62
|
-
|
|
63
|
-
return {
|
|
64
|
-
kind: "unavailable",
|
|
65
|
-
reason: `Refactor operation "${request.operation}" is unavailable: standard LSP code actions do not provide a safe exact extraction-name contract.`,
|
|
66
|
-
};
|
|
67
|
-
case "update_imports":
|
|
68
|
-
return runFilteredCodeActionRefactor({
|
|
55
|
+
case "code_action":
|
|
56
|
+
return runCodeActionRefactor({
|
|
69
57
|
lsp,
|
|
70
58
|
file: request.file,
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
59
|
+
range: request.range,
|
|
60
|
+
kind: request.kind,
|
|
61
|
+
title: request.title,
|
|
62
|
+
label: "Code action",
|
|
74
63
|
control,
|
|
75
64
|
});
|
|
76
|
-
case "
|
|
77
|
-
|
|
65
|
+
case "source_action": {
|
|
66
|
+
// LSP source actions apply to the entire file, so request the whole document.
|
|
67
|
+
const range = wholeDocumentRange(request.file);
|
|
68
|
+
if (!range) {
|
|
69
|
+
return { kind: "unavailable", reason: `File ${request.file} could not be read.` };
|
|
70
|
+
}
|
|
71
|
+
return runSourceActionRefactor({
|
|
78
72
|
lsp,
|
|
79
73
|
file: request.file,
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
74
|
+
range,
|
|
75
|
+
kind: request.kind,
|
|
76
|
+
title: request.title,
|
|
77
|
+
label: "Source action",
|
|
83
78
|
control,
|
|
84
79
|
});
|
|
85
|
-
|
|
86
|
-
case "move_file":
|
|
87
|
-
return {
|
|
88
|
-
kind: "unavailable",
|
|
89
|
-
reason: `Refactor operation "${request.operation}" is not supported yet. File/resource operations are deferred.`,
|
|
90
|
-
};
|
|
80
|
+
}
|
|
91
81
|
}
|
|
92
|
-
return {
|
|
93
|
-
kind: "unavailable",
|
|
94
|
-
reason: `Refactor operation "${request.operation}" is not supported by the active semantic provider.`,
|
|
95
|
-
};
|
|
96
82
|
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan command-only code actions by capturing the edit their command pushes.
|
|
3
|
+
*
|
|
4
|
+
* Only `refactor*`, `source.organizeImports*`, and `source.fixAll*` commands
|
|
5
|
+
* may run, and only when the server lists the command (if it lists any). Other
|
|
6
|
+
* source commands can have side effects outside the editor, so they never run.
|
|
7
|
+
* Several eligible actions stay ambiguous without running any command.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
CodeRequestControl,
|
|
12
|
+
DisambiguationCandidate,
|
|
13
|
+
DocumentEditPrecondition,
|
|
14
|
+
RefactorResult,
|
|
15
|
+
} from "@mrclrchtr/supi-code-runtime/api";
|
|
16
|
+
import { isCodeActionKindWithin } from "../config/operation-support.ts";
|
|
17
|
+
import type { CodeAction, Command } from "../config/types.ts";
|
|
18
|
+
import type { WorkspaceLspRuntime } from "../session/workspace-lsp-runtime.ts";
|
|
19
|
+
import { normalizeSemanticEdit } from "./semantic-edit-normalizer.ts";
|
|
20
|
+
|
|
21
|
+
const CAPTURE_ELIGIBLE_KINDS = ["refactor", "source.organizeImports", "source.fixAll"] as const;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Stable reason for command-only actions SuPi will not run: an ineligible kind
|
|
25
|
+
* or a command the server does not list.
|
|
26
|
+
*/
|
|
27
|
+
export const COMMAND_ONLY_ACTION_REASON =
|
|
28
|
+
"Matching code actions only run a server command that SuPi does not run for preview.";
|
|
29
|
+
|
|
30
|
+
/** A code action whose only effect is a server command. */
|
|
31
|
+
export type CommandOnlyAction = CodeAction & { command: Command; kind: string };
|
|
32
|
+
|
|
33
|
+
/** Whether a code action has a command and no edit. */
|
|
34
|
+
export function isCommandOnlyAction(action: CodeAction): boolean {
|
|
35
|
+
return Object.hasOwn(action, "command") && !action.edit;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Select the command-only actions SuPi may run to capture an edit: an eligible
|
|
40
|
+
* kind, and a command the server lists when it lists any.
|
|
41
|
+
*/
|
|
42
|
+
export function captureEligibleActions(
|
|
43
|
+
actions: readonly CodeAction[],
|
|
44
|
+
advertisedCommands: readonly string[] | undefined,
|
|
45
|
+
): CommandOnlyAction[] {
|
|
46
|
+
return actions.filter((action): action is CommandOnlyAction => {
|
|
47
|
+
const command = action.command;
|
|
48
|
+
return (
|
|
49
|
+
isCommandOnlyAction(action) &&
|
|
50
|
+
typeof action.kind === "string" &&
|
|
51
|
+
typeof command?.command === "string" &&
|
|
52
|
+
CAPTURE_ELIGIBLE_KINDS.some((kind) => isCodeActionKindWithin(action.kind, kind)) &&
|
|
53
|
+
(advertisedCommands === undefined || advertisedCommands.includes(command.command))
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Candidates for several eligible command-only actions; no command has run. */
|
|
59
|
+
export function commandCandidates(actions: readonly CommandOnlyAction[]): RefactorResult {
|
|
60
|
+
return {
|
|
61
|
+
kind: "ambiguous",
|
|
62
|
+
candidates: actions.map(
|
|
63
|
+
(action): DisambiguationCandidate => ({
|
|
64
|
+
description: action.title,
|
|
65
|
+
kind: action.kind,
|
|
66
|
+
title: action.title,
|
|
67
|
+
}),
|
|
68
|
+
),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Run one action's command, capture its single pushed edit, and normalize it
|
|
74
|
+
* like a direct edit. Versioned preconditions move to the resynced versions,
|
|
75
|
+
* which hold the same content.
|
|
76
|
+
*/
|
|
77
|
+
export async function captureCommandAction(options: {
|
|
78
|
+
lsp: WorkspaceLspRuntime;
|
|
79
|
+
file: string;
|
|
80
|
+
action: CommandOnlyAction;
|
|
81
|
+
control?: CodeRequestControl;
|
|
82
|
+
}): Promise<RefactorResult> {
|
|
83
|
+
const { lsp, file, action, control } = options;
|
|
84
|
+
const response = await lsp.captureCodeActionEdit(file, action, control);
|
|
85
|
+
if (!response) {
|
|
86
|
+
return { kind: "unavailable", reason: "No routed LSP client could run the command." };
|
|
87
|
+
}
|
|
88
|
+
const roots = [...response.authorizedMutationRoots];
|
|
89
|
+
const capture = response.value;
|
|
90
|
+
if (!capture || capture.kind === "unavailable") {
|
|
91
|
+
return {
|
|
92
|
+
kind: "unavailable",
|
|
93
|
+
reason: `Command for "${action.title}" is unavailable: ${capture?.reason ?? response.reason ?? "no result."}`,
|
|
94
|
+
authorizedMutationRoots: roots,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const before = new Map(capture.resynced.map((entry) => [entry.file, entry]));
|
|
99
|
+
const result = normalizeSemanticEdit(
|
|
100
|
+
{ kind: "workspace-edit", edit: capture.edit },
|
|
101
|
+
{
|
|
102
|
+
// Captured edits target the versions from before the resync.
|
|
103
|
+
getOpenDocumentVersion: (candidate) => {
|
|
104
|
+
const current = lsp.getOpenDocumentVersion(candidate);
|
|
105
|
+
const versions = before.get(candidate);
|
|
106
|
+
return versions && current === versions.to ? versions.from : current;
|
|
107
|
+
},
|
|
108
|
+
authorizedMutationRoots: response.authorizedMutationRoots,
|
|
109
|
+
},
|
|
110
|
+
);
|
|
111
|
+
if (result.kind !== "precise") {
|
|
112
|
+
return result.kind === "unavailable"
|
|
113
|
+
? {
|
|
114
|
+
kind: "unavailable",
|
|
115
|
+
reason: `Captured edit for "${action.title}" could not produce precise edits: ${result.reason}`,
|
|
116
|
+
authorizedMutationRoots: roots,
|
|
117
|
+
}
|
|
118
|
+
: result;
|
|
119
|
+
}
|
|
120
|
+
const preconditions = result.edits.documentPreconditions?.map((precondition) =>
|
|
121
|
+
rebase(precondition, before),
|
|
122
|
+
);
|
|
123
|
+
return {
|
|
124
|
+
...result,
|
|
125
|
+
edits: { ...result.edits, ...(preconditions ? { documentPreconditions: preconditions } : {}) },
|
|
126
|
+
action: { kind: action.kind, title: action.title, provenance: "captured-command" },
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function rebase(
|
|
131
|
+
precondition: DocumentEditPrecondition,
|
|
132
|
+
resynced: ReadonlyMap<string, { from: number; to: number }>,
|
|
133
|
+
): DocumentEditPrecondition {
|
|
134
|
+
if (precondition.kind === "disk-content") return precondition;
|
|
135
|
+
const versions = resynced.get(precondition.file);
|
|
136
|
+
return versions && precondition.version === versions.from
|
|
137
|
+
? { ...precondition, version: versions.to }
|
|
138
|
+
: precondition;
|
|
139
|
+
}
|