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.