diff --git a/src/plugin-handlers/config-handler.ts b/src/plugin-handlers/config-handler.ts index 47050300f..b4836bd08 100644 --- a/src/plugin-handlers/config-handler.ts +++ b/src/plugin-handlers/config-handler.ts @@ -7,6 +7,7 @@ import { applyMcpConfig } from "./mcp-config-handler"; import { applyProviderConfig } from "./provider-config-handler"; import { loadPluginComponents } from "./plugin-components-loader"; import { applyToolConfig } from "./tool-config-handler"; +import { clearFormatterCache } from "../tools/hashline-edit/formatter-trigger" export { resolveCategoryConfig } from "./category-config-resolver"; @@ -23,6 +24,7 @@ export function createConfigHandler(deps: ConfigHandlerDeps) { const formatterConfig = config.formatter; applyProviderConfig({ config, modelCacheState }); + clearFormatterCache() const pluginComponents = await loadPluginComponents({ pluginConfig }); diff --git a/src/tools/hashline-edit/formatter-trigger.test.ts b/src/tools/hashline-edit/formatter-trigger.test.ts new file mode 100644 index 000000000..c631ae079 --- /dev/null +++ b/src/tools/hashline-edit/formatter-trigger.test.ts @@ -0,0 +1,376 @@ +import { describe, it, expect, beforeEach, mock } from "bun:test" +import { + runFormattersForFile, + clearFormatterCache, + resolveFormatters, + buildFormatterCommand, + type FormatterClient, +} from "./formatter-trigger" + +function createMockClient(config: Record = {}): FormatterClient { + return { + config: { + get: mock(() => Promise.resolve({ data: config })), + }, + } +} + +describe("buildFormatterCommand", () => { + it("substitutes $FILE with the actual file path", () => { + //#given + const command = ["prettier", "--write", "$FILE"] + const filePath = "/src/index.ts" + + //#when + const result = buildFormatterCommand(command, filePath) + + //#then + expect(result).toEqual(["prettier", "--write", "/src/index.ts"]) + }) + + it("substitutes multiple $FILE occurrences in the same arg", () => { + //#given + const command = ["echo", "$FILE:$FILE"] + const filePath = "test.ts" + + //#when + const result = buildFormatterCommand(command, filePath) + + //#then + expect(result).toEqual(["echo", "test.ts:test.ts"]) + }) + + it("returns command unchanged when no $FILE present", () => { + //#given + const command = ["prettier", "--check", "."] + + //#when + const result = buildFormatterCommand(command, "/some/file.ts") + + //#then + expect(result).toEqual(["prettier", "--check", "."]) + }) +}) + +describe("resolveFormatters", () => { + beforeEach(() => { + clearFormatterCache() + }) + + it("resolves formatters from config.formatter section", async () => { + //#given + const client = createMockClient({ + formatter: { + prettier: { + command: ["prettier", "--write", "$FILE"], + extensions: [".ts", ".tsx"], + }, + }, + }) + + //#when + const result = await resolveFormatters(client, "/project") + + //#then + expect(result.get(".ts")).toEqual([{ command: ["prettier", "--write", "$FILE"], environment: {} }]) + expect(result.get(".tsx")).toEqual([{ command: ["prettier", "--write", "$FILE"], environment: {} }]) + }) + + it("resolves formatters from experimental.hook.file_edited section", async () => { + //#given + const client = createMockClient({ + experimental: { + hook: { + file_edited: { + ".go": [{ command: ["gofmt", "-w", "$FILE"], environment: { GOPATH: "/go" } }], + }, + }, + }, + }) + + //#when + const result = await resolveFormatters(client, "/project") + + //#then + expect(result.get(".go")).toEqual([{ command: ["gofmt", "-w", "$FILE"], environment: { GOPATH: "/go" } }]) + }) + + it("normalizes extensions without leading dot", async () => { + //#given + const client = createMockClient({ + formatter: { + biome: { + command: ["biome", "format", "$FILE"], + extensions: ["ts", "js"], + }, + }, + }) + + //#when + const result = await resolveFormatters(client, "/project") + + //#then + expect(result.has(".ts")).toBe(true) + expect(result.has(".js")).toBe(true) + }) + + it("skips disabled formatters", async () => { + //#given + const client = createMockClient({ + formatter: { + prettier: { + disabled: true, + command: ["prettier", "--write", "$FILE"], + extensions: [".ts"], + }, + }, + }) + + //#when + const result = await resolveFormatters(client, "/project") + + //#then + expect(result.size).toBe(0) + }) + + it("skips formatters without command", async () => { + //#given + const client = createMockClient({ + formatter: { + prettier: { + extensions: [".ts"], + }, + }, + }) + + //#when + const result = await resolveFormatters(client, "/project") + + //#then + expect(result.size).toBe(0) + }) + + it("skips formatters without extensions", async () => { + //#given + const client = createMockClient({ + formatter: { + prettier: { + command: ["prettier", "--write", "$FILE"], + }, + }, + }) + + //#when + const result = await resolveFormatters(client, "/project") + + //#then + expect(result.size).toBe(0) + }) + + it("returns cached result on subsequent calls", async () => { + //#given + const client = createMockClient({ + formatter: { + prettier: { + command: ["prettier", "--write", "$FILE"], + extensions: [".ts"], + }, + }, + }) + await resolveFormatters(client, "/project") + + //#when + const result = await resolveFormatters(client, "/project") + + //#then + expect(client.config.get).toHaveBeenCalledTimes(1) + expect(result.get(".ts")).toHaveLength(1) + }) + + it("returns fresh result after clearFormatterCache", async () => { + //#given + const client = createMockClient({ + formatter: { + prettier: { + command: ["prettier", "--write", "$FILE"], + extensions: [".ts"], + }, + }, + }) + await resolveFormatters(client, "/project") + clearFormatterCache() + + //#when + await resolveFormatters(client, "/project") + + //#then + expect(client.config.get).toHaveBeenCalledTimes(2) + }) + + it("handles config.get failure gracefully", async () => { + //#given + const client: FormatterClient = { + config: { + get: mock(() => Promise.reject(new Error("network error"))), + }, + } + + //#when + const result = await resolveFormatters(client, "/project") + + //#then + expect(result.size).toBe(0) + }) + + it("handles missing config data", async () => { + //#given + const client: FormatterClient = { + config: { + get: mock(() => Promise.resolve({ data: undefined })), + }, + } + + //#when + const result = await resolveFormatters(client, "/project") + + //#then + expect(result.size).toBe(0) + }) + + it("merges formatter and experimental.hook.file_edited for same extension", async () => { + //#given + const client = createMockClient({ + formatter: { + prettier: { + command: ["prettier", "--write", "$FILE"], + extensions: [".ts"], + }, + }, + experimental: { + hook: { + file_edited: { + ".ts": [{ command: ["eslint", "--fix", "$FILE"] }], + }, + }, + }, + }) + + //#when + const result = await resolveFormatters(client, "/project") + + //#then + expect(result.get(".ts")).toHaveLength(2) + expect(result.get(".ts")![0].command).toEqual(["prettier", "--write", "$FILE"]) + expect(result.get(".ts")![1].command).toEqual(["eslint", "--fix", "$FILE"]) + }) + + it("defaults environment to empty object when not specified", async () => { + //#given + const client = createMockClient({ + experimental: { + hook: { + file_edited: { + ".py": [{ command: ["black", "$FILE"] }], + }, + }, + }, + }) + + //#when + const result = await resolveFormatters(client, "/project") + + //#then + expect(result.get(".py")![0].environment).toEqual({}) + }) + + it("preserves environment from formatter config", async () => { + //#given + const client = createMockClient({ + formatter: { + biome: { + command: ["biome", "format", "$FILE"], + extensions: [".ts"], + environment: { BIOME_LOG: "debug" }, + }, + }, + }) + + //#when + const result = await resolveFormatters(client, "/project") + + //#then + expect(result.get(".ts")![0].environment).toEqual({ BIOME_LOG: "debug" }) + }) + + it("skips formatter=false config", async () => { + //#given + const client = createMockClient({ + formatter: false, + }) + + //#when + const result = await resolveFormatters(client, "/project") + + //#then + expect(result.size).toBe(0) + }) +}) + +describe("runFormattersForFile", () => { + beforeEach(() => { + clearFormatterCache() + }) + + it("skips files without extensions", async () => { + //#given + const client = createMockClient({ + formatter: { + prettier: { + command: ["prettier", "--write", "$FILE"], + extensions: [".ts"], + }, + }, + }) + + //#when + await runFormattersForFile(client, "/project", "Makefile") + + //#then + expect(client.config.get).not.toHaveBeenCalled() + }) + + it("skips when no matching formatters for extension", async () => { + //#given + const client = createMockClient({ + formatter: { + prettier: { + command: ["prettier", "--write", "$FILE"], + extensions: [".ts"], + }, + }, + }) + + //#when — run for a .go file, but only .ts formatters registered + await runFormattersForFile(client, "/project", "/src/main.go") + + //#then — no error thrown + }) + + it("runs formatter for matching extension", async () => { + //#given + const client = createMockClient({ + formatter: { + echo: { + command: ["echo", "$FILE"], + extensions: [".ts"], + }, + }, + }) + + //#when — echo is a safe no-op command + await runFormattersForFile(client, "/tmp", "/tmp/test.ts") + + //#then — should complete without error + expect(client.config.get).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/tools/hashline-edit/formatter-trigger.ts b/src/tools/hashline-edit/formatter-trigger.ts index 1f9a6d6bc..98733c182 100644 --- a/src/tools/hashline-edit/formatter-trigger.ts +++ b/src/tools/hashline-edit/formatter-trigger.ts @@ -19,7 +19,7 @@ interface OpencodeConfig { } } -interface OpencodeClient { +export interface FormatterClient { config: { get: (options?: { query?: { directory?: string } }) => Promise<{ data?: OpencodeConfig }> } @@ -27,8 +27,8 @@ interface OpencodeClient { let cachedFormatters: Map }>> | null = null -async function resolveFormatters( - client: OpencodeClient, +export async function resolveFormatters( + client: FormatterClient, directory: string, ): Promise }>>> { if (cachedFormatters) return cachedFormatters @@ -76,12 +76,12 @@ async function resolveFormatters( return result } -function buildFormatterCommand(command: string[], filePath: string): string[] { +export function buildFormatterCommand(command: string[], filePath: string): string[] { return command.map((arg) => arg.replace(/\$FILE/g, filePath)) } export async function runFormattersForFile( - client: OpencodeClient, + client: FormatterClient, directory: string, filePath: string, ): Promise { diff --git a/src/tools/hashline-edit/hashline-edit-executor.ts b/src/tools/hashline-edit/hashline-edit-executor.ts index f3ea00aa6..b9412d89e 100644 --- a/src/tools/hashline-edit/hashline-edit-executor.ts +++ b/src/tools/hashline-edit/hashline-edit-executor.ts @@ -6,7 +6,7 @@ import { canonicalizeFileText, restoreFileText } from "./file-text-canonicalizat import { normalizeHashlineEdits, type RawHashlineEdit } from "./normalize-edits" import type { HashlineEdit } from "./types" import { HashlineMismatchError } from "./validation" -import { runFormattersForFile } from "./formatter-trigger" +import { runFormattersForFile, type FormatterClient } from "./formatter-trigger" import type { PluginContext } from "../../plugin/types" interface HashlineEditArgs { @@ -132,7 +132,7 @@ export async function executeHashlineEditTool(args: HashlineEditArgs, context: T await Bun.write(filePath, writeContent) if (pluginCtx?.client) { - await runFormattersForFile(pluginCtx.client as any, context.directory, filePath) + await runFormattersForFile(pluginCtx.client as FormatterClient, context.directory, filePath) const formattedContent = Buffer.from(await Bun.file(filePath).arrayBuffer()).toString("utf8") if (formattedContent !== writeContent) { const formattedEnvelope = canonicalizeFileText(formattedContent)