@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.
Files changed (31) hide show
  1. package/README.md +22 -17
  2. package/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
  3. package/node_modules/@mrclrchtr/supi-code-runtime/src/api.ts +3 -1
  4. package/node_modules/@mrclrchtr/supi-code-runtime/src/declaration-level.ts +34 -0
  5. package/node_modules/@mrclrchtr/supi-code-runtime/src/types.ts +64 -28
  6. package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
  7. package/node_modules/semver/classes/range.js +2 -2
  8. package/node_modules/semver/internal/constants.js +30 -0
  9. package/node_modules/semver/internal/trim-comparator-whitespace.js +438 -0
  10. package/node_modules/semver/package.json +1 -1
  11. package/package.json +3 -3
  12. package/src/api.ts +2 -2
  13. package/src/client/client-diagnostic-capabilities.ts +13 -3
  14. package/src/client/client-diagnostics.ts +25 -9
  15. package/src/client/client-document-sync.ts +7 -5
  16. package/src/client/client-edit-capture.ts +258 -0
  17. package/src/client/client.ts +64 -11
  18. package/src/config/capabilities.ts +8 -0
  19. package/src/config/operation-support.ts +48 -79
  20. package/src/config/tsconfig-scope.ts +10 -0
  21. package/src/manager/manager-typescript-coverage.ts +118 -0
  22. package/src/manager/manager.ts +67 -3
  23. package/src/manager/typescript-file-local.ts +87 -0
  24. package/src/provider/lsp-refactor-provider.ts +21 -35
  25. package/src/provider/refactor-command-capture.ts +139 -0
  26. package/src/provider/refactor-planning.ts +202 -47
  27. package/src/provider/semantic-symbol-mapper.ts +22 -17
  28. package/src/session/route-boundary.ts +26 -0
  29. package/src/session/runtime-registry.ts +106 -16
  30. package/src/session/runtime-rename.ts +3 -0
  31. package/src/session/workspace-lsp-runtime.ts +22 -1
@@ -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
+ }
@@ -23,6 +23,7 @@ import { CodeActionTriggerKind } from "vscode-languageserver-types";
23
23
  import { CLIENT_CAPABILITIES } from "../config/capabilities.ts";
