diff --git a/.chronus/changes/codefix-fix-all-2026-7-30.md b/.chronus/changes/codefix-fix-all-2026-7-30.md new file mode 100644 index 00000000000..8ab37b6d43b --- /dev/null +++ b/.chronus/changes/codefix-fix-all-2026-7-30.md @@ -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: ` quick fix action is now suggested alongside the individual fix. diff --git a/.chronus/changes/codefix-fix-all-playground-2026-7-30.md b/.chronus/changes/codefix-fix-all-playground-2026-7-30.md new file mode 100644 index 00000000000..6fdb7f5238a --- /dev/null +++ b/.chronus/changes/codefix-fix-all-playground-2026-7-30.md @@ -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. diff --git a/packages/compiler/src/server/serverlib.ts b/packages/compiler/src/server/serverlib.ts index 55a4379472c..b5bfeae279b 100644 --- a/packages/compiler/src/server/serverlib.ts +++ b/packages/compiler/src/server/serverlib.ts @@ -177,7 +177,11 @@ export function createServer( log, clientConfigsProvider, }); - let currentDiagnosticIndex = new Map(); + interface DiagnosticIndexEntry { + readonly diagnostic: Diagnostic; + readonly fileUri: string; + } + let currentDiagnosticIndex = new Map(); let diagnosticIdCounter = 0; let workspaceFolders: ServerWorkspaceFolder[] = []; @@ -789,7 +793,7 @@ export function createServer( if (!document) return undefined; if (isTspConfigFile(document)) return undefined; - const newDiagnosticIndex = new Map(); + const newDiagnosticIndex = new Map(); // Group diagnostics by file. // // Initialize diagnostics for all source files in program to empty array @@ -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 }); } } @@ -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, + }); } } @@ -1411,12 +1418,17 @@ export function createServer( async function getCodeActions(params: CodeActionParams): Promise { 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(); + 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, @@ -1424,6 +1436,35 @@ export function createServer( 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(); + 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(); + 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 } }, + }); } } @@ -1431,13 +1472,30 @@ 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); - 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; diff --git a/packages/compiler/test/server/code-actions.test.ts b/packages/compiler/test/server/code-actions.test.ts new file mode 100644 index 00000000000..4c7c94f51c1 --- /dev/null +++ b/packages/compiler/test/server/code-actions.test.ts @@ -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, +): Promise { + 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 { + 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); + }); +}); diff --git a/packages/playground/src/services.ts b/packages/playground/src/services.ts index a1c5c3fef6d..3b22fe9688e 100644 --- a/packages/playground/src/services.ts +++ b/packages/playground/src/services.ts @@ -1,5 +1,7 @@ import { TypeSpecLanguageConfiguration, + type CodeFix, + type CodeFixEdit, type Diagnostic, type DiagnosticTarget, type NoTarget, @@ -396,7 +398,27 @@ export async function registerMonacoLanguage(host: BrowserHost) { const compiler = _currentCompiler; if (!compiler) return { actions: [], dispose: () => {} }; + // First pass: collect all fixes across the whole file, grouped by fix id, + // so we can offer "Fix all" when the same fix appears more than once. + const fixIdToFixes = new Map(); + for (const diag of _currentDiagnostics) { + if (!diag.codefixes?.length) continue; + const loc = compiler.getSourceLocation(diag.target, { locateId: true }); + if (!loc || loc.file.path !== "/test/main.tsp") continue; + for (const fix of diag.codefixes) { + let fixes = fixIdToFixes.get(fix.id); + if (!fixes) { + fixes = []; + fixIdToFixes.set(fix.id, fixes); + } + fixes.push(fix); + } + } + + // Second pass: emit per-instance actions for range-overlapping diagnostics, + // plus a "Fix all" action for any fix id that appears more than once. const actions: monaco.languages.CodeAction[] = []; + const fixAllAdded = new Set(); for (const diag of _currentDiagnostics) { if (!diag.codefixes?.length) continue; const loc = compiler.getSourceLocation(diag.target, { locateId: true }); @@ -406,41 +428,7 @@ export async function registerMonacoLanguage(host: BrowserHost) { for (const fix of diag.codefixes) { const edits = await compiler.resolveCodeFix(fix); - const workspaceEdits: monaco.languages.IWorkspaceTextEdit[] = edits - .filter((edit) => edit.file.path === "/test/main.tsp") - .map((edit) => { - const start = edit.file.getLineAndCharacterOfPosition(edit.pos); - if (edit.kind === "insert-text") { - return { - resource: model.uri, - textEdit: { - range: { - startLineNumber: start.line + 1, - startColumn: start.character + 1, - endLineNumber: start.line + 1, - endColumn: start.character + 1, - }, - text: edit.text, - }, - versionId: undefined, - }; - } else { - const end = edit.file.getLineAndCharacterOfPosition(edit.end); - return { - resource: model.uri, - textEdit: { - range: { - startLineNumber: start.line + 1, - startColumn: start.character + 1, - endLineNumber: end.line + 1, - endColumn: end.character + 1, - }, - text: edit.text, - }, - versionId: undefined, - }; - } - }); + const workspaceEdits = codeFixEditsToMonacoEdits(model, edits); if (workspaceEdits.length > 0) { actions.push({ @@ -449,6 +437,22 @@ export async function registerMonacoLanguage(host: BrowserHost) { edit: { edits: workspaceEdits }, }); } + + // Add "Fix all" if the same fix id appears more than once in the file. + const fixes = fixIdToFixes.get(fix.id); + if (fixes && fixes.length > 1 && !fixAllAdded.has(fix.id)) { + fixAllAdded.add(fix.id); + const allEdits = ( + await Promise.all(fixes.map((f) => compiler.resolveCodeFix(f))) + ).flatMap((e) => codeFixEditsToMonacoEdits(model, e)); + if (allEdits.length > 0) { + actions.push({ + title: `Fix all: ${fix.label}`, + kind: "quickfix", + edit: { edits: allEdits }, + }); + } + } } } return { actions, dispose: () => {} }; @@ -510,6 +514,47 @@ export async function registerMonacoLanguage(host: BrowserHost) { }); } +function codeFixEditsToMonacoEdits( + model: monaco.editor.ITextModel, + edits: CodeFixEdit[], +): monaco.languages.IWorkspaceTextEdit[] { + return edits + .filter((edit) => edit.file.path === "/test/main.tsp") + .map((edit) => { + const start = edit.file.getLineAndCharacterOfPosition(edit.pos); + if (edit.kind === "insert-text") { + return { + resource: model.uri, + textEdit: { + range: { + startLineNumber: start.line + 1, + startColumn: start.character + 1, + endLineNumber: start.line + 1, + endColumn: start.character + 1, + }, + text: edit.text, + }, + versionId: undefined, + }; + } else { + const end = edit.file.getLineAndCharacterOfPosition(edit.end); + return { + resource: model.uri, + textEdit: { + range: { + startLineNumber: start.line + 1, + startColumn: start.character + 1, + endLineNumber: end.line + 1, + endColumn: end.character + 1, + }, + text: edit.text, + }, + versionId: undefined, + }; + } + }); +} + export function getMonacoRange( typespecCompiler: typeof import("@typespec/compiler"), target: DiagnosticTarget | typeof NoTarget,