From 94795d36a3d8501b2785dafe48ddd39de6207b68 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:31:02 +0000 Subject: [PATCH 1/6] Initial plan From 445e44281503c28bb54a1257dc3861cedca7355c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:45:58 +0000 Subject: [PATCH 2/6] feat: add Fix all code action for codefixes that appear multiple times in a file Co-authored-by: timotheeguerin <1031227+timotheeguerin@users.noreply.github.com> --- .chronus/changes/codefix-fix-all-2026-7-30.md | 7 ++ packages/compiler/src/server/serverlib.ts | 88 +++++++++++--- .../compiler/test/server/code-actions.test.ts | 113 ++++++++++++++++++ 3 files changed, 193 insertions(+), 15 deletions(-) create mode 100644 .chronus/changes/codefix-fix-all-2026-7-30.md create mode 100644 packages/compiler/test/server/code-actions.test.ts 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/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..0b92a6e144c --- /dev/null +++ b/packages/compiler/test/server/code-actions.test.ts @@ -0,0 +1,113 @@ +import { ok, strictEqual } from "assert"; +import { describe, it } from "vitest"; +import { CodeActionKind, Range } from "vscode-languageserver"; +import { createTestServerHost } from "../../src/testing/test-server-host.js"; + +describe("getCodeActions", () => { + describe("Fix all: X actions", () => { + it("shows 'Fix all: X' action when same codefix appears multiple times in the file", async () => { + const testHost = await createTestServerHost(); + testHost.addOrUpdateDocument("./sub.tsp", "namespace Foo; model FooModel {};"); + testHost.addOrUpdateDocument("./sub2.tsp", "namespace Bar; model BarModel {};"); + const mainFile = testHost.addOrUpdateDocument( + "./main.tsp", + 'import "./sub.tsp";\nimport "./sub2.tsp";\nusing Foo;\nusing Bar;', + ); + + await testHost.server.compile(mainFile, undefined, { mode: "full" }); + const diags = testHost.getDiagnostics("main.tsp"); + + // Both "using Foo" and "using Bar" are unused → 2 diagnostics with same codefix id + ok(diags.length >= 2, `Expected at least 2 diagnostics, got ${diags.length}`); + + const actions = await testHost.server.getCodeActions({ + textDocument: { uri: testHost.getURL("main.tsp") }, + range: Range.create(0, 0, 0, 0), + context: { diagnostics: [diags[0]] }, + }); + + // Should include an individual fix + ok( + actions.some((a) => a.kind === CodeActionKind.QuickFix && !a.data?.fixAllInFile), + "Expected an individual QuickFix action", + ); + + // Should include a "Fix all: Remove unused code" action + const fixAllAction = actions.find( + (a) => a.kind === CodeActionKind.QuickFix && a.data?.fixAllInFile !== undefined, + ); + ok(fixAllAction, "Expected a 'Fix all' code action"); + strictEqual(fixAllAction.title, "Fix all: Remove unused code"); + }); + + it("does NOT show 'Fix all: X' action when codefix only appears once in the file", async () => { + const testHost = await createTestServerHost(); + testHost.addOrUpdateDocument("./sub.tsp", "namespace Foo; model FooModel {};"); + const mainFile = testHost.addOrUpdateDocument( + "./main.tsp", + 'import "./sub.tsp";\nusing Foo;', + ); + + await testHost.server.compile(mainFile, undefined, { mode: "full" }); + const diags = testHost.getDiagnostics("main.tsp"); + + ok(diags.length >= 1, `Expected at least 1 diagnostic, got ${diags.length}`); + + const actions = await testHost.server.getCodeActions({ + textDocument: { uri: testHost.getURL("main.tsp") }, + range: Range.create(0, 0, 0, 0), + context: { diagnostics: [diags[0]] }, + }); + + // Should include individual fix but NOT "Fix all" + ok( + actions.some((a) => a.kind === CodeActionKind.QuickFix), + "Expected a QuickFix action", + ); + ok( + !actions.some((a) => a.data?.fixAllInFile !== undefined), + "Should NOT have a 'Fix all' action when codefix only appears once", + ); + }); + }); + + describe("resolveCodeAction for Fix all", () => { + it("resolves 'Fix all: X' action applying all instances in the file", async () => { + const testHost = await createTestServerHost(); + testHost.addOrUpdateDocument("./sub.tsp", "namespace Foo; model FooModel {};"); + testHost.addOrUpdateDocument("./sub2.tsp", "namespace Bar; model BarModel {};"); + const mainFile = testHost.addOrUpdateDocument( + "./main.tsp", + 'import "./sub.tsp";\nimport "./sub2.tsp";\nusing Foo;\nusing Bar;', + ); + + await testHost.server.compile(mainFile, undefined, { mode: "full" }); + const diags = testHost.getDiagnostics("main.tsp"); + + ok(diags.length >= 2, `Expected at least 2 diagnostics, got ${diags.length}`); + + const actions = await testHost.server.getCodeActions({ + textDocument: { uri: testHost.getURL("main.tsp") }, + range: Range.create(0, 0, 0, 0), + context: { diagnostics: [diags[0]] }, + }); + + const fixAllAction = actions.find((a) => a.data?.fixAllInFile !== undefined); + ok(fixAllAction, "Expected a 'Fix all' code action"); + + const resolved = await testHost.server.resolveCodeAction(fixAllAction); + ok(resolved.edit, "Expected code action edit to be populated"); + ok( + resolved.edit.documentChanges && resolved.edit.documentChanges.length > 0, + "Expected document changes", + ); + + // 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); + ok(totalEdits >= 2, `Expected at least 2 text edits, got ${totalEdits}`); + }); + }); +}); From fa4d25a293b4351bfcf12fc8a0eb6088f5263cfa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:33:48 +0000 Subject: [PATCH 3/6] feat: add Fix all code action to playground code action provider Co-authored-by: timotheeguerin <1031227+timotheeguerin@users.noreply.github.com> --- packages/playground/src/services.ts | 115 +++++++++++++++++++--------- 1 file changed, 80 insertions(+), 35 deletions(-) diff --git a/packages/playground/src/services.ts b/packages/playground/src/services.ts index a1c5c3fef6d..81f758f4d54 100644 --- a/packages/playground/src/services.ts +++ b/packages/playground/src/services.ts @@ -396,7 +396,70 @@ export async function registerMonacoLanguage(host: BrowserHost) { const compiler = _currentCompiler; if (!compiler) return { actions: [], dispose: () => {} }; + function codeFixEditsToWorkspaceEdits( + edits: Awaited>, + ): 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, + }; + } + }); + } + + // 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< + string, + NonNullable<(typeof _currentDiagnostics)[number]["codefixes"]>[number][] + >(); + 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 +469,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 = codeFixEditsToWorkspaceEdits(edits); if (workspaceEdits.length > 0) { actions.push({ @@ -449,6 +478,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(codeFixEditsToWorkspaceEdits); + if (allEdits.length > 0) { + actions.push({ + title: `Fix all: ${fix.label}`, + kind: "quickfix", + edit: { edits: allEdits }, + }); + } + } } } return { actions, dispose: () => {} }; From 7911f324bfe88010c9d38737030d6370c62cd18b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:51:39 +0000 Subject: [PATCH 4/6] fix: use exported CodeFix/CodeFixEdit types and move helper out of closure Co-authored-by: timotheeguerin <1031227+timotheeguerin@users.noreply.github.com> --- packages/playground/src/services.ts | 92 ++++++++++++++--------------- 1 file changed, 46 insertions(+), 46 deletions(-) diff --git a/packages/playground/src/services.ts b/packages/playground/src/services.ts index 81f758f4d54..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,52 +398,9 @@ export async function registerMonacoLanguage(host: BrowserHost) { const compiler = _currentCompiler; if (!compiler) return { actions: [], dispose: () => {} }; - function codeFixEditsToWorkspaceEdits( - edits: Awaited>, - ): 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, - }; - } - }); - } - // 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< - string, - NonNullable<(typeof _currentDiagnostics)[number]["codefixes"]>[number][] - >(); + const fixIdToFixes = new Map(); for (const diag of _currentDiagnostics) { if (!diag.codefixes?.length) continue; const loc = compiler.getSourceLocation(diag.target, { locateId: true }); @@ -469,7 +428,7 @@ export async function registerMonacoLanguage(host: BrowserHost) { for (const fix of diag.codefixes) { const edits = await compiler.resolveCodeFix(fix); - const workspaceEdits = codeFixEditsToWorkspaceEdits(edits); + const workspaceEdits = codeFixEditsToMonacoEdits(model, edits); if (workspaceEdits.length > 0) { actions.push({ @@ -485,7 +444,7 @@ export async function registerMonacoLanguage(host: BrowserHost) { fixAllAdded.add(fix.id); const allEdits = ( await Promise.all(fixes.map((f) => compiler.resolveCodeFix(f))) - ).flatMap(codeFixEditsToWorkspaceEdits); + ).flatMap((e) => codeFixEditsToMonacoEdits(model, e)); if (allEdits.length > 0) { actions.push({ title: `Fix all: ${fix.label}`, @@ -555,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, From a3ed918e2d13d83bf574502534458a3effa439ad Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 30 Jul 2026 09:25:12 -0400 Subject: [PATCH 5/6] cleanup tests --- .../compiler/test/server/code-actions.test.ts | 198 ++++++++---------- 1 file changed, 93 insertions(+), 105 deletions(-) diff --git a/packages/compiler/test/server/code-actions.test.ts b/packages/compiler/test/server/code-actions.test.ts index 0b92a6e144c..4c7c94f51c1 100644 --- a/packages/compiler/test/server/code-actions.test.ts +++ b/packages/compiler/test/server/code-actions.test.ts @@ -1,113 +1,101 @@ -import { ok, strictEqual } from "assert"; -import { describe, it } from "vitest"; -import { CodeActionKind, Range } from "vscode-languageserver"; -import { createTestServerHost } from "../../src/testing/test-server-host.js"; - -describe("getCodeActions", () => { - describe("Fix all: X actions", () => { - it("shows 'Fix all: X' action when same codefix appears multiple times in the file", async () => { - const testHost = await createTestServerHost(); - testHost.addOrUpdateDocument("./sub.tsp", "namespace Foo; model FooModel {};"); - testHost.addOrUpdateDocument("./sub2.tsp", "namespace Bar; model BarModel {};"); - const mainFile = testHost.addOrUpdateDocument( - "./main.tsp", - 'import "./sub.tsp";\nimport "./sub2.tsp";\nusing Foo;\nusing Bar;', - ); - - await testHost.server.compile(mainFile, undefined, { mode: "full" }); - const diags = testHost.getDiagnostics("main.tsp"); - - // Both "using Foo" and "using Bar" are unused → 2 diagnostics with same codefix id - ok(diags.length >= 2, `Expected at least 2 diagnostics, got ${diags.length}`); - - const actions = await testHost.server.getCodeActions({ - textDocument: { uri: testHost.getURL("main.tsp") }, - range: Range.create(0, 0, 0, 0), - context: { diagnostics: [diags[0]] }, - }); - - // Should include an individual fix - ok( - actions.some((a) => a.kind === CodeActionKind.QuickFix && !a.data?.fixAllInFile), - "Expected an individual QuickFix action", - ); - - // Should include a "Fix all: Remove unused code" action - const fixAllAction = actions.find( - (a) => a.kind === CodeActionKind.QuickFix && a.data?.fixAllInFile !== undefined, - ); - ok(fixAllAction, "Expected a 'Fix all' code action"); - strictEqual(fixAllAction.title, "Fix all: Remove unused code"); +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;', }); - it("does NOT show 'Fix all: X' action when codefix only appears once in the file", async () => { - const testHost = await createTestServerHost(); - testHost.addOrUpdateDocument("./sub.tsp", "namespace Foo; model FooModel {};"); - const mainFile = testHost.addOrUpdateDocument( - "./main.tsp", - 'import "./sub.tsp";\nusing Foo;', - ); - - await testHost.server.compile(mainFile, undefined, { mode: "full" }); - const diags = testHost.getDiagnostics("main.tsp"); - - ok(diags.length >= 1, `Expected at least 1 diagnostic, got ${diags.length}`); - - const actions = await testHost.server.getCodeActions({ - textDocument: { uri: testHost.getURL("main.tsp") }, - range: Range.create(0, 0, 0, 0), - context: { diagnostics: [diags[0]] }, - }); - - // Should include individual fix but NOT "Fix all" - ok( - actions.some((a) => a.kind === CodeActionKind.QuickFix), - "Expected a QuickFix action", - ); - ok( - !actions.some((a) => a.data?.fixAllInFile !== undefined), - "Should NOT have a 'Fix all' action when codefix only appears once", - ); + // 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 testHost = await createTestServerHost(); - testHost.addOrUpdateDocument("./sub.tsp", "namespace Foo; model FooModel {};"); - testHost.addOrUpdateDocument("./sub2.tsp", "namespace Bar; model BarModel {};"); - const mainFile = testHost.addOrUpdateDocument( - "./main.tsp", - 'import "./sub.tsp";\nimport "./sub2.tsp";\nusing Foo;\nusing Bar;', - ); - - await testHost.server.compile(mainFile, undefined, { mode: "full" }); - const diags = testHost.getDiagnostics("main.tsp"); - - ok(diags.length >= 2, `Expected at least 2 diagnostics, got ${diags.length}`); - - const actions = await testHost.server.getCodeActions({ - textDocument: { uri: testHost.getURL("main.tsp") }, - range: Range.create(0, 0, 0, 0), - context: { diagnostics: [diags[0]] }, - }); - - const fixAllAction = actions.find((a) => a.data?.fixAllInFile !== undefined); - ok(fixAllAction, "Expected a 'Fix all' code action"); - - const resolved = await testHost.server.resolveCodeAction(fixAllAction); - ok(resolved.edit, "Expected code action edit to be populated"); - ok( - resolved.edit.documentChanges && resolved.edit.documentChanges.length > 0, - "Expected document changes", - ); - - // 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); - ok(totalEdits >= 2, `Expected at least 2 text edits, got ${totalEdits}`); +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); }); }); From 7de17a1bd8d485e0f13a533bee707e593648c0b9 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 30 Jul 2026 09:34:41 -0400 Subject: [PATCH 6/6] add changelog for playground fix all code action --- .chronus/changes/codefix-fix-all-playground-2026-7-30.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .chronus/changes/codefix-fix-all-playground-2026-7-30.md 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.