Преглед на файлове

refactor(format): update formatter interface to return command from enabled() (#20703)

Dax преди 2 седмици
родител
ревизия
3faabdadb7
променени са 3 файла, в които са добавени 104 реда и са изтрити 89 реда
  1. 78 60
      packages/opencode/src/format/formatter.ts
  2. 24 21
      packages/opencode/src/format/index.ts
  3. 2 8
      packages/opencode/test/format/format.test.ts

+ 78 - 60
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<string, string>
   extensions: string[]
-  enabled(): Promise<boolean>
+  enabled(): Promise<string[] | false>
 }
 
 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<string, string>
         devDependencies?: Record<string, string>
       }>(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<string, string>
         devDependencies?: Record<string, string>
       }>(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<string, string>
         "require-dev"?: Record<string, string>
       }>(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"]
   },
 }

+ 24 - 21
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<string, boolean> = {}
+          const commands: Record<string, string[] | false> = {}
           const formatters: Record<string, Formatter.Info> = {}
 
           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,
                   })
                 }

+ 2 - 8
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
             }),
         )