diff --git a/packages/opencode/src/format/formatter.ts b/packages/opencode/src/format/formatter.ts index d26a0f9943..9051cecde2 100644 --- a/packages/opencode/src/format/formatter.ts +++ b/packages/opencode/src/format/formatter.ts @@ -4,37 +4,39 @@ import { Filesystem } from "../util/filesystem" import { Process } from "../util/process" import { which } from "../util/which" import { Flag } from "@/flag/flag" -import { Npm } from "@/npm" export interface Info { name: string + command: string[] environment?: Record extensions: string[] - enabled(): Promise + enabled(): Promise } export const gofmt: Info = { name: "gofmt", + command: ["gofmt", "-w", "$FILE"], extensions: [".go"], async enabled() { - const p = which("gofmt") - if (p === null) return false - return [p, "-w", "$FILE"] + return which("gofmt") !== null }, } export const mix: Info = { name: "mix", + command: ["mix", "format", "$FILE"], extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"], async enabled() { - const p = which("mix") - if (p === null) return false - return [p, "format", "$FILE"] + return which("mix") !== null }, } export const prettier: Info = { name: "prettier", + command: ["bun", "x", "prettier", "--write", "$FILE"], + environment: { + BUN_BE_BUN: "1", + }, extensions: [ ".js", ".jsx", @@ -70,11 +72,8 @@ export const prettier: Info = { dependencies?: Record devDependencies?: Record }>(item) - if (json.dependencies?.prettier || json.devDependencies?.prettier) { - const bin = await Npm.which("prettier").catch(() => null) - if (!bin) return false - return [bin, "--write", "$FILE"] - } + if (json.dependencies?.prettier) return true + if (json.devDependencies?.prettier) return true } return false }, @@ -82,6 +81,10 @@ export const prettier: Info = { export const oxfmt: Info = { name: "oxfmt", + command: ["bun", "x", "oxfmt", "$FILE"], + environment: { + BUN_BE_BUN: "1", + }, extensions: [".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts"], async enabled() { if (!Flag.OPENCODE_EXPERIMENTAL_OXFMT) return false @@ -91,11 +94,8 @@ export const oxfmt: Info = { dependencies?: Record devDependencies?: Record }>(item) - if (json.dependencies?.oxfmt || json.devDependencies?.oxfmt) { - const bin = await Npm.which("oxfmt") - if (!bin) return false - return [bin, "$FILE"] - } + if (json.dependencies?.oxfmt) return true + if (json.devDependencies?.oxfmt) return true } return false }, @@ -103,6 +103,10 @@ export const oxfmt: Info = { export const biome: Info = { name: "biome", + command: ["bun", "x", "@biomejs/biome", "check", "--write", "$FILE"], + environment: { + BUN_BE_BUN: "1", + }, extensions: [ ".js", ".jsx", @@ -136,9 +140,7 @@ export const biome: Info = { for (const config of configs) { const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree) if (found.length > 0) { - const bin = await Npm.which("@biomejs/biome") - if (!bin) return false - return [bin, "check", "--write", "$FILE"] + return true } } return false @@ -147,49 +149,47 @@ export const biome: Info = { export const zig: Info = { name: "zig", + command: ["zig", "fmt", "$FILE"], extensions: [".zig", ".zon"], async enabled() { - const p = which("zig") - if (p === null) return false - return [p, "fmt", "$FILE"] + return which("zig") !== null }, } export const clang: Info = { name: "clang-format", + command: ["clang-format", "-i", "$FILE"], extensions: [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"], async enabled() { const items = await Filesystem.findUp(".clang-format", Instance.directory, Instance.worktree) - if (items.length === 0) return false - return ["clang-format", "-i", "$FILE"] + return items.length > 0 }, } export const ktlint: Info = { name: "ktlint", + command: ["ktlint", "-F", "$FILE"], extensions: [".kt", ".kts"], async enabled() { - const p = which("ktlint") - if (p === null) return false - return [p, "-F", "$FILE"] + return which("ktlint") !== null }, } export const ruff: Info = { name: "ruff", + command: ["ruff", "format", "$FILE"], extensions: [".py", ".pyi"], async enabled() { - const p = which("ruff") - if (p === null) return false + if (!which("ruff")) return false const configs = ["pyproject.toml", "ruff.toml", ".ruff.toml"] for (const config of configs) { const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree) if (found.length > 0) { if (config === "pyproject.toml") { const content = await Filesystem.readText(found[0]) - if (content.includes("[tool.ruff]")) return [p, "format", "$FILE"] + if (content.includes("[tool.ruff]")) return true } else { - return [p, "format", "$FILE"] + return true } } } @@ -198,7 +198,7 @@ export const ruff: Info = { const found = await Filesystem.findUp(dep, Instance.directory, Instance.worktree) if (found.length > 0) { const content = await Filesystem.readText(found[0]) - if (content.includes("ruff")) return [p, "format", "$FILE"] + if (content.includes("ruff")) return true } } return false @@ -207,13 +207,14 @@ export const ruff: Info = { export const rlang: Info = { name: "air", + command: ["air", "format", "$FILE"], extensions: [".R"], async enabled() { const airPath = which("air") if (airPath == null) return false try { - const proc = Process.spawn([airPath, "--help"], { + const proc = Process.spawn(["air", "--help"], { stdout: "pipe", stderr: "pipe", }) @@ -225,10 +226,7 @@ export const rlang: Info = { const firstLine = output.split("\n")[0] const hasR = firstLine.includes("R language") const hasFormatter = firstLine.includes("formatter") - if (hasR && hasFormatter) { - return [airPath, "format", "$FILE"] - } - return false + return hasR && hasFormatter } catch (error) { return false } @@ -237,14 +235,14 @@ export const rlang: Info = { export const uvformat: Info = { name: "uv", + command: ["uv", "format", "--", "$FILE"], extensions: [".py", ".pyi"], async enabled() { if (await ruff.enabled()) return false - const uvPath = which("uv") - if (uvPath !== null) { - const proc = Process.spawn([uvPath, "format", "--help"], { stderr: "pipe", stdout: "pipe" }) + if (which("uv") !== null) { + const proc = Process.spawn(["uv", "format", "--help"], { stderr: "pipe", stdout: "pipe" }) const code = await proc.exited - if (code === 0) return [uvPath, "format", "--", "$FILE"] + return code === 0 } return false }, @@ -252,118 +250,108 @@ export const uvformat: Info = { export const rubocop: Info = { name: "rubocop", + command: ["rubocop", "--autocorrect", "$FILE"], extensions: [".rb", ".rake", ".gemspec", ".ru"], async enabled() { - const path = which("rubocop") - if (path === null) return false - return [path, "--autocorrect", "$FILE"] + return which("rubocop") !== null }, } export const standardrb: Info = { name: "standardrb", + command: ["standardrb", "--fix", "$FILE"], extensions: [".rb", ".rake", ".gemspec", ".ru"], async enabled() { - const path = which("standardrb") - if (path === null) return false - return [path, "--fix", "$FILE"] + return which("standardrb") !== null }, } export const htmlbeautifier: Info = { name: "htmlbeautifier", + command: ["htmlbeautifier", "$FILE"], extensions: [".erb", ".html.erb"], async enabled() { - const path = which("htmlbeautifier") - if (path === null) return false - return [path, "$FILE"] + return which("htmlbeautifier") !== null }, } export const dart: Info = { name: "dart", + command: ["dart", "format", "$FILE"], extensions: [".dart"], async enabled() { - const path = which("dart") - if (path === null) return false - return [path, "format", "$FILE"] + return which("dart") !== null }, } export const ocamlformat: Info = { name: "ocamlformat", + command: ["ocamlformat", "-i", "$FILE"], extensions: [".ml", ".mli"], async enabled() { - const path = which("ocamlformat") - if (!path) return false + if (!which("ocamlformat")) return false const items = await Filesystem.findUp(".ocamlformat", Instance.directory, Instance.worktree) - if (items.length === 0) return false - return [path, "-i", "$FILE"] + return items.length > 0 }, } export const terraform: Info = { name: "terraform", + command: ["terraform", "fmt", "$FILE"], extensions: [".tf", ".tfvars"], async enabled() { - const path = which("terraform") - if (path === null) return false - return [path, "fmt", "$FILE"] + return which("terraform") !== null }, } export const latexindent: Info = { name: "latexindent", + command: ["latexindent", "-w", "-s", "$FILE"], extensions: [".tex"], async enabled() { - const path = which("latexindent") - if (path === null) return false - return [path, "-w", "-s", "$FILE"] + return which("latexindent") !== null }, } export const gleam: Info = { name: "gleam", + command: ["gleam", "format", "$FILE"], extensions: [".gleam"], async enabled() { - const path = which("gleam") - if (path === null) return false - return [path, "format", "$FILE"] + return which("gleam") !== null }, } export const shfmt: Info = { name: "shfmt", + command: ["shfmt", "-w", "$FILE"], extensions: [".sh", ".bash"], async enabled() { - const path = which("shfmt") - if (path === null) return false - return [path, "-w", "$FILE"] + return which("shfmt") !== null }, } export const nixfmt: Info = { name: "nixfmt", + command: ["nixfmt", "$FILE"], extensions: [".nix"], async enabled() { - const path = which("nixfmt") - if (path === null) return false - return [path, "$FILE"] + return which("nixfmt") !== null }, } export const rustfmt: Info = { name: "rustfmt", + command: ["rustfmt", "$FILE"], extensions: [".rs"], async enabled() { - const path = which("rustfmt") - if (path === null) return false - return [path, "$FILE"] + return which("rustfmt") !== null }, } export const pint: Info = { name: "pint", + command: ["./vendor/bin/pint", "$FILE"], extensions: [".php"], async enabled() { const items = await Filesystem.findUp("composer.json", Instance.directory, Instance.worktree) @@ -372,9 +360,8 @@ export const pint: Info = { require?: Record "require-dev"?: Record }>(item) - if (json.require?.["laravel/pint"] || json["require-dev"]?.["laravel/pint"]) { - return ["./vendor/bin/pint", "$FILE"] - } + if (json.require?.["laravel/pint"]) return true + if (json["require-dev"]?.["laravel/pint"]) return true } return false }, @@ -382,30 +369,27 @@ export const pint: Info = { export const ormolu: Info = { name: "ormolu", + command: ["ormolu", "-i", "$FILE"], extensions: [".hs"], async enabled() { - const path = which("ormolu") - if (path === null) return false - return [path, "-i", "$FILE"] + return which("ormolu") !== null }, } export const cljfmt: Info = { name: "cljfmt", + command: ["cljfmt", "fix", "--quiet", "$FILE"], extensions: [".clj", ".cljs", ".cljc", ".edn"], async enabled() { - const path = which("cljfmt") - if (path === null) return false - return [path, "fix", "--quiet", "$FILE"] + return which("cljfmt") !== null }, } export const dfmt: Info = { name: "dfmt", + command: ["dfmt", "-i", "$FILE"], extensions: [".d"], async enabled() { - const path = which("dfmt") - if (path === null) return false - return [path, "-i", "$FILE"] + return which("dfmt") !== null }, } diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index 92e852f42f..795364be1c 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -41,7 +41,7 @@ export namespace Format { const state = yield* InstanceState.make( Effect.fn("Format.state")(function* (_ctx) { - const enabled: Record = {} + const enabled: Record = {} const formatters: Record = {} const cfg = yield* config.get() @@ -66,19 +66,20 @@ export namespace Format { formatters[name] = { ...info, name, - enabled: async () => info.command, + enabled: async () => true, } } } else { log.info("all formatters are disabled") } - if (info.command.length === 0) continue - - formatters[name] = { - ...info, - name, - enabled: async (): Promise => info.command, + async function isEnabled(item: Formatter.Info) { + let status = enabled[item.name] + if (status === undefined) { + status = await item.enabled() + enabled[item.name] = status + } + return status } async function getFormatter(ext: string) { @@ -86,27 +87,17 @@ export namespace Format { const checks = await Promise.all( matching.map(async (item) => { log.info("checking", { name: item.name, ext }) - const cmd = await isEnabled(item) - if (cmd) { + const on = await isEnabled(item) + if (on) { log.info("enabled", { name: item.name, ext }) } - return { item, cmd } + return { + item, + enabled: on, + } }), ) - const result: Array<{ - name: string - command: string[] - environment?: Record - }> = [] - for (const { item, cmd } of checks) { - if (cmd !== false) - result.push({ - name: item.name, - command: cmd, - environment: item.environment, - }) - } - return result + return checks.filter((x) => x.enabled).map((x) => x.item) } function formatFile(filepath: string) { @@ -173,7 +164,7 @@ export namespace Format { result.push({ name: formatter.name, extensions: formatter.extensions, - enabled: !!isOn, + enabled: isOn, }) } return result diff --git a/packages/opencode/test/format/format.test.ts b/packages/opencode/test/format/format.test.ts index 0e840fef3a..89a8c1f450 100644 --- a/packages/opencode/test/format/format.test.ts +++ b/packages/opencode/test/format/format.test.ts @@ -66,29 +66,12 @@ describe("Format", () => { it.live("service initializes without error", () => provideTmpdirInstance(() => Format.Service.use(() => Effect.void))) - test("status() includes custom formatters with command from config", async () => { - await using tmp = await tmpdir({ - config: { - formatter: { - customtool: { - command: ["echo", "formatted", "$FILE"], - extensions: [".custom"], - }, - }, - }, - }) - - await withServices(tmp.path, Format.layer, async (rt) => { - const statuses = await rt.runPromise(Format.Service.use((s) => s.status())) - const custom = statuses.find((s) => s.name === "customtool") - expect(custom).toBeDefined() - expect(custom!.extensions).toContain(".custom") - expect(custom!.enabled).toBe(true) - }) - }) - - test("service initializes without error", async () => { - await using tmp = await tmpdir() + it.live("status() initializes formatter state per directory", () => + Effect.gen(function* () { + const a = yield* provideTmpdirInstance(() => Format.Service.use((fmt) => fmt.status()), { + config: { formatter: false }, + }) + const b = yield* provideTmpdirInstance(() => Format.Service.use((fmt) => fmt.status())) expect(a).toEqual([]) expect(b.length).toBeGreaterThan(0) @@ -104,10 +87,12 @@ describe("Format", () => { const one = { extensions: Formatter.gofmt.extensions, enabled: Formatter.gofmt.enabled, + command: Formatter.gofmt.command, } const two = { extensions: Formatter.mix.extensions, enabled: Formatter.mix.enabled, + command: Formatter.mix.command, } let active = 0 @@ -117,19 +102,21 @@ describe("Format", () => { Effect.sync(() => { Formatter.gofmt.extensions = [".parallel"] Formatter.mix.extensions = [".parallel"] + Formatter.gofmt.command = ["sh", "-c", "true"] + Formatter.mix.command = ["sh", "-c", "true"] Formatter.gofmt.enabled = async () => { active++ max = Math.max(max, active) await Bun.sleep(20) active-- - return ["sh", "-c", "true"] + return true } Formatter.mix.enabled = async () => { active++ max = Math.max(max, active) await Bun.sleep(20) active-- - return ["sh", "-c", "true"] + return true } }), () => @@ -143,8 +130,10 @@ describe("Format", () => { Effect.sync(() => { Formatter.gofmt.extensions = one.extensions Formatter.gofmt.enabled = one.enabled + Formatter.gofmt.command = one.command Formatter.mix.extensions = two.extensions Formatter.mix.enabled = two.enabled + Formatter.mix.command = two.command }), )