24
24
  import {
25
25
  getServerOperationSupport,
26
+ isCodeActionKindWithin,
26
27
  supportsCodeActionResolve,
27
28
  supportsCodeActions,
28
29
  supportsPrepareRename,
@@ -32,6 +33,7 @@ import {
32
33
  import type {
33
34
  CodeAction,
34
35
  CodeActionContext,
36
+ Command,
35
37
  Diagnostic,
36
38
  DidChangeWatchedFilesParams,
37
39
  DocumentDiagnosticReport,
@@ -71,6 +73,11 @@ import { createTypeScriptDiagnosticRequestAdapter } from "./client-diagnostic-ty
71
73
  import { ClientDiagnostics } from "./client-diagnostics.ts";
72
74
  import type { ClientDiagnosticSnapshot, DiagnosticEntry } from "./client-document-state.ts";
73
75
  import { type NormalizedDocumentSync, normalizeDocumentSync } from "./client-document-sync.ts";
76
+ import {
77
+ type CommandEditCaptureResult,
78
+ EditCaptureWindow,
79
+ runCommandEditCapture,
80
+ } from "./client-edit-capture.ts";
74
81
  import {
75
82
  SemanticInputRequestRetryGuard,
76
83
  type SemanticInputRequestRetryReason,
@@ -244,13 +251,6 @@ function isLazyCodeAction(value: unknown): value is CodeAction {
244
251
  );
245
252
  }
246
253
 
247
- function codeActionKindMatchesFilter(kind: unknown, filter: string): boolean {
248
- return (
249
- filter === "" ||
250
- (typeof kind === "string" && (kind === filter || kind.startsWith(`${filter}.`)))
251
- );
252
- }
253
-
254
254
  function isValidCommand(value: unknown): value is NonNullable<CodeAction["command"]> {
255
255
  const command = isRecord(value) ? value : null;
256
256
  return (
@@ -546,8 +546,11 @@ export class LspClient {
546
546
  private readonly settingsSnapshot: JsonObject | undefined;
547
547
  private readonly diagnosticRequestAdapter: DiagnosticRequestAdapter;
548
548
  private readonly diagnostics: ClientDiagnostics;
549
+ private readonly editCapture = new EditCaptureWindow();
549
550
  /** Dynamic capability registrations for this client instance only. */
550
551
  private readonly dynamicRegistrations = new ClientDynamicRegistrations();
552
+ /** Workspace association snapshot shared by `didOpen` and selector matching. */
553
+ private readonly fileAssociations: CFamilyFileAssociationResolver | undefined;
551
554
 
552
555
  // ── Readiness (work-done-progress) ──────────────────────────────────
553
556
  private trackedTokens = new Map<ProgressToken, "created" | "active" | "ended">();
@@ -576,6 +579,7 @@ export class LspClient {
576
579
  ) {
577
580
  this.name = name;
578
581
  this.root = root;
582
+ this.fileAssociations = fileAssociations;
579
583
  this.settingsSnapshot = cloneSettings(config.settings);
580
584
  const nativeDiagnosticAdapter: DiagnosticRequestAdapter = {
581
585
  supports: (uri) => this.hasApplicableDiagnosticProvider(uri),
@@ -1053,12 +1057,18 @@ export class LspClient {
1053
1057
  if (
1054
1058
  staticRecord !== undefined &&
1055
1059
  isValidDiagnosticOptions(staticRecord) &&
1056
- isDocumentSelectorApplicable(staticRecord.documentSelector, uri)
1060
+ isDocumentSelectorApplicable(staticRecord.documentSelector, uri, this.fileAssociations)
1057
1061
  ) {
1058
1062
  return staticRecord;
1059
1063
  }
1060
1064
  for (const registration of this.dynamicRegistrations.get(DOCUMENT_DIAGNOSTIC_METHOD)) {
1061
- if (isDocumentSelectorApplicable(registration.options.documentSelector, uri)) {
1065
+ if (
1066
+ isDocumentSelectorApplicable(
1067
+ registration.options.documentSelector,
1068
+ uri,
1069
+ this.fileAssociations,
1070
+ )
1071
+ ) {
1062
1072
  return registration.options;
1063
1073
  }
1064
1074
  }
@@ -1239,6 +1249,37 @@ export class LspClient {
1239
1249
  return result.kind === "completed" ? readTypeScriptProjectMembership(result.data) : "unknown";
1240
1250
  }
1241
1251
 
1252
+ /**
1253
+ * Run one server command and capture the single edit it pushes through
1254
+ * `workspace/applyEdit`. Every pushed edit is refused, so no file changes.
1255
+ * Open documents the edit touches are re-sent afterwards under new versions.
1256
+ */
1257
+ async captureCommandEdits(
1258
+ command: Command,
1259
+ options: {
1260
+ timeoutMs: number;
1261
+ graceMs?: number;
1262
+ drainMs?: number;
1263
+ control?: CodeRequestControl;
1264
+ },
1265
+ ): Promise<CommandEditCaptureResult> {
1266
+ const rpc = this.rpc;
1267
+ if (!rpc || this._status !== "running") {
1268
+ return {
1269
+ kind: "unavailable",
1270
+ reason: "The server command is unavailable because the client is not running.",
1271
+ };
1272
+ }
1273
+ return runCommandEditCapture({
1274
+ command,
1275
+ ...options,
1276
+ window: this.editCapture,
1277
+ sendRequestOwned: (method, params, requestOptions) =>
1278
+ rpc.sendRequestOwned(method, params, requestOptions),
1279
+ resynchronize: (file) => this.diagnostics.resynchronizeOpenDocument(file),
1280
+ });
1281
+ }
1282
+
1242
1283
  async workspaceSymbol(
1243
1284
  query: string,
1244
1285
  control?: CodeRequestControl,
@@ -1430,7 +1471,17 @@ export class LspClient {
1430
1471
  control: CodeRequestControl | undefined,
1431
1472
  only: readonly string[] | undefined,
1432
1473
  ): Promise<CodeAction[]> {
1433
- const candidates = raw.map((value) => {
1474
+ // Servers may still return kinds that `only` excludes (typescript-language-server
1475
+ // adds quickfixes). Drop those before validation so they cannot fail the request.
1476
+ const requested = only
1477
+ ? raw.filter(
1478
+ (value) =>
1479
+ !isRecord(value) ||
1480
+ typeof value.kind !== "string" ||
1481
+ only.some((filter) => isCodeActionKindWithin(value.kind, filter)),
1482
+ )
1483
+ : raw;
1484
+ const candidates = requested.map((value) => {
1434
1485
  const candidate = normalizeCodeActionResponseEntry(value);
1435
1486
  if (!candidate)
1436
1487
  throw new Error("LSP server returned a malformed code action response entry.");
@@ -1443,7 +1494,7 @@ export class LspClient {
1443
1494
  !this.supportsCodeActionResolve ||
1444
1495
  !lazyAction ||
1445
1496
  (only !== undefined &&
1446
- !only.some((filter) => codeActionKindMatchesFilter(lazyAction.kind, filter)))
1497
+ !only.some((filter) => isCodeActionKindWithin(lazyAction.kind, filter)))
1447
1498
  ) {
1448
1499
  actions.push(action);
1449
1500
  continue;
@@ -1773,6 +1824,8 @@ export class LspClient {
1773
1824
 
1774
1825
  private handleServerRequest(method: string, params: unknown): unknown {
1775
1826
  switch (method) {
1827
+ case "workspace/applyEdit":
1828
+ return this.editCapture.handleApplyEdit(params);
1776
1829
  case "workspace/configuration":
1777
1830
  return this.buildWorkspaceConfigurationResult(params);
1778
1831
  case "workspace/workspaceFolders":
@@ -42,15 +42,20 @@ export const CLIENT_CAPABILITIES: ClientCapabilities = {
42
42
  "refactor.extract.constant",
43
43
  "refactor.extract.variable",
44
44
  "refactor.inline",
45
+ "refactor.move",
45
46
  "refactor.rewrite",
46
47
  "source",
47
48
  "source.organizeImports",
48
49
  "source.fixAll",
49
50
  "source.removeUnused",
51
+ "source.removeUnusedImports",
52
+ "source.sortImports",
50
53
  ],
51
54
  },
52
55
  },
53
56
  dataSupport: true,
57
+ // Never advertise `workspace.workspaceEdit.snippetEditSupport` (LSP 3.18):
58
+ // snippet edits cannot become exact previewable text plans.
54
59
  // SuPi resolves only the edit property. Commands and other fields
55
60
  // must remain server-owned and are never advertised as resolvable.
56
61
  resolveSupport: { properties: ["edit"] },
@@ -72,6 +77,9 @@ export const CLIENT_CAPABILITIES: ClientCapabilities = {
72
77
  workDoneProgress: true,
73
78
  },
74
79
  workspace: {
80
+ // Servers may push edits through workspace/applyEdit. SuPi records them
81
+ // only while previewing one command and always replies applied: false.
82
+ applyEdit: true,
75
83
  configuration: true,
76
84
  workspaceFolders: true,
77
85
  workspaceEdit: {
@@ -4,10 +4,8 @@ import type { ServerCapabilities } from "vscode-languageserver-protocol";
4
4
  export type ServerOperationName =
5
5
  | "rename_symbol"
6
6
  | "prepare_rename"
7
- | "extract_function"
8
- | "extract_variable"
9
- | "update_imports"
10
- | "delete_dead_code"
7
+ | "code_action"
8
+ | "source_action"
11
9
  | "code_action_resolve";
12
10
 
13
11
  /** Whether a server advertises one operation in its negotiated capabilities. */
@@ -19,6 +17,13 @@ export interface ServerOperationSupport {
19
17
  readonly supi: "supported" | "limited" | "unsupported";
20
18
  /** A stable explanation for a SuPi planning limitation or deferral. */
21
19
  readonly reason?: string;
20
+ /** Advertised CodeActionKinds in this operation's family (`refactor*` or `source*`). */
21
+ readonly kinds?: readonly string[];
22
+ /**
23
+ * The server may return kinds beyond `kinds`: it advertises CodeActionKind.Empty
24
+ * (`""`) or does not narrow `codeActionKinds` at all.
25
+ */
26
+ readonly anyKind?: boolean;
22
27
  }
23
28
 
24
29
  /** Operation support projected onto a project-server health entry. */
@@ -26,23 +31,6 @@ export type ServerOperationSupportMap = Partial<
26
31
  Record<ServerOperationName, ServerOperationSupport>
27
32
  >;
28
33
 
29
- const REMOVE_UNUSED_KIND = "source.removeUnused";
30
- const REMOVE_UNUSED_IMPORTS_KIND = "source.removeUnusedImports";
31
-
32
- /** Code-action kinds requested by each operation-specific planner. */
33
- export const REFACTOR_CODE_ACTION_KINDS = {
34
- extract_function: ["refactor.extract.function"],
35
- extract_variable: ["refactor.extract.constant", "refactor.extract.variable"],
36
- update_imports: ["source.organizeImports"],
37
- delete_dead_code: [REMOVE_UNUSED_KIND, REMOVE_UNUSED_IMPORTS_KIND],
38
- } as const satisfies Record<
39
- "extract_function" | "extract_variable" | "update_imports" | "delete_dead_code",
40
- readonly string[]
41
- >;
42
-
43
- const EXTRACTION_NAME_LIMITATION =
44
- "Standard LSP code actions do not provide a safe exact extraction-name contract.";
45
-
46
34
  function isRecord(value: unknown): value is Record<string, unknown> {
47
35
  return typeof value === "object" && value !== null && !Array.isArray(value);
48
36
  }
@@ -95,6 +83,22 @@ export function supportsCodeActionResolve(capabilities: ServerCapabilities | nul
95
83
  return codeActionOptions(capabilities)?.resolveProvider === true;
96
84
  }
97
85
 
86
+ /**
87
+ * Test whether a returned CodeActionKind falls under a requested kind: the same
88
+ * kind or a dotted descendant. The empty kind requests every kind.
89
+ */
90
+ export function isCodeActionKindWithin(kind: unknown, requested: string): boolean {
91
+ if (requested === "") return true;
92
+ return typeof kind === "string" && (kind === requested || kind.startsWith(`${requested}.`));
93
+ }
94
+
95
+ /** Return the negotiated `codeActionKinds`, or undefined when the server did not narrow them. */
96
+ export function advertisedCodeActionKinds(
97
+ capabilities: ServerCapabilities | null,
98
+ ): readonly string[] | undefined {
99
+ return codeActionOptions(capabilities)?.kinds;
100
+ }
101
+
98
102
  /** Test the documented hierarchical relationship between two code-action kinds. */
99
103
  export function isCodeActionKindInRange(kind: string, requested: string): boolean {
100
104
  if (kind === "" || requested === "") return true;
@@ -118,46 +122,30 @@ export function supportsRequestedCodeActionKinds(
118
122
  );
119
123
  }
120
124
 
121
- function codeActionAdvertisement(
122
- capabilities: ServerCapabilities | null,
123
- requested: readonly string[],
124
- ): ServerAdvertisement {
125
- if (capabilities === null) return "unknown";
126
- const options = codeActionOptions(capabilities);
127
- if (!options) return "not-advertised";
128
- if (requested.length === 0 || !options.kinds) return "unknown";
129
- if (!supportsRequestedCodeActionKinds(capabilities, requested)) return "not-advertised";
130
-
131
- // A generic parent or the empty kind proves eligibility, but it does not
132
- // prove that this exact operation is implemented by the server.
133
- const exactOrChild = requested.some((wanted) =>
134
- options.kinds?.some(
135
- (kind) => kind === wanted || (wanted !== "" && kind.startsWith(`${wanted}.`)),
136
- ),
137
- );
138
- return exactOrChild ? "advertised" : "unknown";
125
+ function supportedOperation(server: ServerAdvertisement): ServerOperationSupport {
126
+ return { server, supi: "supported" };
139
127
  }
140
128
 
141
- function supportedOperation(
142
- server: ServerAdvertisement,
143
- supi: ServerOperationSupport["supi"] = "supported",
144
- reason?: string,
129
+ /** Project advertised kinds for one CodeActionKind family, such as `refactor` or `source`. */
130
+ function codeActionFamilySupport(
131
+ capabilities: ServerCapabilities | null,
132
+ family: "refactor" | "source",
145
133
  ): ServerOperationSupport {
146
- return { server, supi, ...(reason ? { reason } : {}) };
147
- }
148
-
149
- function deleteDeadCodeSupport(capabilities: ServerCapabilities | null): ServerOperationSupport {
150
- const server = codeActionAdvertisement(capabilities, REFACTOR_CODE_ACTION_KINDS.delete_dead_code);
151
- const importsOnly =
152
- codeActionAdvertisement(capabilities, [REMOVE_UNUSED_KIND]) !== "advertised" &&
153
- codeActionAdvertisement(capabilities, [REMOVE_UNUSED_IMPORTS_KIND]) === "advertised";
154
- return importsOnly
155
- ? supportedOperation(
156
- server,
157
- "limited",
158
- "Only unused-import removal is supported by this mapping, not arbitrary unused declarations.",
159
- )
160
- : supportedOperation(server);
134
+ if (capabilities === null) return supportedOperation("unknown");
135
+ const options = codeActionOptions(capabilities);
136
+ if (!options) return supportedOperation("not-advertised");
137
+ // An unnarrowed provider or the empty kind may return kinds it does not list.
138
+ const anyKind = !options.kinds || options.kinds.includes("");
139
+ const kinds = (options.kinds ?? []).filter(
140
+ (kind) => kind !== "" && isCodeActionKindWithin(kind, family),
141
+ );
142
+ if (kinds.length === 0 && !anyKind) return supportedOperation("not-advertised");
143
+ return {
144
+ server: "advertised",
145
+ supi: "supported",
146
+ ...(kinds.length > 0 ? { kinds } : {}),
147
+ ...(anyKind ? { anyKind } : {}),
148
+ };
161
149
  }
162
150
 
163
151
  /** Build the honest operation-support projection for one negotiated server. */
@@ -185,27 +173,8 @@ export function getServerOperationSupport(
185
173
  return {
186
174
  rename_symbol: supportedOperation(renameServer),
187
175
  prepare_rename: supportedOperation(prepareServer),
188
- extract_function: supportedOperation(
189
- codeActionAdvertisement(capabilities, REFACTOR_CODE_ACTION_KINDS.extract_function),
190
- "limited",
191
- EXTRACTION_NAME_LIMITATION,
192
- ),
193
- extract_variable: supportedOperation(
194
- codeActionAdvertisement(capabilities, REFACTOR_CODE_ACTION_KINDS.extract_variable),
195
- "limited",
196
- EXTRACTION_NAME_LIMITATION,
197
- ),
198
- update_imports: supportedOperation(
199
- codeActionAdvertisement(capabilities, REFACTOR_CODE_ACTION_KINDS.update_imports),
200
- ),
201
- delete_dead_code: deleteDeadCodeSupport(capabilities),
176
+ code_action: codeActionFamilySupport(capabilities, "refactor"),
177
+ source_action: codeActionFamilySupport(capabilities, "source"),
202
178
  code_action_resolve: supportedOperation(resolveServer),
203
179
  };
204
180
  }
205
-
206
- /** Get the exact operation-specific kinds used in a code-action request. */
207
- export function codeActionKindsForOperation(
208
- operation: keyof typeof REFACTOR_CODE_ACTION_KINDS,
209
- ): readonly string[] {
210
- return REFACTOR_CODE_ACTION_KINDS[operation];
211
- }
@@ -162,6 +162,16 @@ export function getFileScopeDecision(filePath: string, cwd: string): FileScopeDe
162
162
  };
163
163
  }
164
164
 
165
+ /**
166
+ * Whether a project config's program includes a file, by its own file set or
167
+ * include/exclude rules. Unlike {@link getFileScopeDecision}, the config need
168
+ * not be the file's nearest one (e.g. a test config including `../src`).
169
+ */
170
+ export function projectConfigIncludesFile(configPath: string, filePath: string): boolean {
171
+ const parsed = parseProjectConfig(configPath);
172
+ return parsed !== null && decideFileScope(parsed, path.resolve(filePath)).included;
173
+ }
174
+
165
175
  function isOutOfTree(cwd: string, absolutePath: string): boolean {
166
176
  const relative = path.relative(path.resolve(cwd), absolutePath);
167
177
  return relative.startsWith(`..${path.sep}`) || relative === "..";