Files
zeekayandHanzo Dev aa87442fdd Record upstream authorship centrally in NOTICE instead of in every file header
Source files keep their SPDX-License-Identifier and Hanzo copyright line; the
Gitea, Gogs and LFS Test Server copyrights they used to repeat now live once in
NOTICE, alongside LICENSE. The header lint accepts any copyright line above the
SPDX line, so it no longer demands an upstream one.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-10 17:50:35 -07:00

244 lines
5.5 KiB
Go

// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// SPDX-License-Identifier: MIT
package glob
import (
"errors"
"fmt"
"regexp"
"slices"
"strings"
"github.com/hanzoai/git/modules/util"
)
// Reference: https://github.com/gobwas/glob/blob/master/glob.go
type Glob interface {
Match(string) bool
}
type globCompiler struct {
regexpQuestion bool
regexpPlus bool
superWildcardRight bool
supportNegative bool
separators []rune
nonSeparatorChars string
globPattern []rune
regexpPattern string
regexp *regexp.Regexp
builder *strings.Builder
pos int
negativeFlip bool
}
// compileChars compiles character class patterns like [abc] or [!abc]
func (g *globCompiler) compileChars() error {
g.builder.WriteByte('[')
if g.pos < len(g.globPattern) && g.globPattern[g.pos] == '!' {
g.pos++
g.builder.WriteByte('^')
}
for g.pos < len(g.globPattern) {
c := g.globPattern[g.pos]
g.pos++
if c == ']' {
g.builder.WriteByte(']')
return nil
}
if c == '\\' {
if g.pos >= len(g.globPattern) {
return errors.New("unterminated character class escape")
}
g.builder.WriteByte('\\')
g.builder.WriteRune(g.globPattern[g.pos])
g.pos++
} else {
g.builder.WriteRune(c)
}
}
return errors.New("unterminated character class")
}
// compile compiles the glob pattern into a regular expression
func (g *globCompiler) compile(subPattern bool) error {
for g.pos < len(g.globPattern) {
c := g.globPattern[g.pos]
g.pos++
if subPattern && c == '}' {
g.builder.WriteByte(')')
return nil
}
switch c {
case '*':
if g.pos < len(g.globPattern) && g.globPattern[g.pos] == '*' {
var matchRightSep bool
if g.superWildcardRight {
// check "**/" pattern, then the wildcards should also match the right separator
// e.g.: "**/docs" should match "docs"
var rightRune rune
if g.pos+1 < len(g.globPattern) {
rightRune = g.globPattern[g.pos+1]
}
if slices.Contains(g.separators, rightRune) {
matchRightSep = g.pos-2 < 0 || g.globPattern[g.pos-2] == rightRune
}
}
if matchRightSep {
g.pos += 2
} else {
g.pos++
}
g.builder.WriteString(".*") // match any sequence of characters
} else {
g.builder.WriteString(g.nonSeparatorChars)
g.builder.WriteByte('*') // match any sequence of non-separator characters
}
case '?':
if g.regexpQuestion {
g.builder.WriteByte('?')
} else {
g.builder.WriteString(g.nonSeparatorChars) // match any single non-separator character
}
case '+':
if g.regexpPlus {
g.builder.WriteByte('+')
} else {
g.builder.WriteByte('\\')
g.builder.WriteRune(c)
}
case '[':
if err := g.compileChars(); err != nil {
return err
}
case '{':
g.builder.WriteByte('(')
if err := g.compile(true); err != nil {
return err
}
case ',':
if subPattern {
g.builder.WriteByte('|')
} else {
g.builder.WriteByte(',')
}
case '\\':
if g.pos >= len(g.globPattern) {
return errors.New("no character to escape")
}
g.builder.WriteByte('\\')
g.builder.WriteRune(g.globPattern[g.pos])
g.pos++
case '.', '^', '$', '(', ')', '|':
g.builder.WriteByte('\\')
g.builder.WriteRune(c) // escape regexp special characters
default:
g.builder.WriteRune(c)
}
}
return nil
}
func initGlobCompiler(g *globCompiler, pattern string, separators []rune) (Glob, error) {
g.globPattern = []rune(pattern)
g.separators = separators
g.builder = new(strings.Builder)
// Escape separators for use in character class
escapedSeparators := regexp.QuoteMeta(string(separators))
if escapedSeparators != "" {
g.nonSeparatorChars = "[^" + escapedSeparators + "]"
} else {
g.nonSeparatorChars = "."
}
if g.supportNegative && len(g.globPattern) > 0 && g.globPattern[0] == '!' {
g.negativeFlip = true
g.pos++
}
g.builder.WriteByte('^')
if err := g.compile(false); err != nil {
return nil, err
}
g.builder.WriteByte('$')
g.regexpPattern = g.builder.String()
g.builder = nil
regex, err := regexp.Compile(g.regexpPattern)
if err != nil {
return nil, fmt.Errorf("failed to compile regexp: %w", err)
}
g.regexp = regex
return g, nil
}
func (g *globCompiler) Match(s string) bool {
ret := g.regexp.MatchString(s)
if g.negativeFlip {
ret = !ret
}
return ret
}
func Compile(pattern string, separators ...rune) (Glob, error) {
return initGlobCompiler(&globCompiler{}, pattern, separators)
}
func CompileWorkflow(pattern string) (Glob, error) {
return initGlobCompiler(&globCompiler{
regexpQuestion: true,
regexpPlus: true,
superWildcardRight: true,
supportNegative: true,
}, pattern, []rune{'/'})
}
func MustCompile(pattern string, separators ...rune) Glob {
g, err := Compile(pattern, separators...)
if err != nil {
panic(err)
}
return g
}
func IsSpecialByte(c byte) bool {
return c == '*' || c == '?' || c == '\\' || c == '[' || c == ']' || c == '{' || c == '}'
}
// QuoteMeta returns a string that quotes all glob pattern meta characters
// inside the argument text; For example, QuoteMeta(`{foo*}`) returns `\[foo\*\]`.
// Reference: https://github.com/gobwas/glob/blob/master/glob.go
func QuoteMeta(s string) string {
pos := 0
for pos < len(s) && !IsSpecialByte(s[pos]) {
pos++
}
if pos == len(s) {
return s
}
b := make([]byte, pos+2*(len(s)-pos))
copy(b, s[0:pos])
to := pos
for ; pos < len(s); pos++ {
if IsSpecialByte(s[pos]) {
b[to] = '\\'
to++
}
b[to] = s[pos]
to++
}
return util.UnsafeBytesToString(b[0:to])
}