Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 24 additions & 6 deletions packages/opencode/src/format/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { Bus } from "../bus"
import { File } from "../file"
import { Log } from "../util/log"
import path from "path"
import z from "zod"

import * as Formatter from "./formatter"
import { Config } from "../config/config"
import { mergeDeep } from "remeda"
import { Instance } from "../project/instance"
import { Wildcard } from "../util/wildcard"
import { Filesystem } from "../util/filesystem"

export namespace Format {
const log = Log.create({ service: "format" })
Expand Down Expand Up @@ -62,16 +63,33 @@ export namespace Format {
return status
}

async function getFormatter(ext: string) {
async function getFormatter(ext: string, fullExt: string) {
const formatters = await state().then((x) => x.formatters)
const result = []

for (const item of Object.values(formatters)) {
log.info("checking", { name: item.name, ext })
if (!item.extensions.includes(ext)) continue
log.info("checking", { name: item.name, ext, fullExt })

const matches = item.extensions.some(pattern => {
if (pattern.includes("*") || pattern.includes("?")) {
return Wildcard.match(fullExt, pattern)
}
return pattern === ext
})

if (!matches) continue
if (!(await isEnabled(item))) continue
log.info("enabled", { name: item.name, ext })
result.push(item)
}

const strongFormatters = result.filter(formatter =>
formatter.extensions.some(pattern =>
(pattern.includes("*") || pattern.includes("?")) &&
Wildcard.match(fullExt, pattern)
)
)
if (strongFormatters.length) return strongFormatters
return result
}

Expand All @@ -94,9 +112,9 @@ export namespace Format {
Bus.subscribe(File.Event.Edited, async (payload) => {
const file = payload.properties.file
log.info("formatting", { file })
const ext = path.extname(file)
const { ext, fullExt } = Filesystem.getFullExt(file)

for (const item of await getFormatter(ext)) {
for (const item of await getFormatter(ext, fullExt)) {
log.info("running", { command: item.command })
try {
const proc = Bun.spawn({
Expand Down
12 changes: 11 additions & 1 deletion packages/opencode/src/util/filesystem.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { exists } from "fs/promises"
import { dirname, join, relative } from "path"
import path, { dirname, join, relative } from "path"

export namespace Filesystem {
export function overlaps(a: string, b: string) {
Expand Down Expand Up @@ -66,4 +66,14 @@ export namespace Filesystem {
}
return result
}

export function getFullExt(filename: string): { ext: string, fullExt: string } {
const base = path.basename(filename);
const ext = path.extname(filename);
const parts = base.split(".");

if (parts.length <= 1) return { ext: "", fullExt: "" };

return { ext: ext, fullExt: "." + parts.slice(1).join(".") };
}
}
40 changes: 40 additions & 0 deletions packages/web/src/content/docs/formatters.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,43 @@ 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.

---

### Pattern-based formatting

You can use wildcard patterns in extensions to apply different formatters based on full file extensions. This is useful when you have special file types that share a base extension but need different formatting.

For example, your PHP projects might have both `.php` files and `.blade.php` template files that require different formatters:

```json title="opencode.json" {4-11}
{
"$schema": "https://opencode.ai/config.json",
"formatter": {
"php-cs-fixer": {
"command": ["php-cs-fixer", "fix", "$FILE"],
"extensions": [".php"]
},
"blade-formatter": {
"command": ["blade-formatter", "--write", "$FILE"],
"extensions": ["*.blade.php"]
}
}
}
```
With this configuration:
- `User.blade.php` → Uses `blade-formatter` (wildcard match takes precedence)
- `UserController.php` → Uses `php-cs-fixer` (base extension match)

**How it works:**

When OpenCode formats a file, it:

1. Collects formatters that match either the full extension (via wildcard) or base extension (exact match)
2. If any formatter has a wildcard pattern matching the full extension, only those formatters run
3. Otherwise, all matching formatters run (backward compatible)

This pattern-based approach works with any file type. Other common examples:
- `*.test.ts` for test files vs `.ts` for regular TypeScript
- `*.config.js` for config files vs `.js` for regular JavaScript
- `*.spec.rb` for spec files vs `.rb` for regular Ruby