@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.
Files changed (32) hide show
  1. package/README.md +29 -6
  2. package/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
  3. package/node_modules/@mrclrchtr/supi-code-runtime/src/api.ts +1 -1
  4. package/node_modules/@mrclrchtr/supi-code-runtime/src/query-result.ts +22 -4
  5. package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
  6. package/node_modules/@mrclrchtr/supi-core/src/config/config.ts +110 -0
  7. package/node_modules/@mrclrchtr/supi-core/src/config.ts +6 -1
  8. package/node_modules/@mrclrchtr/supi-core/src/settings/settings-schema.ts +2 -1
  9. package/package.json +3 -3
  10. package/src/api.ts +25 -3
  11. package/src/client/client-diagnostic-host.ts +2 -1
  12. package/src/client/client-diagnostic-refresh.ts +5 -2
  13. package/src/client/client-diagnostics.ts +39 -6
  14. package/src/client/client-document-sync.ts +82 -12
  15. package/src/client/client-semantic-input-barrier.ts +28 -5
  16. package/src/client/client.ts +606 -30
  17. package/src/config/capabilities.ts +9 -0
  18. package/src/config/config.ts +69 -8
  19. package/src/config/operation-support.ts +196 -0
  20. package/src/config/server-config.ts +15 -0
  21. package/src/config/types.ts +9 -1
  22. package/src/manager/manager-project-info.ts +2 -0
  23. package/src/manager/manager-workspace-symbol.ts +31 -2
  24. package/src/manager/manager.ts +72 -2
  25. package/src/provider/lsp-refactor-provider.ts +9 -43
  26. package/src/provider/refactor-planning.ts +102 -50
  27. package/src/provider/semantic-edit-normalizer.ts +49 -3
  28. package/src/session/runtime-controller.ts +78 -18
  29. package/src/session/runtime-registry.ts +46 -2
  30. package/src/session/workspace-lsp-runtime.ts +14 -0
  31. package/src/workspace-path-policy.ts +30 -4
  32. 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" | "extract_function" | "extract_variable";
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
- ? await lsp.codeActions(file, options.range ?? position, options.control)
63
- : await lsp.codeActions(file, options.range ?? position);
64
- const actions = response?.value;
65
- if (!response || !actions || actions.length === 0) {
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
- let unavailableReason: string | null = null;
85
- for (const action of matching) {
86
- const converted = normalizeSemanticEdit({ kind: "code-action", action }, context);
87
- if (converted.kind === "precise") return converted;
88
- if (converted.kind === "unavailable") unavailableReason ??= converted.reason;
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
- const title = action.title.trim().toLowerCase();
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 isExtractFunctionCodeAction(action: CodeAction): boolean {
111
- const kind = action.kind ?? "";
112
- const title = action.title.trim().toLowerCase();
113
- return (
114
- kind === "refactor.extract.function" ||
115
- kind.startsWith("refactor.extract.function.") ||
116
- (title.includes("extract") && /\b(function|method)\b/.test(title))
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
- export function isExtractVariableCodeAction(action: CodeAction): boolean {
121
- const kind = action.kind ?? "";
122
- const title = action.title.trim().toLowerCase();
123
- return (
124
- kind === "refactor.extract.constant" ||
125
- kind.startsWith("refactor.extract.constant.") ||
126
- kind === "refactor.extract.variable" ||
127
- kind.startsWith("refactor.extract.variable.") ||
128
- (title.includes("extract") && /\b(constant|const|variable)\b/.test(title))
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
- export function isDeleteDeadCodeCodeAction(action: CodeAction): boolean {
133
- const kind = action.kind ?? "";
134
- const title = action.title.trim().toLowerCase();
135
- const kindMatches =
136
- kind === "quickfix" ||
137
- kind.startsWith("quickfix.") ||
138
- kind === "refactor.rewrite" ||
139
- kind.startsWith("refactor.rewrite.");
140
- const titleMatches =
141
- /(unused|dead code|remove unused|remove unreachable|remove declaration)/.test(title);
142
- return kindMatches && titleMatches;
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
- return Object.hasOwn(workspaceEdit, "documentChanges")
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" in textDocument)) {
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
- createAutomaticLspPathPolicy,
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
- settings: LspSettings;
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
- /** LSP settings used for this session. */
177
- get settings(): LspSettings | null {
178
- if (this.#state.kind === "ready") return this.#state.settings;
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 settings, creates the manager, starts detected servers,
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
- const lspSettings = loadLspSettings(this.#cwd);
228
- const config = loadConfig(this.#cwd);
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(config, lspSettings, generation);
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
- settings: LspSettings,
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
- settings,
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
- const config = loadConfig(this.#cwd);
463
- return scanMissingServers(config, this.#cwd, undefined, this.#state.automaticPathPolicy);
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 "start" in value && "end" in value;
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, { diagnostics }, control),
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
  }