Skip to content

[FEATURE]: Formatter enhanced file extension matching #3431

Closed

Description

@zutritt

Feature hasn't been suggested before.

  • I have verified this feature I'm about to request hasn't been suggested before.

Describe the enhancement you want to request

I think it would be beneficial to modify the formatter logic to support pattern matching or compare against config defined extension rather than extracting it from the file path.

For ex. problematic extensions: .php + .blade.php

Currently the workaround for this is creating single rule:

"formatter": {
    "php": {
        "extensions": [".php"],
        "command": [
            "./.opencode/format.sh",
            "$FILE"
        ],
    },
},

and adding a script to handle the differentiation:

#!/bin/bash

file=$1

if [[ $file == *.blade.php ]]; then
    npx blade-formatter -e -w --sort-tailwindcss-classes --wrap-attributes-min-attrs=2 "$file"
elif [[ $file == *.php ]]; then
    ./vendor/bin/pint "$file"
else
    echo "No formatter configured for this file type."
fi

Activity

  1. added
    discussionUsed for feature requests, proposals, ideas, etc. Open discussion
    on Oct 25, 2025
  2. self-assigned this
    on Oct 25, 2025
  3. rekram1-node commented on Oct 25, 2025

    @rekram1-node
    Collaborator

    yeah I think this makes sense, i feel like pattern matching would be best probably

  4. dkudos commented on Nov 2, 2025

    @dkudos

    I needed to rip out and analyst compare to give a few more options to compare for info to see if we missed anything that A.I. could give us to think about:

    Alternative Approaches for Formatter Pattern Matching

    This document explores different design approaches for solving the compound extension problem in the opencode formatter, each with trade-offs and use cases.


    Approach 1: Glob Pattern Matching (Original Proposal)

    Implementation:

    patterns: z.array(z.string()).optional()

    Example Config:

    {
      "formatter": {
        "blade": {
          "patterns": ["*.blade.php", "**/*.blade.php"],
          "command": ["blade-formatter", "$FILE"]
        }
      }
    }

    Pros:

    • Familiar syntax for developers
    • Powerful (supports wildcards, paths)
    • Can match on path segments, not just extensions
    • Library support (minimatch, picomatch)

    Cons:

    • May be overkill for just extensions
    • Performance overhead for glob matching on every file
    • Potential for overly complex patterns

    Approach 2: Extension Priority Order

    Implementation:
    Instead of extracting just the last extension, extract ALL potential extension combinations and check against a priority-ordered list.

    // For file: "component.spec.ts"
    // Extract: [".spec.ts", ".ts"]
    // Check formatters in order until match found

    Example Config:

    {
      "formatter": {
        "blade": {
          "extensions": [".blade.php"],
          "command": ["blade-formatter", "$FILE"]
        },
        "php": {
          "extensions": [".php"],
          "command": ["pint", "$FILE"]
        }
      }
    }

    Implementation in format/index.ts:

    function extractExtensions(filepath: string): string[] {
      const basename = path.basename(filepath)
      const parts = basename.split('.')
      const extensions: string[] = []
      
      // Extract all possible compound extensions
      for (let i = 1; i < parts.length; i++) {
        extensions.push('.' + parts.slice(i).join('.'))
      }
      
      return extensions // [".spec.ts", ".ts"] for "file.spec.ts"
    }
    
    async function getFormatter(filepath: string) {
      const extensions = extractExtensions(filepath)
      const formatters = await state().then((x) => x.formatters)
      const result = []
      
      for (const item of Object.values(formatters)) {
        // Check most specific extension first
        for (const ext of extensions) {
          if (item.extensions.includes(ext)) {
            if (await isEnabled(item)) {
              result.push(item)
            }
            break // Found match, stop checking this formatter
          }
        }
      }
      
      return result
    }

    Pros:

    • ✅ No new config fields needed
    • ✅ Backward compatible
    • ✅ Simple, intuitive behavior
    • ✅ No external dependencies
    • ✅ Efficient (simple string operations)

    Cons:

    • Only works for extensions (can't match on directory paths)
    • Relies on formatter definition order in config
    • Less flexible than patterns

    Approach 3: Regex Pattern Matching

    Implementation:

    patterns: z.array(z.string()).optional()

    Example Config:

    {
      "formatter": {
        "blade": {
          "patterns": ["\\.blade\\.php$"],
          "command": ["blade-formatter", "$FILE"]
        },
        "spec-files": {
          "patterns": ["\\.spec\\.(ts|js)$"],
          "command": ["spec-formatter", "$FILE"]
        }
      }
    }

    Pros:

    • Extremely flexible and powerful
    • Can match complex patterns
    • No external dependencies (native RegExp)

    Cons:

    • ❌ Harder for non-technical users
    • ❌ Easy to write incorrect patterns
    • ❌ Less readable than glob patterns
    • ❌ Security concerns (ReDoS attacks if user-provided)

    Approach 4: File Matcher Function (Most Flexible)

    Implementation:
    Allow custom matcher functions or predefined matchers.

    matcher: z.union([
      z.array(z.string()),           // Simple extensions
      z.object({
        type: z.literal('glob'),
        patterns: z.array(z.string())
      }),
      z.object({
        type: z.literal('regex'),
        patterns: z.array(z.string())
      }),
      z.object({
        type: z.literal('extension'),
        values: z.array(z.string()),
        compound: z.boolean()          // Enable compound extension matching
      })
    ])

    Example Config:

    {
      "formatter": {
        "blade": {
          "matcher": {
            "type": "glob",
            "patterns": ["*.blade.php"]
          },
          "command": ["blade-formatter", "$FILE"]
        },
        "php": {
          "matcher": {
            "type": "extension",
            "values": [".php"],
            "compound": false
          },
          "command": ["pint", "$FILE"]
        }
      }
    }

    Pros:

    • Maximum flexibility
    • Explicit matching strategy
    • Can add new matcher types later

    Cons:

    • ❌ Most complex to implement
    • ❌ Steeper learning curve
    • ❌ More verbose configuration

    Approach 5: Hybrid - Extensions + Optional Patterns

    Implementation:
    Keep extensions for backward compatibility, add optional patterns for advanced cases.

    extensions: z.array(z.string()).optional()
    patterns: z.array(z.string()).optional()
    // Match order: patterns first, then compound extensions, then simple extensions

    Example Config:

    {
      "formatter": {
        "blade": {
          "patterns": ["*.blade.php"],
          "command": ["blade-formatter", "$FILE"]
        },
        "typescript-spec": {
          "extensions": [".spec.ts", ".test.ts"],
          "command": ["spec-formatter", "$FILE"]
        },
        "php": {
          "extensions": [".php"],
          "command": ["pint", "$FILE"]
        }
      }
    }

    Matching Logic:

    async function getFormatter(filepath: string) {
      const formatters = await state().then((x) => x.formatters)
      const result = []
      
      for (const item of Object.values(formatters)) {
        let matched = false
        
        // 1. Check patterns first (if defined)
        if (item.patterns) {
          for (const pattern of item.patterns) {
            if (minimatch(filepath, pattern)) {
              matched = true
              break
            }
          }
        }
        
        // 2. Fall back to compound extension matching
        if (!matched && item.extensions) {
          const compoundExts = extractExtensions(filepath)
          for (const ext of compoundExts) {
            if (item.extensions.includes(ext)) {
              matched = true
              break
            }
          }
        }
        
        if (matched && await isEnabled(item)) {
          result.push(item)
        }
      }
      
      return result
    }

    Pros:

    • ✅ Backward compatible
    • ✅ Solves your use case elegantly
    • ✅ Progressive enhancement (simple → advanced)
    • ✅ Clear upgrade path for users

    Cons:

    • Two ways to do similar things
    • Slightly more complex matching logic

    Approach 6: Extension Suffix Matching (Simplest)

    Implementation:
    Just change how extensions are extracted - use suffix matching instead of path.extname().

    // Current: path.extname("file.blade.php") → ".php"
    // New: checkExtension("file.blade.php", [".blade.php", ".php"]) → ".blade.php"
    
    function matchesExtension(filepath: string, extensions: string[]): string | null {
      const filename = path.basename(filepath)
      
      // Sort by length (longest first) to match most specific
      const sorted = [...extensions].sort((a, b) => b.length - a.length)
      
      for (const ext of sorted) {
        if (filename.endsWith(ext)) {
          return ext
        }
      }
      
      return null
    }

    Example Config (NO CHANGES NEEDED):

    {
      "formatter": {
        "blade": {
          "extensions": [".blade.php"],
          "command": ["blade-formatter", "$FILE"]
        },
        "php": {
          "extensions": [".php"],
          "command": ["pint", "$FILE"]
        }
      }
    }

    Pros:

    • ✅ ZERO config changes needed
    • ✅ Completely backward compatible
    • ✅ Solves the exact problem described
    • ✅ Simple implementation
    • ✅ Intuitive behavior (more specific extensions match first)
    • ✅ No new dependencies

    Cons:

    • Can't match on directory patterns
    • Limited to suffix matching only

    My Recommendation: Approach 6 (Extension Suffix Matching)

    Why?

    1. Solves your exact use case with zero config changes
    2. Simplest implementation - just change one function
    3. No breaking changes - fully backward compatible
    4. Intuitive behavior - more specific matches win
    5. No new concepts to learn

    Implementation Summary:

    1. Replace path.extname(file) with a suffix matching function
    2. Sort extensions by length (longest first) when checking
    3. Return first match

    This would allow your desired config to work immediately:

    {
      "formatter": {
        "blade": {
          "extensions": [".blade.php"],
          "command": ["blade-formatter", "$FILE"]
        },
        "php": {
          "extensions": [".php"],
          "command": ["pint", "$FILE"]
        }
      }
    }

    If you need path-based matching later, you could add Approach 5 (Hybrid) as an enhancement.


    Comparison Matrix

    Approach Complexity Backward Compat Flexibility Config Changes
    1. Glob Patterns Medium ✅ High Optional
    2. Priority Order Low ✅ Medium None
    3. Regex Medium ✅ Very High Optional
    4. Matcher Function High ✅ Very High Optional
    5. Hybrid Medium ✅ High Optional
    6. Suffix Match Low ✅ Medium None

    Next Steps

    Recommended: Implement Approach 6 for immediate value, with potential to add Approach 5 later if path-based patterns are needed.


    Problem Context

    Current Issue

    The formatter currently uses path.extname() to extract file extensions, which only captures the last extension segment (e.g., .php from .blade.php). This creates issues for compound extensions like:

    • .blade.php → only matches .php
    • .html.erb → only matches .erb
    • .spec.ts → only matches .ts

    Current Workaround

    Users must create a wrapper script to handle pattern matching manually:

    #!/bin/bash
    file=$1
    if [[ $file == *.blade.php ]]; then
        npx blade-formatter -e -w --sort-tailwindcss-classes --wrap-attributes-min-attrs=2 "$file"
    elif [[ $file == *.php ]]; then
        ./vendor/bin/pint "$file"
    else
        echo "No formatter configured for this file type."
    fi

    With the recommended approach, this workaround would no longer be necessary.

    I am taking what was said and looking though a few more options while noting the security concerns.

  5. OpeOginni commented on Nov 2, 2025

    @OpeOginni
    Contributor

    Made a PR, with Exact Matching of extentions

    .ts will work for files like index.ts but not index.test.ts

    *.ts will work for both index.ts and index.test.ts

    *.test.ts will work for only index.test.ts

  6. magicprinc commented on Jan 11, 2026

    @magicprinc
  7. Taqinou commented on Jan 31, 2026

    @Taqinou

    Hi! 👋

    I've read through the discussion and the different approaches suggested (thanks @dkudos for the comprehensive analysis!).

    I've implemented Approach 6: Extension Suffix Matching on my fork to solve the compound extension problem (.blade.php, .spec.ts, .html.erb, etc.).

    Implementation Details

    Problem: The current path.extname() only extracts the last extension:

    • component.blade.php → .php ❌
    • test.spec.ts → .ts ❌

    Solution: Added extractExtensions() function that extracts ALL possible extensions from most specific to most general:

    // For "component.blade.php"
    extractExtensions() returns: [".blade.php", ".php"]
    
    // Matching logic then checks if any formatter extension
    // matches any of these file extensions

    Changes made:

    • Modified packages/opencode/src/format/index.ts
    • Added extractExtensions(filepath: string): string[] helper function
    • Updated getFormatter() to accept full filepath instead of single extension
    • Changed matching logic from path.extname() to suffix matching

    Why Approach 6:

    • ✅ Zero breaking changes - fully backward compatible
    • ✅ No config changes required - existing formatter configs work immediately
    • ✅ Simplest implementation - only ~15 lines of code changed
    • ✅ Intuitive behavior - more specific extensions match first
    • ✅ No new dependencies

    Example config that now works:

    {
      "formatter": {
        "blade": {
          "extensions": [".blade.php"],
          "command": ["blade-formatter", "$FILE"]
        },
        "php": {
          "extensions": [".php"],
          "command": ["pint", "$FILE"]
        }
      }
    }

    For component.blade.php, both formatters are now found (instead of just the PHP formatter).

    Testing:

    • ✅ Tested with .blade.php, .spec.ts, .html.erb
    • ✅ Backward compatible with simple extensions (.php, .ts, .js)
    • ✅ Handles edge cases (files without extension, full paths)

    Is this feature still considered open / up for grabs? I'd be happy to open a PR if you're interested in this direction!

    Thanks!

  8. added a commit that references this issue on Feb 21, 2026
    6f6b184
  9. github-actions commented on Apr 16, 2026

    @github-actions
    Contributor

    To stay organized issues are automatically closed after 90 days of no activity. If the issue is still relevant please open a new one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

discussionUsed for feature requests, proposals, ideas, etc. Open discussiongood first issueGood for newcomershelp-wanted

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions