diff --git a/.chronus/changes/resolve-codefixes-api-2026-7-7-20-21-0.md b/.chronus/changes/resolve-codefixes-api-2026-7-7-20-21-0.md new file mode 100644 index 00000000000..6aa4f311503 --- /dev/null +++ b/.chronus/changes/resolve-codefixes-api-2026-7-7-20-21-0.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/compiler" +--- + +Add dynamic codefix expansion support for language-server code actions. diff --git a/packages/compiler/src/core/types.ts b/packages/compiler/src/core/types.ts index 50c36efbf4b..4ff1bed8586 100644 --- a/packages/compiler/src/core/types.ts +++ b/packages/compiler/src/core/types.ts @@ -2184,6 +2184,13 @@ export interface CodeFix { readonly id: string; readonly label: string; readonly fix: (fixContext: CodeFixContext) => CodeFixEdit | CodeFixEdit[] | Promise | void; + /** + * Optional async resolver that expands this codefix into multiple labeled codefixes. + * Called when the user triggers the code action menu (Ctrl+.). + * Each returned item becomes a separate entry in the quickfix menu with its own label. + * If not provided, the single `label` and `fix` are used as-is. + */ + readonly resolveCodefixes?: () => Promise; } export interface FilePos { diff --git a/packages/compiler/src/server/serverlib.ts b/packages/compiler/src/server/serverlib.ts index 964cca1f927..3fe82cf0fac 100644 --- a/packages/compiler/src/server/serverlib.ts +++ b/packages/compiler/src/server/serverlib.ts @@ -75,6 +75,7 @@ import { createSourceFile, getSourceFileKindFromExt } from "../core/source-file. import { createRemoveUnusedSuppressionCodeFix } from "../core/suppression-tracking.js"; import { AugmentDecoratorStatementNode, + CodeFix, CodeFixEdit, CompilerHost, DecoratorDeclarationStatementNode, @@ -178,6 +179,8 @@ export function createServer( }); let currentDiagnosticIndex = new Map(); let diagnosticIdCounter = 0; + const resolvedCodefixCache = new Map>(); // AI suggestion result cache - declared + const resolvedCodefixMap = new Map(); // resolved codefix lookup for resolveCodeAction let workspaceFolders: ServerWorkspaceFolder[] = []; let isInitialized = false; @@ -880,6 +883,8 @@ export function createServer( // Atomically swap the diagnostic index so that in-flight code action resolves // referencing old diagnostic IDs can still find their entries until new diagnostics are sent. currentDiagnosticIndex = newDiagnosticIndex; + resolvedCodefixCache.clear(); // AI suggestion result cache - cleared on recompilation + resolvedCodefixMap.clear(); for (const [document, diagnostics] of diagnosticMap) { sendDiagnostics(document, diagnostics); @@ -1396,13 +1401,41 @@ export function createServer( if (tspDiag === undefined || tspDiag.codefixes === undefined) continue; for (const fix of tspDiag.codefixes ?? []) { - const codeAction: CodeAction = { - title: fix.label, - kind: CodeActionKind.QuickFix, - diagnostics: [vsDiag], - data: { diagId: vsDiag.data?.id, fixId: fix.id }, - }; - actions.push(codeAction); + if (fix.resolveCodefixes) { + const cacheKey = `${vsDiag.data?.id}:${fix.id}`; + if (!resolvedCodefixCache.has(cacheKey)) { + // AI suggestion result cache - checked and populated (Promise cached immediately to prevent concurrent duplicates) + resolvedCodefixCache.set(cacheKey, fix.resolveCodefixes().catch(() => [])); + } + + try { + const resolved = await resolvedCodefixCache.get(cacheKey)!; + for (const resolvedFix of resolved) { + // Store in map for resolveCodeAction lookup (not in the diagnostic array) + resolvedCodefixMap.set(resolvedFix.id, resolvedFix); + actions.push({ + title: resolvedFix.label, + kind: CodeActionKind.QuickFix, + diagnostics: [vsDiag], + data: { diagId: vsDiag.data?.id, fixId: resolvedFix.id }, + }); + } + } catch { + actions.push({ + title: fix.label, + kind: CodeActionKind.QuickFix, + diagnostics: [vsDiag], + data: { diagId: vsDiag.data?.id, fixId: fix.id }, + }); + } + } else { + actions.push({ + title: fix.label, + kind: CodeActionKind.QuickFix, + diagnostics: [vsDiag], + data: { diagId: vsDiag.data?.id, fixId: fix.id }, + }); + } } } @@ -1412,8 +1445,10 @@ export function createServer( async function resolveCodeAction(codeAction: CodeAction): Promise { const { diagId, fixId } = codeAction.data ?? {}; if (diagId !== undefined && fixId) { - const diag = currentDiagnosticIndex.get(diagId); - const codeFix = diag?.codefixes?.find((x) => x.id === fixId); + // Check resolved codefixes first (from resolveCodefixes), then diagnostic codefixes + const codeFix = + resolvedCodefixMap.get(fixId) ?? + currentDiagnosticIndex.get(diagId)?.codefixes?.find((x) => x.id === fixId); if (codeFix) { const edits = await resolveCodeFix(codeFix); codeAction.edit = { documentChanges: convertCodeFixEdits(edits) }; diff --git a/packages/compiler/test/server/code-action.test.ts b/packages/compiler/test/server/code-action.test.ts new file mode 100644 index 00000000000..5c6479ca14b --- /dev/null +++ b/packages/compiler/test/server/code-action.test.ts @@ -0,0 +1,109 @@ +import { deepStrictEqual, strictEqual } from "assert"; +import { it } from "vitest"; +import { CodeActionKind, Range } from "vscode-languageserver"; +import { createLinterRule, defineLinter } from "../../src/core/library.js"; +import { createTestServerHost } from "../../src/testing/test-server-host.js"; + +it("expands dynamic codefixes into separate code actions", async () => { + const host = await createTestServerHost(); + let resolveCalls = 0; + let appliedFix = ""; + + const dynamicRule = createLinterRule({ + name: "dynamic-codefix", + description: "Test dynamic codefix expansion", + severity: "warning", + messages: { + default: "Dynamic codefix test.", + }, + create(context) { + return { + model: (target) => { + context.reportDiagnostic({ + target, + codefixes: [ + { + id: "dynamic-root", + label: "Generate dynamic fixes", + fix: () => undefined, + resolveCodefixes: async () => { + resolveCalls++; + return [ + { + id: "dynamic-first", + label: "Use first suggestion", + fix: () => { + appliedFix = "first"; + }, + }, + { + id: "dynamic-second", + label: "Use second suggestion", + fix: () => { + appliedFix = "second"; + }, + }, + ]; + }, + }, + ], + }); + }, + }; + }, + }); + + host.addTypeSpecFile( + "./node_modules/@typespec/dynamic-codefix-linter/package.json", + JSON.stringify({ + name: "@typespec/dynamic-codefix-linter", + version: "0.0.1", + main: "lib/index.js", + }), + ); + host.addJsFile("./node_modules/@typespec/dynamic-codefix-linter/lib/index.js", { + $linter: defineLinter({ + rules: [dynamicRule], + ruleSets: { + recommended: { + enable: { + "@typespec/dynamic-codefix-linter/dynamic-codefix": true, + }, + }, + }, + }), + }); + + host.addOrUpdateDocument( + "./tspconfig.yaml", + [ + "linter:", + " extends:", + ' - "@typespec/dynamic-codefix-linter/recommended"', + ].join("\n"), + ); + const document = host.addOrUpdateDocument("./main.tsp", "model Test {}"); + await host.server.compile(document, undefined, { mode: "full" }); + + const diagnostics = host.getDiagnostics("./main.tsp"); + strictEqual(diagnostics.length, 1); + + const actions = await host.server.getCodeActions({ + textDocument: document, + range: Range.create(0, 0, 0, 0), + context: { diagnostics: [...diagnostics] }, + }); + + const dynamicActions = actions.filter((x) => x.title.startsWith("Use ")); + deepStrictEqual( + dynamicActions.map((x) => ({ title: x.title, kind: x.kind })), + [ + { title: "Use first suggestion", kind: CodeActionKind.QuickFix }, + { title: "Use second suggestion", kind: CodeActionKind.QuickFix }, + ], + ); + strictEqual(resolveCalls, 1); + + await host.server.resolveCodeAction(dynamicActions[1]); + strictEqual(appliedFix, "second"); +});