diff --git a/packages/opencode/src/format/formatter.ts b/packages/opencode/src/format/formatter.ts index 51a54273fd..a4cecd4f71 100644 --- a/packages/opencode/src/format/formatter.ts +++ b/packages/opencode/src/format/formatter.ts @@ -1,4 +1,5 @@ import { text } from "node:stream/consumers" +import { Npm } from "@/npm" import { Instance } from "../project/instance" import { Filesystem } from "../util/filesystem" import { Process } from "../util/process" @@ -7,33 +8,33 @@ import { Flag } from "@/flag/flag" 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() { - return which("gofmt") !== null + const match = which("gofmt") + if (!match) return false + return [match, "-w", "$FILE"] }, } export const mix: Info = { name: "mix", - command: ["mix", "format", "$FILE"], extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"], async enabled() { - return which("mix") !== null + const match = which("mix") + if (!match) return false + return [match, "format", "$FILE"] }, } export const prettier: Info = { name: "prettier", - command: ["bun", "x", "prettier", "--write", "$FILE"], environment: { BUN_BE_BUN: "1", }, @@ -72,8 +73,10 @@ export const prettier: Info = { dependencies?: Record devDependencies?: Record }>(item) - if (json.dependencies?.prettier) return true - if (json.devDependencies?.prettier) return true + if (json.dependencies?.prettier || json.devDependencies?.prettier) { + const bin = await Npm.which("prettier") + if (bin) return [bin, "--write", "$FILE"] + } } return false }, @@ -81,7 +84,6 @@ export const prettier: Info = { export const oxfmt: Info = { name: "oxfmt", - command: ["bun", "x", "oxfmt", "$FILE"], environment: { BUN_BE_BUN: "1", }, @@ -94,8 +96,10 @@ export const oxfmt: Info = { dependencies?: Record devDependencies?: Record }>(item) - if (json.dependencies?.oxfmt) return true - if (json.devDependencies?.oxfmt) return true + if (json.dependencies?.oxfmt || json.devDependencies?.oxfmt) { + const bin = await Npm.which("oxfmt") + if (bin) return [bin, "$FILE"] + } } return false }, @@ -103,7 +107,6 @@ export const oxfmt: Info = { export const biome: Info = { name: "biome", - command: ["bun", "x", "@biomejs/biome", "format", "--write", "$FILE"], environment: { BUN_BE_BUN: "1", }, @@ -140,7 +143,8 @@ export const biome: Info = { for (const config of configs) { const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree) if (found.length > 0) { - return true + const bin = await Npm.which("@biomejs/biome") + if (bin) return [bin, "format", "--write", "$FILE"] } } return false @@ -149,35 +153,39 @@ export const biome: Info = { export const zig: Info = { name: "zig", - command: ["zig", "fmt", "$FILE"], extensions: [".zig", ".zon"], async enabled() { - return which("zig") !== null + const match = which("zig") + if (!match) return false + return [match, "fmt", "$FILE"] }, } 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) - return items.length > 0 + if (items.length > 0) { + const match = which("clang-format") + if (match) return [match, "-i", "$FILE"] + } + return false }, } export const ktlint: Info = { name: "ktlint", - command: ["ktlint", "-F", "$FILE"], extensions: [".kt", ".kts"], async enabled() { - return which("ktlint") !== null + const match = which("ktlint") + if (!match) return false + return [match, "-F", "$FILE"] }, } export const ruff: Info = { name: "ruff", - command: ["ruff", "format", "$FILE"], extensions: [".py", ".pyi"], async enabled() { if (!which("ruff")) return false @@ -187,9 +195,9 @@ export const ruff: Info = { if (found.length > 0) { if (config === "pyproject.toml") { const content = await Filesystem.readText(found[0]) - if (content.includes("[tool.ruff]")) return true + if (content.includes("[tool.ruff]")) return ["ruff", "format", "$FILE"] } else { - return true + return ["ruff", "format", "$FILE"] } } } @@ -198,7 +206,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 true + if (content.includes("ruff")) return ["ruff", "format", "$FILE"] } } return false @@ -207,7 +215,6 @@ export const ruff: Info = { export const rlang: Info = { name: "air", - command: ["air", "format", "$FILE"], extensions: [".R"], async enabled() { const airPath = which("air") @@ -226,23 +233,23 @@ export const rlang: Info = { const firstLine = output.split("\n")[0] const hasR = firstLine.includes("R language") const hasFormatter = firstLine.includes("formatter") - return hasR && hasFormatter - } catch (error) { + if (hasR && hasFormatter) return ["air", "format", "$FILE"] + } catch { return false } + return false }, } export const uvformat: Info = { name: "uv", - command: ["uv", "format", "--", "$FILE"], extensions: [".py", ".pyi"], async enabled() { if (await ruff.enabled()) return false if (which("uv") !== null) { const proc = Process.spawn(["uv", "format", "--help"], { stderr: "pipe", stdout: "pipe" }) const code = await proc.exited - return code === 0 + if (code === 0) return ["uv", "format", "--", "$FILE"] } return false }, @@ -250,108 +257,117 @@ export const uvformat: Info = { export const rubocop: Info = { name: "rubocop", - command: ["rubocop", "--autocorrect", "$FILE"], extensions: [".rb", ".rake", ".gemspec", ".ru"], async enabled() { - return which("rubocop") !== null + const match = which("rubocop") + if (!match) return false + return [match, "--autocorrect", "$FILE"] }, } export const standardrb: Info = { name: "standardrb", - command: ["standardrb", "--fix", "$FILE"], extensions: [".rb", ".rake", ".gemspec", ".ru"], async enabled() { - return which("standardrb") !== null + const match = which("standardrb") + if (!match) return false + return [match, "--fix", "$FILE"] }, } export const htmlbeautifier: Info = { name: "htmlbeautifier", - command: ["htmlbeautifier", "$FILE"], extensions: [".erb", ".html.erb"], async enabled() { - return which("htmlbeautifier") !== null + const match = which("htmlbeautifier") + if (!match) return false + return [match, "$FILE"] }, } export const dart: Info = { name: "dart", - command: ["dart", "format", "$FILE"], extensions: [".dart"], async enabled() { - return which("dart") !== null + const match = which("dart") + if (!match) return false + return [match, "format", "$FILE"] }, } export const ocamlformat: Info = { name: "ocamlformat", - command: ["ocamlformat", "-i", "$FILE"], extensions: [".ml", ".mli"], async enabled() { if (!which("ocamlformat")) return false const items = await Filesystem.findUp(".ocamlformat", Instance.directory, Instance.worktree) - return items.length > 0 + if (items.length > 0) return ["ocamlformat", "-i", "$FILE"] + return false }, } export const terraform: Info = { name: "terraform", - command: ["terraform", "fmt", "$FILE"], extensions: [".tf", ".tfvars"], async enabled() { - return which("terraform") !== null + const match = which("terraform") + if (!match) return false + return [match, "fmt", "$FILE"] }, } export const latexindent: Info = { name: "latexindent", - command: ["latexindent", "-w", "-s", "$FILE"], extensions: [".tex"], async enabled() { - return which("latexindent") !== null + const match = which("latexindent") + if (!match) return false + return [match, "-w", "-s", "$FILE"] }, } export const gleam: Info = { name: "gleam", - command: ["gleam", "format", "$FILE"], extensions: [".gleam"], async enabled() { - return which("gleam") !== null + const match = which("gleam") + if (!match) return false + return [match, "format", "$FILE"] }, } export const shfmt: Info = { name: "shfmt", - command: ["shfmt", "-w", "$FILE"], extensions: [".sh", ".bash"], async enabled() { - return which("shfmt") !== null + const match = which("shfmt") + if (!match) return false + return [match, "-w", "$FILE"] }, } export const nixfmt: Info = { name: "nixfmt", - command: ["nixfmt", "$FILE"], extensions: [".nix"], async enabled() { - return which("nixfmt") !== null + const match = which("nixfmt") + if (!match) return false + return [match, "$FILE"] }, } export const rustfmt: Info = { name: "rustfmt", - command: ["rustfmt", "$FILE"], extensions: [".rs"], async enabled() { - return which("rustfmt") !== null + const match = which("rustfmt") + if (!match) return false + return [match, "$FILE"] }, } 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) @@ -360,8 +376,7 @@ export const pint: Info = { require?: Record "require-dev"?: Record }>(item) - if (json.require?.["laravel/pint"]) return true - if (json["require-dev"]?.["laravel/pint"]) return true + if (json.require?.["laravel/pint"] || json["require-dev"]?.["laravel/pint"]) return ["./vendor/bin/pint", "$FILE"] } return false }, @@ -369,27 +384,30 @@ export const pint: Info = { export const ormolu: Info = { name: "ormolu", - command: ["ormolu", "-i", "$FILE"], extensions: [".hs"], async enabled() { - return which("ormolu") !== null + const match = which("ormolu") + if (!match) return false + return [match, "-i", "$FILE"] }, } export const cljfmt: Info = { name: "cljfmt", - command: ["cljfmt", "fix", "--quiet", "$FILE"], extensions: [".clj", ".cljs", ".cljc", ".edn"], async enabled() { - return which("cljfmt") !== null + const match = which("cljfmt") + if (!match) return false + return [match, "fix", "--quiet", "$FILE"] }, } export const dfmt: Info = { name: "dfmt", - command: ["dfmt", "-i", "$FILE"], extensions: [".d"], async enabled() { - return which("dfmt") !== null + const match = which("dfmt") + if (!match) return false + return [match, "-i", "$FILE"] }, } diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index 795364be1c..c05c2bf454 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 commands: Record = {} const formatters: Record = {} const cfg = yield* config.get() @@ -56,30 +56,32 @@ export namespace Format { continue } const info = mergeDeep(formatters[name] ?? {}, { - command: [], extensions: [], ...item, }) - if (info.command.length === 0) continue - formatters[name] = { ...info, name, - enabled: async () => true, + enabled: async () => info.command ?? false, } } } else { log.info("all formatters are disabled") } - async function isEnabled(item: Formatter.Info) { - let status = enabled[item.name] - if (status === undefined) { - status = await item.enabled() - enabled[item.name] = status + async function getCommand(item: Formatter.Info) { + let cmd = commands[item.name] + if (cmd === false || cmd === undefined) { + cmd = await item.enabled() + commands[item.name] = cmd } - return status + return cmd + } + + async function isEnabled(item: Formatter.Info) { + const cmd = await getCommand(item) + return cmd !== false } async function getFormatter(ext: string) { @@ -87,17 +89,17 @@ export namespace Format { const checks = await Promise.all( matching.map(async (item) => { log.info("checking", { name: item.name, ext }) - const on = await isEnabled(item) - if (on) { + const cmd = await getCommand(item) + if (cmd) { log.info("enabled", { name: item.name, ext }) } return { item, - enabled: on, + cmd, } }), ) - return checks.filter((x) => x.enabled).map((x) => x.item) + return checks.filter((x) => x.cmd).map((x) => ({ item: x.item, cmd: x.cmd! })) } function formatFile(filepath: string) { @@ -105,13 +107,14 @@ export namespace Format { log.info("formatting", { file: filepath }) const ext = path.extname(filepath) - for (const item of yield* Effect.promise(() => getFormatter(ext))) { - log.info("running", { command: item.command }) - const cmd = item.command.map((x) => x.replace("$FILE", filepath)) + for (const { item, cmd } of yield* Effect.promise(() => getFormatter(ext))) { + if (cmd === false) continue + log.info("running", { command: cmd }) + const replaced = cmd.map((x) => x.replace("$FILE", filepath)) const dir = yield* InstanceState.directory const code = yield* spawner .spawn( - ChildProcess.make(cmd[0]!, cmd.slice(1), { + ChildProcess.make(replaced[0]!, replaced.slice(1), { cwd: dir, env: item.environment, extendEnv: true, @@ -124,7 +127,7 @@ export namespace Format { Effect.sync(() => { log.error("failed to format file", { error: "spawn failed", - command: item.command, + command: cmd, ...item.environment, file: filepath, }) @@ -134,7 +137,7 @@ export namespace Format { ) if (code !== 0) { log.error("failed", { - command: item.command, + command: cmd, ...item.environment, }) } diff --git a/packages/opencode/test/format/format.test.ts b/packages/opencode/test/format/format.test.ts index 89a8c1f450..1b341d2f41 100644 --- a/packages/opencode/test/format/format.test.ts +++ b/packages/opencode/test/format/format.test.ts @@ -87,12 +87,10 @@ 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 @@ -102,21 +100,19 @@ 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 true + return ["sh", "-c", "true"] } Formatter.mix.enabled = async () => { active++ max = Math.max(max, active) await Bun.sleep(20) active-- - return true + return ["sh", "-c", "true"] } }), () => @@ -130,10 +126,8 @@ 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 }), )