fix: add tests and fix typing for formatter trigger (#2768)
This commit is contained in:
@@ -7,6 +7,7 @@ import { applyMcpConfig } from "./mcp-config-handler";
|
|||||||
import { applyProviderConfig } from "./provider-config-handler";
|
import { applyProviderConfig } from "./provider-config-handler";
|
||||||
import { loadPluginComponents } from "./plugin-components-loader";
|
import { loadPluginComponents } from "./plugin-components-loader";
|
||||||
import { applyToolConfig } from "./tool-config-handler";
|
import { applyToolConfig } from "./tool-config-handler";
|
||||||
|
import { clearFormatterCache } from "../tools/hashline-edit/formatter-trigger"
|
||||||
|
|
||||||
export { resolveCategoryConfig } from "./category-config-resolver";
|
export { resolveCategoryConfig } from "./category-config-resolver";
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
|||||||
const formatterConfig = config.formatter;
|
const formatterConfig = config.formatter;
|
||||||
|
|
||||||
applyProviderConfig({ config, modelCacheState });
|
applyProviderConfig({ config, modelCacheState });
|
||||||
|
clearFormatterCache()
|
||||||
|
|
||||||
const pluginComponents = await loadPluginComponents({ pluginConfig });
|
const pluginComponents = await loadPluginComponents({ pluginConfig });
|
||||||
|
|
||||||
|
|||||||
@@ -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<string, unknown> = {}): 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -19,7 +19,7 @@ interface OpencodeConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OpencodeClient {
|
export interface FormatterClient {
|
||||||
config: {
|
config: {
|
||||||
get: (options?: { query?: { directory?: string } }) => Promise<{ data?: OpencodeConfig }>
|
get: (options?: { query?: { directory?: string } }) => Promise<{ data?: OpencodeConfig }>
|
||||||
}
|
}
|
||||||
@@ -27,8 +27,8 @@ interface OpencodeClient {
|
|||||||
|
|
||||||
let cachedFormatters: Map<string, Array<{ command: string[]; environment: Record<string, string> }>> | null = null
|
let cachedFormatters: Map<string, Array<{ command: string[]; environment: Record<string, string> }>> | null = null
|
||||||
|
|
||||||
async function resolveFormatters(
|
export async function resolveFormatters(
|
||||||
client: OpencodeClient,
|
client: FormatterClient,
|
||||||
directory: string,
|
directory: string,
|
||||||
): Promise<Map<string, Array<{ command: string[]; environment: Record<string, string> }>>> {
|
): Promise<Map<string, Array<{ command: string[]; environment: Record<string, string> }>>> {
|
||||||
if (cachedFormatters) return cachedFormatters
|
if (cachedFormatters) return cachedFormatters
|
||||||
@@ -76,12 +76,12 @@ async function resolveFormatters(
|
|||||||
return result
|
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))
|
return command.map((arg) => arg.replace(/\$FILE/g, filePath))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runFormattersForFile(
|
export async function runFormattersForFile(
|
||||||
client: OpencodeClient,
|
client: FormatterClient,
|
||||||
directory: string,
|
directory: string,
|
||||||
filePath: string,
|
filePath: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { canonicalizeFileText, restoreFileText } from "./file-text-canonicalizat
|
|||||||
import { normalizeHashlineEdits, type RawHashlineEdit } from "./normalize-edits"
|
import { normalizeHashlineEdits, type RawHashlineEdit } from "./normalize-edits"
|
||||||
import type { HashlineEdit } from "./types"
|
import type { HashlineEdit } from "./types"
|
||||||
import { HashlineMismatchError } from "./validation"
|
import { HashlineMismatchError } from "./validation"
|
||||||
import { runFormattersForFile } from "./formatter-trigger"
|
import { runFormattersForFile, type FormatterClient } from "./formatter-trigger"
|
||||||
import type { PluginContext } from "../../plugin/types"
|
import type { PluginContext } from "../../plugin/types"
|
||||||
|
|
||||||
interface HashlineEditArgs {
|
interface HashlineEditArgs {
|
||||||
@@ -132,7 +132,7 @@ export async function executeHashlineEditTool(args: HashlineEditArgs, context: T
|
|||||||
await Bun.write(filePath, writeContent)
|
await Bun.write(filePath, writeContent)
|
||||||
|
|
||||||
if (pluginCtx?.client) {
|
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")
|
const formattedContent = Buffer.from(await Bun.file(filePath).arrayBuffer()).toString("utf8")
|
||||||
if (formattedContent !== writeContent) {
|
if (formattedContent !== writeContent) {
|
||||||
const formattedEnvelope = canonicalizeFileText(formattedContent)
|
const formattedEnvelope = canonicalizeFileText(formattedContent)
|
||||||
|
|||||||
Reference in New Issue
Block a user