@mrclrchtr/supi-lsp 7.3.1 → 8.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 +29 -6
- package/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
- package/node_modules/@mrclrchtr/supi-code-runtime/src/api.ts +1 -1
- package/node_modules/@mrclrchtr/supi-code-runtime/src/query-result.ts +22 -4
- package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
- package/node_modules/@mrclrchtr/supi-core/src/config/config.ts +110 -0
- package/node_modules/@mrclrchtr/supi-core/src/config.ts +6 -1
- package/node_modules/@mrclrchtr/supi-core/src/settings/settings-schema.ts +2 -1
- package/package.json +3 -3
- package/src/api.ts +25 -3
- package/src/client/client-diagnostic-host.ts +2 -1
- package/src/client/client-diagnostic-refresh.ts +5 -2
- package/src/client/client-diagnostics.ts +39 -6
- package/src/client/client-document-sync.ts +82 -12
- package/src/client/client-semantic-input-barrier.ts +28 -5
- package/src/client/client.ts +606 -30
- package/src/config/capabilities.ts +9 -0
- package/src/config/config.ts +69 -8
- package/src/config/operation-support.ts +196 -0
- package/src/config/server-config.ts +15 -0
- package/src/config/types.ts +9 -1
- package/src/manager/manager-project-info.ts +2 -0
- package/src/manager/manager-workspace-symbol.ts +31 -2
- package/src/manager/manager.ts +72 -2
- package/src/provider/lsp-refactor-provider.ts +9 -43
- package/src/provider/refactor-planning.ts +102 -50
- package/src/provider/semantic-edit-normalizer.ts +49 -3
- package/src/session/runtime-controller.ts +78 -18
- package/src/session/runtime-registry.ts +46 -2
- package/src/session/workspace-lsp-runtime.ts +14 -0
- package/src/workspace-path-policy.ts +30 -4
- package/src/config/lsp-settings.ts +0 -22
|
@@ -4,6 +4,8 @@ import type {
|
|
|
4
4
|
RefactorResult,
|
|
5
5
|
SourceRange,
|
|
6
6
|
} from "@mrclrchtr/supi-code-runtime/api";
|
|
7
|
+
import { CodeActionTriggerKind } from "vscode-languageserver-types";
|
|
8
|
+
import { codeActionKindsForOperation } from "../config/operation-support.ts";
|
|
7
9
|
import type { CodeAction } from "../config/types.ts";
|
|
8
10
|
import type { WorkspaceLspRuntime } from "../session/runtime-registry.ts";
|
|
9
11
|
import {
|
|
@@ -25,6 +27,12 @@ export async function runRenameRefactor(options: {
|
|
|
25
27
|
if (!response) {
|
|
26
28
|
return { kind: "unavailable", reason: "No routed LSP client could plan the rename." };
|
|
27
29
|
}
|
|
30
|
+
if (!response.value) {
|
|
31
|
+
return {
|
|
32
|
+
kind: "unavailable",
|
|
33
|
+
reason: response.reason ?? "No routed LSP client could plan the rename.",
|
|
34
|
+
};
|
|
35
|
+
}
|
|
28
36
|
return normalizeSemanticEdit(
|
|
29
37
|
{ kind: "workspace-edit", edit: response.value },
|
|
30
38
|
{
|
|
@@ -52,17 +60,25 @@ export async function runFilteredCodeActionRefactor(options: {
|
|
|
52
60
|
lsp: WorkspaceLspRuntime;
|
|
53
61
|
file: string;
|
|
54
62
|
position: CodePosition;
|
|
55
|
-
operation: "update_imports" | "delete_dead_code"
|
|
63
|
+
operation: "update_imports" | "delete_dead_code";
|
|
56
64
|
range?: SourceRange;
|
|
57
65
|
matches: (action: CodeAction) => boolean;
|
|
58
66
|
control?: CodeRequestControl;
|
|
59
67
|
}): Promise<RefactorResult> {
|
|
60
68
|
const { lsp, file, position, operation, matches } = options;
|
|
61
|
-
const response = options.control
|
|
62
|
-
|
|
63
|
-
:
|
|
64
|
-
|
|
65
|
-
if (!response ||
|
|
69
|
+
const response = await lsp.codeActions(file, options.range ?? position, options.control, {
|
|
70
|
+
only: codeActionKindsForOperation(operation),
|
|
71
|
+
triggerKind: CodeActionTriggerKind.Invoked,
|
|
72
|
+
});
|
|
73
|
+
if (!response || response.reason) {
|
|
74
|
+
return {
|
|
75
|
+
kind: "unavailable",
|
|
76
|
+
reason:
|
|
77
|
+
response?.reason ?? `No code actions are available for refactor operation "${operation}".`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
const actions = response.value;
|
|
81
|
+
if (!actions || actions.length === 0) {
|
|
66
82
|
return {
|
|
67
83
|
kind: "unavailable",
|
|
68
84
|
reason: `No code actions are available for refactor operation "${operation}".`,
|
|
@@ -81,63 +97,99 @@ export async function runFilteredCodeActionRefactor(options: {
|
|
|
81
97
|
getOpenDocumentVersion: (candidate) => lsp.getOpenDocumentVersion(candidate),
|
|
82
98
|
authorizedMutationRoots: response.authorizedMutationRoots,
|
|
83
99
|
};
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
100
|
+
const plans = collectPrecisePlans(matching, context);
|
|
101
|
+
if (plans.precise.length === 1) return plans.precise[0].result;
|
|
102
|
+
if (plans.precise.length > 1) {
|
|
103
|
+
return {
|
|
104
|
+
kind: "ambiguous",
|
|
105
|
+
candidates: plans.precise.map(({ action, result }, index) =>
|
|
106
|
+
refactorCandidate(action, result, index, plans.precise),
|
|
107
|
+
),
|
|
108
|
+
};
|
|
89
109
|
}
|
|
90
|
-
|
|
91
110
|
return {
|
|
92
111
|
kind: "unavailable",
|
|
93
|
-
reason: unavailableReason
|
|
94
|
-
? `Matching code action is unavailable: ${unavailableReason}`
|
|
112
|
+
reason: plans.unavailableReason
|
|
113
|
+
? `Matching code action is unavailable: ${plans.unavailableReason}`
|
|
95
114
|
: `Matching code actions for refactor operation "${operation}" did not produce precise edits.`,
|
|
96
115
|
};
|
|
97
116
|
}
|
|
98
117
|
|
|
99
118
|
export function isUpdateImportsCodeAction(action: CodeAction): boolean {
|
|
100
|
-
const kind = action.kind
|
|
101
|
-
|
|
102
|
-
const kindlessTitleMatch = kind === "" && title === "organize imports";
|
|
103
|
-
return (
|
|
104
|
-
kind === "source.organizeImports" ||
|
|
105
|
-
kind.startsWith("source.organizeImports.") ||
|
|
106
|
-
kindlessTitleMatch
|
|
107
|
-
);
|
|
119
|
+
const kind = typeof action?.kind === "string" ? action.kind : "";
|
|
120
|
+
return kind === "source.organizeImports" || kind.startsWith("source.organizeImports.");
|
|
108
121
|
}
|
|
109
122
|
|
|
110
|
-
export function
|
|
111
|
-
const kind = action.kind
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
123
|
+
export function isDeleteDeadCodeCodeAction(action: CodeAction): boolean {
|
|
124
|
+
const kind = typeof action?.kind === "string" ? action.kind : "";
|
|
125
|
+
return kind === "source.removeUnused" || kind.startsWith("source.removeUnused.");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
interface PreciseCodeActionPlan {
|
|
129
|
+
action: CodeAction;
|
|
130
|
+
result: Extract<RefactorResult, { kind: "precise" }>;
|
|
118
131
|
}
|
|
119
132
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
(
|
|
133
|
+
function collectPrecisePlans(
|
|
134
|
+
actions: readonly CodeAction[],
|
|
135
|
+
context: SemanticEditNormalizationContext,
|
|
136
|
+
): { precise: PreciseCodeActionPlan[]; unavailableReason: string | null } {
|
|
137
|
+
const precise: PreciseCodeActionPlan[] = [];
|
|
138
|
+
const planKeys = new Set<string>();
|
|
139
|
+
let unavailableReason: string | null = null;
|
|
140
|
+
for (const action of actions) {
|
|
141
|
+
const converted = normalizeSemanticEdit({ kind: "code-action", action }, context);
|
|
142
|
+
if (converted.kind === "precise") {
|
|
143
|
+
const key = normalizedPlanKey(converted);
|
|
144
|
+
if (!planKeys.has(key)) {
|
|
145
|
+
planKeys.add(key);
|
|
146
|
+
precise.push({ action, result: converted });
|
|
147
|
+
}
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (converted.kind === "unavailable") unavailableReason ??= converted.reason;
|
|
151
|
+
}
|
|
152
|
+
return { precise, unavailableReason };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function normalizedPlanKey(result: Extract<RefactorResult, { kind: "precise" }>): string {
|
|
156
|
+
const edits = result.edits.edits
|
|
157
|
+
.map((edit) => ({
|
|
158
|
+
file: edit.file,
|
|
159
|
+
start: edit.range.start,
|
|
160
|
+
end: edit.range.end,
|
|
161
|
+
newText: edit.newText,
|
|
162
|
+
}))
|
|
163
|
+
.sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)));
|
|
164
|
+
const preconditions = [...(result.edits.documentPreconditions ?? [])].sort((left, right) =>
|
|
165
|
+
JSON.stringify(left).localeCompare(JSON.stringify(right)),
|
|
129
166
|
);
|
|
167
|
+
return JSON.stringify({ edits, preconditions });
|
|
130
168
|
}
|
|
131
169
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
170
|
+
function refactorCandidate(
|
|
171
|
+
action: CodeAction,
|
|
172
|
+
result: Extract<RefactorResult, { kind: "precise" }>,
|
|
173
|
+
index: number,
|
|
174
|
+
plans: readonly { action: CodeAction }[],
|
|
175
|
+
): {
|
|
176
|
+
description: string;
|
|
177
|
+
file?: string;
|
|
178
|
+
line?: number;
|
|
179
|
+
character?: number;
|
|
180
|
+
} {
|
|
181
|
+
const firstEdit = result.edits.edits[0];
|
|
182
|
+
const sameTitleCount = plans.filter((plan) => plan.action.title === action.title).length;
|
|
183
|
+
const description =
|
|
184
|
+
sameTitleCount > 1 ? `${action.title} (alternative ${index + 1})` : action.title;
|
|
185
|
+
return {
|
|
186
|
+
description,
|
|
187
|
+
...(firstEdit
|
|
188
|
+
? {
|
|
189
|
+
file: firstEdit.file,
|
|
190
|
+
line: firstEdit.range.start.line + 1,
|
|
191
|
+
character: firstEdit.range.start.character + 1,
|
|
192
|
+
}
|
|
193
|
+
: {}),
|
|
194
|
+
};
|
|
143
195
|
}
|
|
@@ -36,7 +36,9 @@ function normalizeCodeAction(
|
|
|
36
36
|
const action = asRecord(value);
|
|
37
37
|
if (
|
|
38
38
|
!action ||
|
|
39
|
+
!Object.hasOwn(action, "title") ||
|
|
39
40
|
typeof action.title !== "string" ||
|
|
41
|
+
action.title.trim().length === 0 ||
|
|
40
42
|
!hasOnlyKeys(action, [
|
|
41
43
|
"title",
|
|
42
44
|
"kind",
|
|
@@ -46,11 +48,33 @@ function normalizeCodeAction(
|
|
|
46
48
|
"edit",
|
|
47
49
|
"command",
|
|
48
50
|
"data",
|
|
51
|
+
]) ||
|
|
52
|
+
!hasNoInheritedAllowedKeys(action, [
|
|
53
|
+
"title",
|
|
54
|
+
"kind",
|
|
55
|
+
"diagnostics",
|
|
56
|
+
"isPreferred",
|
|
57
|
+
"disabled",
|
|
58
|
+
"edit",
|
|
59
|
+
"command",
|
|
60
|
+
"data",
|
|
49
61
|
])
|
|
50
62
|
) {
|
|
51
63
|
return { kind: "unavailable", reason: "LSP server returned a malformed code action." };
|
|
52
64
|
}
|
|
53
65
|
const title = action.title;
|
|
66
|
+
if (
|
|
67
|
+
Object.hasOwn(action, "kind") &&
|
|
68
|
+
(typeof action.kind !== "string" || action.kind.trim().length === 0)
|
|
69
|
+
) {
|
|
70
|
+
return { kind: "unavailable", reason: `Code action "${title}" has a malformed kind.` };
|
|
71
|
+
}
|
|
72
|
+
if (Object.hasOwn(action, "diagnostics") && !Array.isArray(action.diagnostics)) {
|
|
73
|
+
return { kind: "unavailable", reason: `Code action "${title}" has malformed diagnostics.` };
|
|
74
|
+
}
|
|
75
|
+
if (Object.hasOwn(action, "isPreferred") && typeof action.isPreferred !== "boolean") {
|
|
76
|
+
return { kind: "unavailable", reason: `Code action "${title}" has a malformed preference.` };
|
|
77
|
+
}
|
|
54
78
|
if (Object.hasOwn(action, "disabled")) {
|
|
55
79
|
return { kind: "unavailable", reason: `Code action "${title}" is disabled.` };
|
|
56
80
|
}
|
|
@@ -82,7 +106,8 @@ function normalizeWorkspaceEdit(
|
|
|
82
106
|
const workspaceEdit = asRecord(value);
|
|
83
107
|
if (
|
|
84
108
|
!workspaceEdit ||
|
|
85
|
-
!hasOnlyKeys(workspaceEdit, ["changes", "documentChanges", "changeAnnotations"])
|
|
109
|
+
!hasOnlyKeys(workspaceEdit, ["changes", "documentChanges", "changeAnnotations"]) ||
|
|
110
|
+
!hasNoInheritedAllowedKeys(workspaceEdit, ["changes", "documentChanges", "changeAnnotations"])
|
|
86
111
|
) {
|
|
87
112
|
return { kind: "unavailable", reason: "LSP server returned a malformed workspace edit." };
|
|
88
113
|
}
|
|
@@ -92,7 +117,8 @@ function normalizeWorkspaceEdit(
|
|
|
92
117
|
reason: "Workspace edit contains unsupported change annotations.",
|
|
93
118
|
};
|
|
94
119
|
}
|
|
95
|
-
|
|
120
|
+
const hasDocumentChanges = Object.hasOwn(workspaceEdit, "documentChanges");
|
|
121
|
+
return hasDocumentChanges
|
|
96
122
|
? normalizeDocumentChanges(workspaceEdit.documentChanges, context)
|
|
97
123
|
: normalizeChanges(workspaceEdit.changes, context);
|
|
98
124
|
}
|
|
@@ -165,7 +191,7 @@ function normalizeDocumentChange(
|
|
|
165
191
|
if (!file) {
|
|
166
192
|
return { kind: "unavailable", reason: "Document change does not contain a valid file URI." };
|
|
167
193
|
}
|
|
168
|
-
if (!("version"
|
|
194
|
+
if (!Object.hasOwn(textDocument, "version")) {
|
|
169
195
|
return { kind: "unavailable", reason: "Document change does not contain a version." };
|
|
170
196
|
}
|
|
171
197
|
const precondition = establishPrecondition(file, textDocument.version, context);
|
|
@@ -185,9 +211,14 @@ function parseTextDocumentEdit(edit: Record<string, unknown> | null): {
|
|
|
185
211
|
const textDocument = asRecord(edit?.textDocument);
|
|
186
212
|
if (
|
|
187
213
|
!edit ||
|
|
214
|
+
!Object.hasOwn(edit, "textDocument") ||
|
|
215
|
+
!Object.hasOwn(edit, "edits") ||
|
|
188
216
|
!hasOnlyKeys(edit, ["textDocument", "edits"]) ||
|
|
217
|
+
!hasNoInheritedAllowedKeys(edit, ["textDocument", "edits"]) ||
|
|
189
218
|
!textDocument ||
|
|
219
|
+
!Object.hasOwn(textDocument, "uri") ||
|
|
190
220
|
!hasOnlyKeys(textDocument, ["uri", "version"]) ||
|
|
221
|
+
!hasNoInheritedAllowedKeys(textDocument, ["uri", "version"]) ||
|
|
191
222
|
!Array.isArray(edit.edits)
|
|
192
223
|
) {
|
|
193
224
|
return null;
|
|
@@ -245,9 +276,15 @@ function normalizeTextEdits(
|
|
|
245
276
|
const end = asPosition(range?.end);
|
|
246
277
|
if (
|
|
247
278
|
!textEdit ||
|
|
279
|
+
!Object.hasOwn(textEdit, "range") ||
|
|
280
|
+
!Object.hasOwn(textEdit, "newText") ||
|
|
248
281
|
!hasOnlyKeys(textEdit, ["range", "newText"]) ||
|
|
282
|
+
!hasNoInheritedAllowedKeys(textEdit, ["range", "newText", "snippet", "annotationId"]) ||
|
|
249
283
|
!range ||
|
|
284
|
+
!Object.hasOwn(range, "start") ||
|
|
285
|
+
!Object.hasOwn(range, "end") ||
|
|
250
286
|
!hasOnlyKeys(range, ["start", "end"]) ||
|
|
287
|
+
!hasNoInheritedAllowedKeys(range, ["start", "end"]) ||
|
|
251
288
|
!start ||
|
|
252
289
|
!end ||
|
|
253
290
|
isBefore(end, start) ||
|
|
@@ -285,6 +322,13 @@ function hasOnlyKeys(value: Record<string, unknown>, allowed: readonly string[])
|
|
|
285
322
|
return Object.keys(value).every((key) => allowedKeys.has(key));
|
|
286
323
|
}
|
|
287
324
|
|
|
325
|
+
function hasNoInheritedAllowedKeys(
|
|
326
|
+
value: Record<string, unknown>,
|
|
327
|
+
allowed: readonly string[],
|
|
328
|
+
): boolean {
|
|
329
|
+
return allowed.every((key) => !(key in value) || Object.hasOwn(value, key));
|
|
330
|
+
}
|
|
331
|
+
|
|
288
332
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
289
333
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
290
334
|
? (value as Record<string, unknown>)
|
|
@@ -294,6 +338,8 @@ function asRecord(value: unknown): Record<string, unknown> | null {
|
|
|
294
338
|
function asPosition(value: unknown): { line: number; character: number } | null {
|
|
295
339
|
const position = asRecord(value);
|
|
296
340
|
return position &&
|
|
341
|
+
Object.hasOwn(position, "line") &&
|
|
342
|
+
Object.hasOwn(position, "character") &&
|
|
297
343
|
hasOnlyKeys(position, ["line", "character"]) &&
|
|
298
344
|
Number.isInteger(position.line) &&
|
|
299
345
|
(position.line as number) >= 0 &&
|
|
@@ -13,14 +13,13 @@ import {
|
|
|
13
13
|
recordDebugEvent,
|
|
14
14
|
truncateDebugIdentity as truncateIdentity,
|
|
15
15
|
} from "@mrclrchtr/supi-core/debug";
|
|
16
|
-
import { loadConfig } from "../config/config.ts";
|
|
17
|
-
import { type LspSettings, loadLspSettings } from "../config/lsp-settings.ts";
|
|
16
|
+
import { getExplicitlyDisabledLanguages, loadConfig } from "../config/config.ts";
|
|
18
17
|
import { clearTsconfigCache } from "../config/tsconfig-scope.ts";
|
|
19
18
|
import type { DetectedProjectServer, LspConfig, ProjectServerInfo } from "../config/types.ts";
|
|
20
19
|
import { LspManager, type ManagerLifecycleTransition } from "../manager/manager.ts";
|
|
21
20
|
import {
|
|
22
21
|
type AutomaticLspPathPolicy,
|
|
23
|
-
|
|
22
|
+
createDefaultAutomaticLspPathPolicy,
|
|
24
23
|
} from "../workspace-path-policy.ts";
|
|
25
24
|
import {
|
|
26
25
|
markLspCapabilitiesReady,
|
|
@@ -62,7 +61,7 @@ interface LspControllerReady {
|
|
|
62
61
|
workspaceRuntime: WorkspaceLspRuntime;
|
|
63
62
|
projectServers: ProjectServerInfo[];
|
|
64
63
|
detectedServers: DetectedProjectServer[];
|
|
65
|
-
|
|
64
|
+
config: LspConfig;
|
|
66
65
|
automaticPathPolicy: AutomaticLspPathPolicy;
|
|
67
66
|
}
|
|
68
67
|
|
|
@@ -76,6 +75,16 @@ interface LspControllerUnavailable {
|
|
|
76
75
|
reason: string;
|
|
77
76
|
}
|
|
78
77
|
|
|
78
|
+
/** Options for one LSP runtime controller. */
|
|
79
|
+
export interface LspRuntimeControllerOptions {
|
|
80
|
+
/** Whether project-scoped configuration is trusted. Defaults to true. */
|
|
81
|
+
readonly projectTrusted?: boolean;
|
|
82
|
+
/** Global config home, used by managed sessions and tests. */
|
|
83
|
+
readonly homeDir?: string;
|
|
84
|
+
/** A policy captured by the owning workspace host. */
|
|
85
|
+
readonly automaticPathPolicy?: AutomaticLspPathPolicy;
|
|
86
|
+
}
|
|
87
|
+
|
|
79
88
|
/** Result type from {@link LspRuntimeController.start}. */
|
|
80
89
|
export type LspStartResult =
|
|
81
90
|
| { kind: "ready"; runtime: WorkspaceLspRuntime }
|
|
@@ -129,6 +138,7 @@ function supersededStartResult(): LspStartResult {
|
|
|
129
138
|
*/
|
|
130
139
|
export class LspRuntimeController {
|
|
131
140
|
readonly #cwd: string;
|
|
141
|
+
readonly #options: LspRuntimeControllerOptions;
|
|
132
142
|
#state: LspControllerState;
|
|
133
143
|
#capabilityRuntime: WorkspaceRuntime | null;
|
|
134
144
|
#activeManager: LspManager | null = null;
|
|
@@ -139,9 +149,14 @@ export class LspRuntimeController {
|
|
|
139
149
|
readonly #lifecycleListeners = new Set<LspRuntimeTransitionListener>();
|
|
140
150
|
/** Monotonic ownership token for starts, shutdowns, and manager callbacks. */
|
|
141
151
|
#readinessGeneration = 0;
|
|
152
|
+
/** Explicit disablement captured with the active server configuration. */
|
|
153
|
+
#activeExplicitlyDisabledLanguages: readonly string[] = [];
|
|
154
|
+
/** Validation failure from the configuration snapshot used by automatic work. */
|
|
155
|
+
#configurationError: string | null = null;
|
|
142
156
|
|
|
143
|
-
constructor(cwd: string, runtime?: WorkspaceRuntime) {
|
|
157
|
+
constructor(cwd: string, runtime?: WorkspaceRuntime, options: LspRuntimeControllerOptions = {}) {
|
|
144
158
|
this.#cwd = cwd;
|
|
159
|
+
this.#options = options;
|
|
145
160
|
this.#state = { kind: "initial" };
|
|
146
161
|
this.#capabilityRuntime = runtime ?? null;
|
|
147
162
|
}
|
|
@@ -173,9 +188,9 @@ export class LspRuntimeController {
|
|
|
173
188
|
return [];
|
|
174
189
|
}
|
|
175
190
|
|
|
176
|
-
/**
|
|
177
|
-
get
|
|
178
|
-
if (this.#state.kind === "ready") return this.#state.
|
|
191
|
+
/** Automatic path policy captured for the active runtime. */
|
|
192
|
+
get automaticPathPolicy(): AutomaticLspPathPolicy | null {
|
|
193
|
+
if (this.#state.kind === "ready") return this.#state.automaticPathPolicy;
|
|
179
194
|
return null;
|
|
180
195
|
}
|
|
181
196
|
|
|
@@ -208,7 +223,8 @@ export class LspRuntimeController {
|
|
|
208
223
|
/**
|
|
209
224
|
* Start the LSP session for this controller's cwd.
|
|
210
225
|
*
|
|
211
|
-
* Loads
|
|
226
|
+
* Loads the server configuration and one automatic path snapshot, creates
|
|
227
|
+
* the manager, starts detected servers,
|
|
212
228
|
* publishes the session service, and registers capabilities.
|
|
213
229
|
*
|
|
214
230
|
* Always attempts detected servers unless they were explicitly disabled
|
|
@@ -224,11 +240,37 @@ export class LspRuntimeController {
|
|
|
224
240
|
await this.cleanupExistingSession();
|
|
225
241
|
if (generation !== this.#readinessGeneration) return supersededStartResult();
|
|
226
242
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
243
|
+
let automaticPathPolicy: AutomaticLspPathPolicy;
|
|
244
|
+
let config: LspConfig;
|
|
245
|
+
let explicitlyDisabledLanguages: string[];
|
|
246
|
+
try {
|
|
247
|
+
automaticPathPolicy =
|
|
248
|
+
this.#options.automaticPathPolicy ??
|
|
249
|
+
createDefaultAutomaticLspPathPolicy(this.#cwd, {
|
|
250
|
+
projectTrusted: this.#options.projectTrusted,
|
|
251
|
+
homeDir: this.#options.homeDir,
|
|
252
|
+
});
|
|
253
|
+
config = loadConfig(this.#cwd, {
|
|
254
|
+
projectTrusted: this.#options.projectTrusted,
|
|
255
|
+
homeDir: this.#options.homeDir,
|
|
256
|
+
});
|
|
257
|
+
explicitlyDisabledLanguages = getExplicitlyDisabledLanguages(this.#cwd, {
|
|
258
|
+
projectTrusted: this.#options.projectTrusted,
|
|
259
|
+
homeDir: this.#options.homeDir,
|
|
260
|
+
});
|
|
261
|
+
} catch (error: unknown) {
|
|
262
|
+
this.#configurationError =
|
|
263
|
+
error instanceof Error && error.message ? error.message : String(error);
|
|
264
|
+
return this.setUnavailable(error, generation);
|
|
265
|
+
}
|
|
266
|
+
this.#configurationError = null;
|
|
230
267
|
try {
|
|
231
|
-
return await this.initializeLspSession(
|
|
268
|
+
return await this.initializeLspSession(
|
|
269
|
+
config,
|
|
270
|
+
automaticPathPolicy,
|
|
271
|
+
explicitlyDisabledLanguages,
|
|
272
|
+
generation,
|
|
273
|
+
);
|
|
232
274
|
} catch (error: unknown) {
|
|
233
275
|
return this.setUnavailable(error, generation);
|
|
234
276
|
}
|
|
@@ -266,6 +308,7 @@ export class LspRuntimeController {
|
|
|
266
308
|
const reason = error instanceof Error ? error.message : String(error);
|
|
267
309
|
this.#activeManager = null;
|
|
268
310
|
this.#latestManagerTransition = null;
|
|
311
|
+
this.#activeExplicitlyDisabledLanguages = [];
|
|
269
312
|
if (this.#capabilityRuntime) unregisterLspCapabilities(this.#capabilityRuntime, this.#cwd);
|
|
270
313
|
this.#projectedSemanticReady = null;
|
|
271
314
|
this.#state = { kind: "unavailable", reason };
|
|
@@ -279,15 +322,16 @@ export class LspRuntimeController {
|
|
|
279
322
|
*/
|
|
280
323
|
private async initializeLspSession(
|
|
281
324
|
config: LspConfig,
|
|
282
|
-
|
|
325
|
+
automaticPathPolicy: AutomaticLspPathPolicy,
|
|
326
|
+
explicitlyDisabledLanguages: readonly string[],
|
|
283
327
|
generation: number,
|
|
284
328
|
): Promise<LspStartResult> {
|
|
285
329
|
if (generation !== this.#readinessGeneration) return supersededStartResult();
|
|
286
330
|
clearWorkspaceLspRuntime(this.#cwd);
|
|
331
|
+
this.#activeExplicitlyDisabledLanguages = [...explicitlyDisabledLanguages];
|
|
287
332
|
if (Object.keys(config.servers).length === 0) return this.setDisabled(generation);
|
|
288
333
|
this.#state = { kind: "pending" };
|
|
289
334
|
|
|
290
|
-
const automaticPathPolicy = createAutomaticLspPathPolicy(this.#cwd, settings.exclude);
|
|
291
335
|
let manager: LspManager;
|
|
292
336
|
manager = new LspManager(
|
|
293
337
|
config,
|
|
@@ -340,7 +384,7 @@ export class LspRuntimeController {
|
|
|
340
384
|
workspaceRuntime,
|
|
341
385
|
projectServers,
|
|
342
386
|
detectedServers,
|
|
343
|
-
|
|
387
|
+
config,
|
|
344
388
|
automaticPathPolicy,
|
|
345
389
|
};
|
|
346
390
|
|
|
@@ -438,6 +482,8 @@ export class LspRuntimeController {
|
|
|
438
482
|
clearTsconfigCache();
|
|
439
483
|
this.#activeManager = null;
|
|
440
484
|
this.#latestManagerTransition = null;
|
|
485
|
+
this.#activeExplicitlyDisabledLanguages = [];
|
|
486
|
+
this.#configurationError = null;
|
|
441
487
|
|
|
442
488
|
if (this.#capabilityRuntime) {
|
|
443
489
|
unregisterLspCapabilities(this.#capabilityRuntime, this.#cwd);
|
|
@@ -456,10 +502,24 @@ export class LspRuntimeController {
|
|
|
456
502
|
this.publishLifecycle("shutdown", false, []);
|
|
457
503
|
}
|
|
458
504
|
|
|
505
|
+
/** Explicit language disablement captured by the active LSP session. */
|
|
506
|
+
getExplicitlyDisabledLanguages(): readonly string[] {
|
|
507
|
+
return [...this.#activeExplicitlyDisabledLanguages];
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** Configuration validation error that prevented automatic LSP startup, if any. */
|
|
511
|
+
getConfigurationError(): string | null {
|
|
512
|
+
return this.#configurationError;
|
|
513
|
+
}
|
|
514
|
+
|
|
459
515
|
/** Get the missing servers warning (servers whose binary is not on PATH). */
|
|
460
516
|
getMissingServers(): Array<{ name: string; command: string }> {
|
|
461
517
|
if (this.#state.kind !== "ready") return [];
|
|
462
|
-
|
|
463
|
-
|
|
518
|
+
return scanMissingServers(
|
|
519
|
+
this.#state.config,
|
|
520
|
+
this.#cwd,
|
|
521
|
+
undefined,
|
|
522
|
+
this.#state.automaticPathPolicy,
|
|
523
|
+
);
|
|
464
524
|
}
|
|
465
525
|
}
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
} from "@mrclrchtr/supi-core/debug";
|
|
16
16
|
import { resolveToolPath as resolveSessionPath } from "@mrclrchtr/supi-core/path";
|
|
17
17
|
import { createSessionStateRegistry } from "@mrclrchtr/supi-core/session";
|
|
18
|
+
import { CodeActionTriggerKind } from "vscode-languageserver-types";
|
|
18
19
|
import type {
|
|
19
20
|
CodeAction,
|
|
20
21
|
Diagnostic,
|
|
@@ -47,6 +48,7 @@ import type {
|
|
|
47
48
|
BulkTrackFilesResult,
|
|
48
49
|
RoutedMutationResponse,
|
|
49
50
|
SemanticReadinessResult,
|
|
51
|
+
WorkspaceCodeActionRequestOptions,
|
|
50
52
|
WorkspaceLspRuntime,
|
|
51
53
|
WorkspaceLspRuntimeState,
|
|
52
54
|
} from "./workspace-lsp-runtime.ts";
|
|
@@ -81,12 +83,18 @@ export type {
|
|
|
81
83
|
BulkTrackFilesResult,
|
|
82
84
|
RoutedMutationResponse,
|
|
83
85
|
SemanticReadinessResult,
|
|
86
|
+
WorkspaceCodeActionRequestOptions,
|
|
84
87
|
WorkspaceLspRuntime,
|
|
85
88
|
WorkspaceLspRuntimeState,
|
|
86
89
|
} from "./workspace-lsp-runtime.ts";
|
|
87
90
|
|
|
88
91
|
function isRange(value: Position | Range): value is Range {
|
|
89
|
-
return
|
|
92
|
+
return (
|
|
93
|
+
typeof value === "object" &&
|
|
94
|
+
value !== null &&
|
|
95
|
+
Object.hasOwn(value, "start") &&
|
|
96
|
+
Object.hasOwn(value, "end")
|
|
97
|
+
);
|
|
90
98
|
}
|
|
91
99
|
|
|
92
100
|
function unavailableFileQuery<T>(operation: string, file: string): CodeQueryResult<T> {
|
|
@@ -234,6 +242,18 @@ class DefaultWorkspaceLspRuntime implements WorkspaceLspRuntime {
|
|
|
234
242
|
control,
|
|
235
243
|
});
|
|
236
244
|
if (!client) return null;
|
|
245
|
+
if (typeof client.renameDetailed === "function") {
|
|
246
|
+
const result = await client.renameDetailed(resolvedPath, position, newName, control);
|
|
247
|
+
return {
|
|
248
|
+
value: result.kind === "completed" ? result.data : null,
|
|
249
|
+
authorizedMutationRoots: [client.root],
|
|
250
|
+
...(result.kind === "unavailable"
|
|
251
|
+
? { reason: result.reason }
|
|
252
|
+
: result.data === null
|
|
253
|
+
? { reason: "LSP server returned no workspace edit." }
|
|
254
|
+
: {}),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
237
257
|
return {
|
|
238
258
|
value: await client.rename(resolvedPath, position, newName, control),
|
|
239
259
|
authorizedMutationRoots: [client.root],
|
|
@@ -248,6 +268,7 @@ class DefaultWorkspaceLspRuntime implements WorkspaceLspRuntime {
|
|
|
248
268
|
filePath: string,
|
|
249
269
|
positionOrRange: Position | Range,
|
|
250
270
|
control?: CodeRequestControl,
|
|
271
|
+
options?: WorkspaceCodeActionRequestOptions,
|
|
251
272
|
): Promise<RoutedMutationResponse<CodeAction[] | null> | null> {
|
|
252
273
|
const resolvedPath = this.resolveFilePath(filePath);
|
|
253
274
|
const client = await this.manager.ensureFileOpen(resolvedPath, {
|
|
@@ -264,8 +285,25 @@ class DefaultWorkspaceLspRuntime implements WorkspaceLspRuntime {
|
|
|
264
285
|
.filter((diagnostic) => diagnostic.range.end.line >= range.start.line)
|
|
265
286
|
.filter((diagnostic) => diagnostic.range.start.line <= range.end.line);
|
|
266
287
|
|
|
288
|
+
const context = {
|
|
289
|
+
diagnostics,
|
|
290
|
+
triggerKind: options?.triggerKind ?? CodeActionTriggerKind.Invoked,
|
|
291
|
+
...(options?.only ? { only: [...options.only] } : {}),
|
|
292
|
+
};
|
|
293
|
+
if (typeof client.codeActionsDetailed === "function") {
|
|
294
|
+
const result = await client.codeActionsDetailed(resolvedPath, range, context, control);
|
|
295
|
+
return {
|
|
296
|
+
value: result.kind === "completed" ? result.data : null,
|
|
297
|
+
authorizedMutationRoots: [client.root],
|
|
298
|
+
...(result.kind === "unavailable"
|
|
299
|
+
? { reason: result.reason }
|
|
300
|
+
: result.data === null
|
|
301
|
+
? { reason: "LSP server returned no code actions." }
|
|
302
|
+
: {}),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
267
305
|
return {
|
|
268
|
-
value: await client.codeActions(resolvedPath, range,
|
|
306
|
+
value: await client.codeActions(resolvedPath, range, context, control),
|
|
269
307
|
authorizedMutationRoots: [client.root],
|
|
270
308
|
};
|
|
271
309
|
}
|
|
@@ -410,6 +448,12 @@ class DefaultWorkspaceLspRuntime implements WorkspaceLspRuntime {
|
|
|
410
448
|
this.manager.noteWorkspaceChanges(changes);
|
|
411
449
|
}
|
|
412
450
|
|
|
451
|
+
/** Synchronize tracked files after successful writes and send requested saves. */
|
|
452
|
+
noteWorkspaceWrites(changes: FileEvent[]): void {
|
|
453
|
+
this.manager.clearAllPullResultIds();
|
|
454
|
+
this.manager.noteWorkspaceWrites(changes);
|
|
455
|
+
}
|
|
456
|
+
|
|
413
457
|
async #shutdown(): Promise<void> {
|
|
414
458
|
await this.manager.shutdownAll();
|
|
415
459
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { CodeQueryResult, CodeRequestControl } from "@mrclrchtr/supi-code-runtime/api";
|
|
2
2
|
import type {
|
|
3
3
|
CodeAction,
|
|
4
|
+
CodeActionContext,
|
|
4
5
|
DocumentSymbol,
|
|
5
6
|
FileEvent,
|
|
6
7
|
Hover,
|
|
@@ -61,6 +62,16 @@ export interface RoutedMutationResponse<T> {
|
|
|
61
62
|
readonly value: T;
|
|
62
63
|
/** Roots that the routed client owns for this mutation response. */
|
|
63
64
|
readonly authorizedMutationRoots: readonly string[];
|
|
65
|
+
/** Honest reason when the routed server could not provide the operation. */
|
|
66
|
+
readonly reason?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Extra protocol context for one explicit code-action request. */
|
|
70
|
+
export interface WorkspaceCodeActionRequestOptions {
|
|
71
|
+
/** Operation-specific CodeActionKind filters. */
|
|
72
|
+
readonly only?: readonly string[];
|
|
73
|
+
/** Explicit requests use Invoked; automatic callers may select another kind. */
|
|
74
|
+
readonly triggerKind?: CodeActionContext["triggerKind"];
|
|
64
75
|
}
|
|
65
76
|
|
|
66
77
|
/** Outcome for one path selected by a bounded automatic tracking batch. */
|
|
@@ -134,6 +145,7 @@ export interface WorkspaceLspRuntime extends WorkspaceLspDiagnosticSurface {
|
|
|
134
145
|
filePath: string,
|
|
135
146
|
positionOrRange: Position | Range,
|
|
136
147
|
control?: CodeRequestControl,
|
|
148
|
+
options?: WorkspaceCodeActionRequestOptions,
|
|
137
149
|
): Promise<RoutedMutationResponse<CodeAction[] | null> | null>;
|
|
138
150
|
getOpenDocumentVersion(filePath: string): number | null;
|
|
139
151
|
/**
|
|
@@ -173,4 +185,6 @@ export interface WorkspaceLspRuntime extends WorkspaceLspDiagnosticSurface {
|
|
|
173
185
|
closeFile(filePath: string): void;
|
|
174
186
|
pruneMissingFiles(): readonly string[];
|
|
175
187
|
noteWorkspaceChanges(changes: FileEvent[]): void;
|
|
188
|
+
/** Synchronize tracked files after successful writes and send requested saves. */
|
|
189
|
+
noteWorkspaceWrites(changes: FileEvent[]): void;
|
|
176
190
|
}
|