Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .chronus/changes/resolve-codefixes-api-2026-7-7-20-21-0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: feature
packages:
- "@typespec/compiler"
---

Add dynamic codefix expansion support for language-server code actions.
7 changes: 7 additions & 0 deletions packages/compiler/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2184,6 +2184,13 @@ export interface CodeFix {
readonly id: string;
readonly label: string;
readonly fix: (fixContext: CodeFixContext) => CodeFixEdit | CodeFixEdit[] | Promise<void> | 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<CodeFix[]>;
}

export interface FilePos {
Expand Down
53 changes: 44 additions & 9 deletions packages/compiler/src/server/serverlib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ import { createSourceFile, getSourceFileKindFromExt } from "../core/source-file.
import { createRemoveUnusedSuppressionCodeFix } from "../core/suppression-tracking.js";
import {
AugmentDecoratorStatementNode,
CodeFix,
CodeFixEdit,
CompilerHost,
DecoratorDeclarationStatementNode,
Expand Down Expand Up @@ -178,6 +179,8 @@ export function createServer(
});
let currentDiagnosticIndex = new Map<number, Diagnostic>();
let diagnosticIdCounter = 0;
const resolvedCodefixCache = new Map<string, Promise<CodeFix[]>>(); // AI suggestion result cache - declared
const resolvedCodefixMap = new Map<string, CodeFix>(); // resolved codefix lookup for resolveCodeAction

let workspaceFolders: ServerWorkspaceFolder[] = [];
let isInitialized = false;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 },
});
}
}
}

Expand All @@ -1412,8 +1445,10 @@ export function createServer(
async function resolveCodeAction(codeAction: CodeAction): Promise<CodeAction> {
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) };
Expand Down
109 changes: 109 additions & 0 deletions packages/compiler/test/server/code-action.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
Loading