diff --git a/packages/app/src/custom-elements.d.ts b/packages/app/src/custom-elements.d.ts index e4ea0d6cebd..86cb284693e 120000 --- a/packages/app/src/custom-elements.d.ts +++ b/packages/app/src/custom-elements.d.ts @@ -1 +1 @@ -../../ui/src/custom-elements.d.ts \ No newline at end of file +/// diff --git a/packages/enterprise/src/custom-elements.d.ts b/packages/enterprise/src/custom-elements.d.ts index e4ea0d6cebd..86cb284693e 120000 --- a/packages/enterprise/src/custom-elements.d.ts +++ b/packages/enterprise/src/custom-elements.d.ts @@ -1 +1 @@ -../../ui/src/custom-elements.d.ts \ No newline at end of file +/// diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 6b4242a225a..ff0e8a636d3 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1084,6 +1084,7 @@ export namespace Config { z.object({ disabled: z.boolean().optional(), command: z.array(z.string()).optional(), + args: z.array(z.string()).optional(), environment: z.record(z.string(), z.string()).optional(), extensions: z.array(z.string()).optional(), }), diff --git a/packages/opencode/src/format/formatter.ts b/packages/opencode/src/format/formatter.ts index 9e96b2305cd..111b34cb71a 100644 --- a/packages/opencode/src/format/formatter.ts +++ b/packages/opencode/src/format/formatter.ts @@ -1,3 +1,4 @@ +import path from "path" import { text } from "node:stream/consumers" import { BunProc } from "../bun" import { Instance } from "../project/instance" @@ -9,11 +10,18 @@ import { Flag } from "@/flag/flag" export interface Info { name: string command: string[] + args?: string[] environment?: Record + setup?(file: string): Promise extensions: string[] enabled(): Promise } +export interface Setup { + directory?: string + placeholders?: Record +} + export const gofmt: Info = { name: "gofmt", command: ["gofmt", "-w", "$FILE"], @@ -350,6 +358,41 @@ export const rustfmt: Info = { }, } +export const dotnetformat: Info = { + name: "dotnet-format", + command: ["dotnet", "format", "$TARGET", "--include", "$RELATIVE_FILE"], + async setup(file) { + let current = path.dirname(file) + + while (true) { + for (const pattern of ["*.slnx", "*.sln", "*.csproj", "*.fsproj", "*.vbproj"]) { + const glob = new Bun.Glob(pattern) + const matches = Array.from(glob.scanSync({ cwd: current, absolute: true })).sort() + if (matches[0]) { + const root = path.dirname(matches[0]) + const rel = path.relative(root, file) + if (!rel || rel.startsWith("..")) return + return { + directory: root, + placeholders: { + $TARGET: path.basename(matches[0]), + $RELATIVE_FILE: rel.split(path.sep).join("/"), + }, + } + } + } + + const parent = path.dirname(current) + if (parent === current) break + current = parent + } + }, + extensions: [".cs", ".vb", ".fs", ".fsi", ".fsx", ".fsscript"], + async enabled() { + return which("dotnet") !== null + }, +} + export const pint: Info = { name: "pint", command: ["./vendor/bin/pint", "$FILE"], diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index b849f778ece..5073ebca042 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -45,16 +45,18 @@ export namespace Format { delete formatters[name] continue } - const result: Formatter.Info = mergeDeep(formatters[name] ?? {}, { - command: [], - extensions: [], - ...item, - }) + const base = formatters[name] + const merged = mergeDeep(base ?? { command: [], extensions: [] }, item) + const result: Formatter.Info = { + ...merged, + name, + command: merged.command ?? [], + extensions: merged.extensions ?? [], + enabled: base?.enabled ?? (async () => true), + } if (result.command.length === 0) continue - result.enabled = async () => true - result.name = name formatters[name] = result } @@ -109,12 +111,32 @@ export namespace Format { const ext = path.extname(file) for (const item of await getFormatter(ext)) { - log.info("running", { command: item.command }) + let command = item.command try { + const cfg = await item.setup?.(file) + const placeholders: Record = { + $FILE: file, + ...(cfg?.placeholders ?? {}), + } + const cwd = cfg?.directory ?? path.dirname(file) + command = item.command.map((part) => { + let result = part + for (const [key, value] of Object.entries(placeholders).toSorted((a, b) => b[0].length - a[0].length)) { + result = result.replaceAll(key, value) + } + return result + }) + if (command.some((part) => /\$[A-Z][A-Z0-9_]*/.test(part))) { + continue + } + command.push(...(item.args ?? [])) + + log.info("running", { command: command }) + const proc = Process.spawn( - item.command.map((x) => x.replace("$FILE", file)), + command, { - cwd: Instance.directory, + cwd, env: { ...process.env, ...item.environment }, stdout: "ignore", stderr: "ignore", @@ -123,13 +145,15 @@ export namespace Format { const exit = await proc.exited if (exit !== 0) log.error("failed", { - command: item.command, + command, + cwd, + exit, ...item.environment, }) } catch (error) { log.error("failed to format file", { error, - command: item.command, + command, ...item.environment, file, }) diff --git a/packages/opencode/src/util/which.ts b/packages/opencode/src/util/which.ts index 81da2572170..340a1bc3721 100644 --- a/packages/opencode/src/util/which.ts +++ b/packages/opencode/src/util/which.ts @@ -1,10 +1,12 @@ import whichPkg from "which" export function which(cmd: string, env?: NodeJS.ProcessEnv) { + const path = env?.PATH ?? env?.Path ?? process.env.PATH ?? process.env.Path + const pathExt = env?.PATHEXT ?? env?.PathExt ?? process.env.PATHEXT ?? process.env.PathExt const result = whichPkg.sync(cmd, { nothrow: true, - path: env?.PATH ?? env?.Path ?? process.env.PATH ?? process.env.Path, - pathExt: env?.PATHEXT ?? env?.PathExt ?? process.env.PATHEXT ?? process.env.PathExt, + path, + pathExt }) return typeof result === "string" ? result : null } diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index 038f253274e..129891d3b6b 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -446,6 +446,9 @@ You can configure code formatters through the `formatter` option. { "$schema": "https://opencode.ai/config.json", "formatter": { + "dotnet-format": { + "args": ["--severity", "info"] + }, "prettier": { "disabled": true }, diff --git a/packages/web/src/content/docs/formatters.mdx b/packages/web/src/content/docs/formatters.mdx index dbee49dca6f..e514c1877a5 100644 --- a/packages/web/src/content/docs/formatters.mdx +++ b/packages/web/src/content/docs/formatters.mdx @@ -20,6 +20,7 @@ OpenCode comes with several built-in formatters for popular languages and framew | cljfmt | .clj, .cljs, .cljc, .edn | `cljfmt` command available | | dart | .dart | `dart` command available | | dfmt | .d | `dfmt` command available | +| dotnet-format | .cs, .vb, .fs, .fsi, .fsx, .fsscript | `dotnet` command available and a nearest `.slnx`, `.sln`, `.csproj`, `.fsproj`, or `.vbproj` | | gleam | .gleam | `gleam` command available | | gofmt | .go | `gofmt` command available | | htmlbeautifier | .erb, .html.erb | `htmlbeautifier` command available | @@ -71,6 +72,7 @@ Each formatter configuration supports the following: | Property | Type | Description | | ------------- | -------- | ------------------------------------------------------- | +| `args` | string[] | Additional arguments appended to the formatter command | | `disabled` | boolean | Set this to `true` to disable the formatter | | `command` | string[] | The command to run for formatting | | `environment` | object | Environment variables to set when running the formatter | @@ -130,3 +132,5 @@ You can override the built-in formatters or add new ones by specifying the comma ``` The **`$FILE` placeholder** in the command will be replaced with the path to the file being formatted. + +The `args` property is appended after the formatter command. Use it when you want to tweak a built-in formatter without replacing its full command. If you need complete control over argument order, override `command` instead.