Skip to content
Merged
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/codefix-fix-all-2026-7-30.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: feature
packages:
- "@typespec/compiler"
---

Add `Fix all: X` code action for codefixes that can be applied to multiple instances in a file at once. When a codefix applies to more than one diagnostic of the same kind in a file, a `Fix all: <fix label>` quick fix action is now suggested alongside the individual fix.
7 changes: 7 additions & 0 deletions .chronus/changes/codefix-fix-all-playground-2026-7-30.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: feature
packages:
- "@typespec/playground"
---

Add support for the `Fix all: X` code action in the playground, allowing a codefix that appears multiple times in a file to be applied to all instances at once.
88 changes: 73 additions & 15 deletions packages/compiler/src/server/serverlib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,11 @@ export function createServer(
log,
clientConfigsProvider,
});
let currentDiagnosticIndex = new Map<number, Diagnostic>();
interface DiagnosticIndexEntry {
readonly diagnostic: Diagnostic;
readonly fileUri: string;
}
let currentDiagnosticIndex = new Map<number, DiagnosticIndexEntry>();
let diagnosticIdCounter = 0;

let workspaceFolders: ServerWorkspaceFolder[] = [];
Expand Down Expand Up @@ -789,7 +793,7 @@ export function createServer(
if (!document) return undefined;
if (isTspConfigFile(document)) return undefined;

const newDiagnosticIndex = new Map<number, Diagnostic>();
const newDiagnosticIndex = new Map<number, DiagnosticIndexEntry>();
// Group diagnostics by file.
//
// Initialize diagnostics for all source files in program to empty array
Expand Down Expand Up @@ -862,7 +866,7 @@ export function createServer(
"Diagnostic reported against a source file that was not added to the program.",
);
diagnostics.push(diagnostic);
newDiagnosticIndex.set(diagnostic.data.id, each);
newDiagnosticIndex.set(diagnostic.data.id, { diagnostic: each, fileUri: diagDocument.uri });
}
}

Expand Down Expand Up @@ -894,7 +898,10 @@ export function createServer(
"Diagnostic reported against a source file that was not added to the program.",
);
diagnostics.push(diagnostic);
newDiagnosticIndex.set(diagnostic.data.id, unusedSuppressionDiagnostic);
newDiagnosticIndex.set(diagnostic.data.id, {
diagnostic: unusedSuppressionDiagnostic,
fileUri: diagDocument.uri,
});
}
}

