diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index e307496c4a7..b38cc0e9f6a 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -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" }) @@ -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 } @@ -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({ diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts index a3dcfc70367..6dc7a5f81cf 100644 --- a/packages/opencode/src/util/filesystem.ts +++ b/packages/opencode/src/util/filesystem.ts @@ -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) { @@ -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(".") }; + } } diff --git a/packages/web/src/content/docs/formatters.mdx b/packages/web/src/content/docs/formatters.mdx index 9fc41a53d6f..1efd65fe7a8 100644 --- a/packages/web/src/content/docs/formatters.mdx +++ b/packages/web/src/content/docs/formatters.mdx @@ -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 \ No newline at end of file