Expand Down Expand Up @@ -1411,33 +1418,84 @@ export function createServer(
async function getCodeActions(params: CodeActionParams): Promise<CodeAction[]> {
if (isTspConfigFile(params.textDocument)) return [];

const actions = [];
const fileUri = params.textDocument.uri;
const actions: CodeAction[] = [];

// Track fix IDs seen in the current selection to generate "Fix all" actions
const fixIdsInSelection = new Set<string>();

for (const vsDiag of params.context.diagnostics) {
const tspDiag = currentDiagnosticIndex.get(vsDiag.data?.id);
if (tspDiag === undefined || tspDiag.codefixes === undefined) continue;
const entry = currentDiagnosticIndex.get(vsDiag.data?.id);
if (entry === undefined || entry.diagnostic.codefixes === undefined) continue;

for (const fix of tspDiag.codefixes ?? []) {
for (const fix of entry.diagnostic.codefixes) {
const codeAction: CodeAction = {
title: fix.label,
kind: CodeActionKind.QuickFix,
diagnostics: [vsDiag],
data: { diagId: vsDiag.data?.id, fixId: fix.id },
};
actions.push(codeAction);
fixIdsInSelection.add(fix.id);
}
}

// Build a map of fixId -> { count, label } for all diagnostics in the current file
const fixIdInfoInFile = new Map<string, { count: number; label: string }>();
for (const entry of currentDiagnosticIndex.values()) {
if (entry.fileUri !== fileUri) continue;
for (const fix of entry.diagnostic.codefixes ?? []) {
const existing = fixIdInfoInFile.get(fix.id);
if (existing) {
existing.count++;
} else {
fixIdInfoInFile.set(fix.id, { count: 1, label: fix.label });
}
}
}

// Add "Fix all: X" actions for codefixes that appear multiple times in the file
const addedFixAllIds = new Set<string>();
for (const fixId of fixIdsInSelection) {
const info = fixIdInfoInFile.get(fixId);
if (info && info.count > 1 && !addedFixAllIds.has(fixId)) {
addedFixAllIds.add(fixId);
actions.push({
title: `Fix all: ${info.label}`,
kind: CodeActionKind.QuickFix,
data: { fixAllInFile: { fixId, fileUri } },
});
}
}

return actions;
}

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);
if (codeFix) {
const edits = await resolveCodeFix(codeFix);
codeAction.edit = { documentChanges: convertCodeFixEdits(edits) };
const data = codeAction.data ?? {};
if (data.fixAllInFile !== undefined) {
const { fixId, fileUri } = data.fixAllInFile;
const allEdits: CodeFixEdit[] = [];
for (const entry of currentDiagnosticIndex.values()) {
if (entry.fileUri !== fileUri) continue;
const codeFix = entry.diagnostic.codefixes?.find((x) => x.id === fixId);
if (codeFix) {
const edits = await resolveCodeFix(codeFix);
allEdits.push(...edits);
}
}
if (allEdits.length > 0) {
codeAction.edit = { documentChanges: convertCodeFixEdits(allEdits) };
}
} else {
const { diagId, fixId } = data;
if (diagId !== undefined && fixId) {
const entry = currentDiagnosticIndex.get(diagId);
const codeFix = entry?.diagnostic.codefixes?.find((x) => x.id === fixId);
if (codeFix) {
const edits = await resolveCodeFix(codeFix);
codeAction.edit = { documentChanges: convertCodeFixEdits(edits) };
}
}
}
return codeAction;
Expand Down
101 changes: 101 additions & 0 deletions packages/compiler/test/server/code-actions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { beforeEach, describe, expect, it } from "vitest";
import { CodeAction, CodeActionKind, Diagnostic, Range } from "vscode-languageserver";
import { createTestServerHost, TestServerHost } from "../../src/testing/test-server-host.js";

let host: TestServerHost;

beforeEach(async () => {
host = await createTestServerHost();
});

/** Add the given files, compile `main.tsp` and return its diagnostics. */
async function compileAndGetDiagnostics(
files: Record<string, string>,
): Promise<readonly Diagnostic[]> {
let mainFile;
for (const [path, content] of Object.entries(files)) {
const doc = host.addOrUpdateDocument(path, content);
if (path.endsWith("main.tsp")) {
mainFile = doc;
}
}

await host.server.compile(mainFile!, undefined, { mode: "full" });
return host.getDiagnostics("main.tsp");
}

function getCodeActions(diagnostics: Diagnostic[]): Promise<CodeAction[]> {
return host.server.getCodeActions({
textDocument: { uri: host.getURL("main.tsp") },
range: Range.create(0, 0, 0, 0),
context: { diagnostics },
});
}

describe("Fix all: X actions", () => {
it("shows 'Fix all: X' action when same codefix appears multiple times in the file", async () => {
const diags = await compileAndGetDiagnostics({
"./sub.tsp": "namespace Foo; model FooModel {};",
"./sub2.tsp": "namespace Bar; model BarModel {};",
"./main.tsp": 'import "./sub.tsp";\nimport "./sub2.tsp";\nusing Foo;\nusing Bar;',
});

// Both "using Foo" and "using Bar" are unused → 2 diagnostics with same codefix id
expect(diags.length).toBeGreaterThanOrEqual(2);

const actions = await getCodeActions([diags[0]]);

// Should include an individual fix
expect(actions.some((a) => a.kind === CodeActionKind.QuickFix && !a.data?.fixAllInFile)).toBe(
true,
);

// Should include a "Fix all: Remove unused code" action
const fixAllAction = actions.find(
(a) => a.kind === CodeActionKind.QuickFix && a.data?.fixAllInFile !== undefined,
);
expect(fixAllAction).toBeDefined();
expect(fixAllAction?.title).toBe("Fix all: Remove unused code");
});

it("does NOT show 'Fix all: X' action when codefix only appears once in the file", async () => {
const diags = await compileAndGetDiagnostics({
"./sub.tsp": "namespace Foo; model FooModel {};",
"./main.tsp": 'import "./sub.tsp";\nusing Foo;',
});

expect(diags.length).toBeGreaterThanOrEqual(1);

const actions = await getCodeActions([diags[0]]);

// Should include individual fix but NOT "Fix all"
expect(actions.some((a) => a.kind === CodeActionKind.QuickFix)).toBe(true);
expect(actions.some((a) => a.data?.fixAllInFile !== undefined)).toBe(false);
});
});

describe("resolveCodeAction for Fix all", () => {
it("resolves 'Fix all: X' action applying all instances in the file", async () => {
const diags = await compileAndGetDiagnostics({
"./sub.tsp": "namespace Foo; model FooModel {};",
"./sub2.tsp": "namespace Bar; model BarModel {};",
"./main.tsp": 'import "./sub.tsp";\nimport "./sub2.tsp";\nusing Foo;\nusing Bar;',
});

expect(diags.length).toBeGreaterThanOrEqual(2);

const actions = await getCodeActions([diags[0]]);
const fixAllAction = actions.find((a) => a.data?.fixAllInFile !== undefined);
expect(fixAllAction).toBeDefined();

const resolved = await host.server.resolveCodeAction(fixAllAction!);
expect(resolved.edit?.documentChanges?.length).toBeGreaterThan(0);

// Should contain edits (both "using Foo" and "using Bar" removal)
const edits = resolved.edit!.documentChanges!.filter(
(c): c is { textDocument: any; edits: any[] } => "edits" in c,
);
const totalEdits = edits.reduce((sum, e) => sum + e.edits.length, 0);
expect(totalEdits).toBeGreaterThanOrEqual(2);
});
});
Loading
Loading