feat: port the path patterns of @actions/glob (#1342)

Built-in actions that select files need to match what the JavaScript actions match. This ports @actions/glob 0.6.1 and the minimatch 3 subset it uses, as bundled in `actions/upload-artifact` v7. It only answers questions about paths; walking the files stays with the caller. Only malformed bracket expressions and some non-ASCII case folding on Windows can match differently.

Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1342
Reviewed-by: silverwind <[email protected]>
Co-authored-by: bircni <[email protected]>
This commit is contained in:
bircniandsilverwind authored and silverwind committed 2026-11-06 01:13:07 +00:00
1 parent bb6d793cb9
commit b88d111cc5
5 files changed
+1542

No files matched your search

+239
View File
@@ -0,0 +1,239 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
// Package glob ports the pattern semantics of the JavaScript @actions/glob 0.6.1 package, as
// bundled in actions/upload-artifact, without walking a filesystem. Malformed bracket expressions
// and some non-ASCII case folding on Windows can match differently.
package glob
import (
"fmt"
"path"
"strings"
"sync"
)
type Options struct {
Workdir string // absolute; relative patterns resolve against it
Home func() (string, error) // replaces ~, called once and only when a pattern uses it
Windows bool // Windows path flavour
ImplicitDescendants bool
}
type MatchKind int
const (
MatchNone MatchKind = 0
MatchDirectory MatchKind = 1
MatchFile MatchKind = 2
MatchAll = MatchDirectory | MatchFile
)
type Globber struct {
patterns []*pattern
searchPaths []string
}
// Parse parses newline separated patterns like DefaultGlobber.create.
func Parse(patterns string, o Options) (*Globber, error) {
w := flavour{o.Windows}
env := parseEnv{w: w, cwd: w.normalizeSeparators(o.Workdir)}
if o.Home != nil {
env.home = sync.OnceValues(o.Home)
}
if env.cwd == "" || !w.hasAbsoluteRoot(env.cwd) {
return nil, fmt.Errorf("workdir '%s' is not an absolute path", o.Workdir)
}
if o.Windows {
patterns = strings.ReplaceAll(patterns, "\r\n", "\n")
patterns = strings.ReplaceAll(patterns, "\r", "\n")
}
var parsed []*pattern
for line := range strings.SplitSeq(patterns, "\n") {
line = jsTrim(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
p, err := newPattern(line, false, env)
if err != nil {
return nil, err
}
parsed = append(parsed, p)
}
g := &Globber{searchPaths: searchPaths(parsed, w)}
for _, p := range parsed {
g.patterns = append(g.patterns, p)
if o.ImplicitDescendants && (p.trailingSeparator || p.segments[len(p.segments)-1] != "**") {
s := jsTrim(w.joinSegments(append(append([]string(nil), p.segments...), "**")))
if p.negate {
s = "!" + s
}
implicit, err := newPattern(s, true, env)
if err != nil {
return nil, err
}
g.patterns = append(g.patterns, implicit)
}
}
return g, nil
}
// SearchPaths returns the literal prefix of each include pattern, without duplicates and
// paths nested under another search path.
func (g *Globber) SearchPaths() []string {
return append([]string(nil), g.searchPaths...)
}
// Match ORs in each include pattern and masks out each exclude pattern, in order.
func (g *Globber) Match(p string) MatchKind {
result := MatchNone
for _, pat := range g.patterns {
if pat.negate {
result &^= pat.match(p)
} else {
result |= pat.match(p)
}
}
return result
}
// PartialMatch reports whether an include pattern may match descendants of p.
func (g *Globber) PartialMatch(p string) bool {
for _, pat := range g.patterns {
if !pat.negate && pat.partialMatch(p) {
return true
}
}
return false
}
func searchPaths(patterns []*pattern, w flavour) []string {
key := func(p *pattern) string {
if w.windows {
return strings.ToUpper(p.searchPath)
}
return p.searchPath
}
const candidate, included = 1, 2
seen := map[string]int{}
for _, p := range patterns {
if !p.negate {
seen[key(p)] = candidate
}
}
var result []string
for _, p := range patterns {
if p.negate {
continue
}
k := key(p)
if seen[k] == included {
continue
}
foundAncestor := false
for temp, parent := k, w.dirname(k); parent != temp; temp, parent = parent, w.dirname(parent) {
if seen[parent] != 0 {
foundAncestor = true
break
}
}
if !foundAncestor {
result = append(result, p.searchPath)
seen[k] = included
}
}
return result
}
// LeastCommonAncestor ports getMultiPathLCA from upload-artifact's search.ts, which becomes the
// artifact root when there are several search paths. Segments compare case-sensitively there,
// even on Windows.
func LeastCommonAncestor(paths []string, windows bool) string {
switch len(paths) {
case 0:
return ""
case 1:
return paths[0]
}
w := flavour{windows}
sep := w.sep()
split := make([][]string, len(paths))
smallest := -1
for i, p := range paths {
if w.windows {
p = w.normalizeSeparators(p)
} else {
p = path.Clean(p)
}
split[i] = strings.Split(p, sep)
if smallest < 0 || len(split[i]) < smallest {
smallest = len(split[i])
}
}
var common []string
if strings.HasPrefix(paths[0], sep) {
common = append(common, sep)
}
for i := 0; i < smallest; i++ {
same := true
for _, s := range split[1:] {
if s[i] != split[0][i] {
same = false
break
}
}
if !same {
break
}
common = append(common, split[0][i])
}
return w.nodeJoin(common)
}
// nodeJoin ports Node's path.join for the already normalized segments LeastCommonAncestor produces.
func (w flavour) nodeJoin(parts []string) string {
var nonEmpty []string
for _, p := range parts {
if p != "" {
nonEmpty = append(nonEmpty, p)
}
}
if len(nonEmpty) == 0 {
return "."
}
joined := strings.Join(nonEmpty, w.sep())
if !w.windows {
return path.Clean(joined)
}
first := nonEmpty[0]
needsReplace, slashCount := true, 0
if w.isSep(first[0]) {
slashCount++
if len(first) > 1 && w.isSep(first[1]) {
slashCount++
if len(first) > 2 {
if w.isSep(first[2]) {
slashCount++
} else {
needsReplace = false
}
}
}
}
if needsReplace {
for slashCount < len(joined) && w.isSep(joined[slashCount]) {
slashCount++
}
if slashCount >= 2 {
joined = `\` + joined[slashCount:]
}
}
// path.win32.normalize turns a bare drive into the drive relative "C:.".
if reDriveOnly.MatchString(joined) {
return joined + "."
}
return joined
}
+216
View File
@@ -0,0 +1,216 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package glob
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var (
posix = Options{Workdir: "/work/cwd-with-[and]", Home: home("/home/home-with-[and]")}
windows = Options{Workdir: `C:\work\cwd-with-[and]`, Home: home(`C:\Users\home-with-[and]`), Windows: true}
)
func home(dir string) func() (string, error) {
return func() (string, error) { return dir, nil }
}
func implicit(o Options) Options {
o.ImplicitDescendants = true
return o
}
func TestHomeResolvedLazilyOnce(t *testing.T) {
calls := 0
o := Options{Workdir: "/w", Home: func() (string, error) { calls++; return "/h", nil }}
_, err := Parse("~/a\n! ~/b", o)
require.NoError(t, err)
_, err = Parse("/a", o)
require.NoError(t, err)
assert.Equal(t, 1, calls)
}
func TestMatch(t *testing.T) {
tests := []struct {
name string
patterns string
opts Options
want map[string]MatchKind
}{
{"negate markers toggle", "/a/*.txt\n!!/a/two.txt\n!/a/one.txt\n!!!/a/three.txt", posix, map[string]MatchKind{
"/a/two.txt": MatchAll, "/a/one.txt": MatchNone, "/a/three.txt": MatchNone, "/a/x.txt": MatchAll,
}},
{"home is escaped", "~/m*", posix, map[string]MatchKind{
"/home/home-with-[and]/match": MatchAll, "/home/home-with-[and]/not-match": MatchNone, "/home/home-with-a/match": MatchNone,
}},
{"workdir is escaped", "m*", posix, map[string]MatchKind{
"/work/cwd-with-[and]/match": MatchAll, "/work/cwd-with-[and]/not-match": MatchNone,
}},
{"workdir is escaped on windows", "m*\nC:n*\n" + `\work\cwd-with-[[]and]\o*`, windows, map[string]MatchKind{
`C:\work\cwd-with-[and]\match`: MatchAll, `C:\work\cwd-with-[and]\nope`: MatchAll, `C:\work\cwd-with-[and]\ok`: MatchAll, `C:\work\cwd-with-[and]\x`: MatchNone,
}},
{"globstar matches the directory before it", "/foo/bar/**", posix, map[string]MatchKind{
"/": MatchNone, "/foo": MatchNone, "/foo/bar": MatchAll, "/foo/bar/baz": MatchAll,
}},
{"globstar matches root", "/**", posix, map[string]MatchKind{"/": MatchAll}},
{"globstar matches root on windows", `C:\**`, windows, map[string]MatchKind{`C:\`: MatchAll}},
{"case sensitive on posix", "/Foo/**/Baz", posix, map[string]MatchKind{
"/Foo/Baz": MatchAll, "/Foo/bAZ": MatchNone, "/fOO/bar/Baz": MatchNone,
}},
{"case insensitive on windows", "C:/Foo/**/Baz", windows, map[string]MatchKind{
`C:\Foo\Baz`: MatchAll, `c:\Foo\bAZ`: MatchAll, "C:/fOO/bar/bAZ": MatchAll, `C:\Foo\Bar`: MatchNone,
}},
{"leading dot segment", ".\n./foo\n.bar", posix, map[string]MatchKind{
"/work/cwd-with-[and]": MatchAll, "/work/cwd-with-[and]/foo": MatchAll, "/work/cwd-with-[and]/.bar": MatchAll,
"/work/cwd-with-[and]/bar": MatchNone, "/work/cwd-with-[and]bar": MatchNone,
}},
{"leading tilde segment", "~\n~/foo\n~bar", posix, map[string]MatchKind{
"/home/home-with-[and]": MatchAll, "/home/home-with-[and]/foo": MatchAll, "/work/cwd-with-[and]/~bar": MatchAll,
"/home/home-with-[and]/bar": MatchNone, "/home/home-with-[and]bar": MatchNone,
}},
{"windows relative roots", "C:\nC:foo\nD:\nD:bar\n" + `\work\cwd-with-[[]and]\baz`, windows, map[string]MatchKind{
`C:\work\cwd-with-[and]`: MatchAll, `C:\work\cwd-with-[and]\foo`: MatchAll, `C:\foo`: MatchNone,
`D:\`: MatchDirectory, `D:\bar`: MatchAll, `D:\foo`: MatchNone, `C:\work\cwd-with-[and]\baz`: MatchAll,
}},
{"windows unc", `\\srv\share\a\*`, windows, map[string]MatchKind{`\\srv\share\a\b`: MatchAll, `//SRV/share/a/b`: MatchAll, `\\srv\other\a\b`: MatchNone}},
{"trailing separator matches directories only", "/foo/**/", posix, map[string]MatchKind{
"/": MatchNone, "/foo/": MatchDirectory, "/foo/bar": MatchDirectory, "/foo/bar/baz": MatchDirectory,
}},
{"backslash is a separator on windows", `C:\foo\`, windows, map[string]MatchKind{`C:\foo`: MatchDirectory}},
{"backslash escapes on posix", `/foo\*/b\[a]r`, posix, map[string]MatchKind{"/foo*/b[a]r": MatchAll, "/fooo/bar": MatchNone}},
{"interleaved excludes", "/**/*.proj\n/**/README.txt\n!/**/solution2/**\n/**/*.sln\n!/**/proj2/README.txt", posix, map[string]MatchKind{
"/solution1/proj1/proj1.proj": MatchAll, "/solution1/proj1/README.txt": MatchAll, "/solution1/proj2/README.txt": MatchNone,
"/solution1/solution1.sln": MatchAll, "/solution2/proj1/proj1.proj": MatchNone, "/solution2/solution2.sln": MatchAll,
}},
{"exclude directories", "C:/foo/**\n!C:/foo/**/", windows, map[string]MatchKind{
`C:\`: MatchNone, `C:\foo`: MatchFile, `C:\foo\bar`: MatchFile,
}},
{"dot is on, brace and extglob are off", "/a/*\n/b/{x,y}\n/c/+(x)\n/d/#e", posix, map[string]MatchKind{
"/a/.hidden": MatchAll, "/a/.": MatchNone, "/b/{x,y}": MatchAll, "/b/x": MatchNone, "/c/+(x)": MatchAll, "/c/x": MatchNone, "/d/#e": MatchAll,
}},
{"character classes", "/a/[!x]\n/b/[a-c]z\n/c/[z-a]\n/d/[]]", posix, map[string]MatchKind{
"/a/y": MatchAll, "/a/x": MatchNone, "/b/bz": MatchAll, "/b/dz": MatchNone, "/c/[z-a]": MatchAll, "/c/z": MatchNone, "/d/]": MatchAll,
}},
{"unclosed class splices undefined like minimatch 3", "C:/foo/b[\\!]r/b*", windows, map[string]MatchKind{
"C:/foo/b[undefined/!]r/baz": MatchAll,
}},
{"implicit descendants", "/foo\n/bar/**\n/dir/\n!/foo/skip", implicit(posix), map[string]MatchKind{
"/foo": MatchAll, "/foo/a/b": MatchAll, "/foo/skip": MatchNone, "/foo/skip/x": MatchNone,
"/bar": MatchAll, "/dir": MatchDirectory, "/dir/file": MatchAll,
}},
{"no implicit descendants", "/foo", posix, map[string]MatchKind{"/foo": MatchAll, "/foo/a": MatchNone}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g, err := Parse(tt.patterns, tt.opts)
require.NoError(t, err)
for p, want := range tt.want {
assert.Equal(t, want, g.Match(p), p)
}
})
}
}
func TestPartialMatch(t *testing.T) {
tests := []struct {
name string
patterns string
opts Options
want map[string]bool
}{
{"skips excludes", "/search1/foo/**\n/search2/bar/**\n!/search2/bar/**\n!/search3/baz/**", posix, map[string]bool{
"/search1": true, "/search1/foo": true, "/search2": true, "/search2/bar": true, "/search3": false, "/search3/bar": false,
}},
{"root", "/foo/**", posix, map[string]bool{"/": true}},
{"root on windows", "C:\\foo\\**\nd:\\foo\\**", windows, map[string]bool{`c:\`: true, `D:\`: true, `E:\`: false}},
{"case", "/Foo/Bar/**/Baz", posix, map[string]bool{"/Foo": true, "/fOO": false}},
{"case on windows", "C:/Foo/Bar/**/Baz", windows, map[string]bool{`C:\Foo`: true, `C:\fOO`: true, `C:\Other`: false}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g, err := Parse(tt.patterns, tt.opts)
require.NoError(t, err)
for p, want := range tt.want {
assert.Equal(t, want, g.PartialMatch(p), p)
}
})
}
}
func TestSearchPaths(t *testing.T) {
tests := []struct {
name string
patterns string
opts Options
want []string
}{
{"omits excludes", "/search1/foo/**\n/search2/bar/**\n!/search3/baz/**", posix, []string{"/search1/foo", "/search2/bar"}},
{
"omits descendants", "/search1/foo/**\n/search1/foo/bar/**\n/search2/foo/bar\n/search2/**\n/search3/foo/bar/**\n/search3/foo/bar/**", posix,
[]string{"/search1/foo", "/search2", "/search3/foo/bar"},
},
{
"omits descendants on windows", "C:\\Search1\\Foo\\**\nC:\\sEARCH1\\fOO\\bar\\**\nC:\\sEARCH1\\foo\\bar\nC:\\Search2\\**\nC:\\Search3\\Foo\\Bar\\**\nC:\\sEARCH3\\fOO\\bAR\\**", windows,
[]string{`C:\Search1\Foo`, `C:\Search2`, `C:\Search3\Foo\Bar`},
},
{"skips comments and blank lines", "# /nope\n\n /a/* \n", posix, []string{"/a"}},
{"splits carriage returns on windows", "C:/a/*\r\nC:/b/*\rC:/c/*", windows, []string{`C:\a`, `C:\b`, `C:\c`}},
{
"unescapes literal segments", "/foo/b[a]r/b*\n/x/b[*]r/b*\n/y/b[!]r/b*\n/z/b[]r/b*\n/q/b[aA]r/b*\n/e/b\\[a]r/b*\n/f/b[\\]]r/b*", posix,
[]string{"/foo/bar", "/x/b*r", "/y/b!r", "/z/b[]r", "/q", "/e/b[a]r", "/f/b]r"},
},
{"unescapes literal segments on windows", "C:/foo/b\\[a]r/b*\nC:/x/b[?]r/b*", windows, []string{`C:\foo\b\ar`, `C:\x\b?r`}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g, err := Parse(tt.patterns, tt.opts)
require.NoError(t, err)
assert.Equal(t, tt.want, g.SearchPaths())
})
}
}
func TestParseErrors(t *testing.T) {
tests := []struct {
name string
patterns string
opts Options
}{
{"empty after negation", "!", posix},
{"dot segment", "/a/./b", posix},
{"dot dot segment", "a/../b", posix},
{"glob in unc root", `\\srv\sh*re\a`, windows},
{"tilde without home", "~/a", Options{Workdir: "/w"}},
{"relative workdir", "a", Options{Workdir: "w"}},
{"drive relative with unc workdir", "C:a", Options{Workdir: `\\srv\share`, Windows: true}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := Parse(tt.patterns, tt.opts)
assert.Error(t, err)
})
}
}
func TestLeastCommonAncestor(t *testing.T) {
tests := []struct {
paths []string
windows bool
want string
}{
{[]string{"/a/b/c", "/a/b/d", "/a/b"}, false, "/a/b"},
{[]string{"/foo", "/bar"}, false, "/"},
{[]string{"/a"}, false, "/a"},
{[]string{`C:\a\b`, `C:\a\c`}, true, `C:\a`},
{[]string{`C:\a`, `c:\a\b`}, true, "."},
{[]string{`C:\a`, `C:\b`}, true, "C:."},
{[]string{`\\srv\share\a`, `\\srv\share\b`}, true, `\srv\share`},
}
for _, tt := range tests {
assert.Equal(t, tt.want, LeastCommonAncestor(tt.paths, tt.windows), tt.paths)
}
}
+534
View File
@@ -0,0 +1,534 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package glob
import (
"errors"
"slices"
"unicode"
"unicode/utf16"
)
// This ports the subset of minimatch 3.1.5 that @actions/glob 0.6.1 uses: options dot, nobrace,
// noext, nocomment and nonegate, with nocase on Windows. Like JS regexps without the u flag it
// works on UTF-16 code units.
const maxGlobstarRecursion = 200
type tokKind uint8
const (
tokLit tokKind = iota
tokAny // [^/]
tokStar // [^/]*?
tokClass // [...]
tokNonEmpty // (?=.)
tokNotDot // (?!(?:^|\/)\.{1,2}(?:$|\/))
)
type unitRange struct{ lo, hi uint16 }
type tok struct {
kind tokKind
ch uint16
neg bool
ranges []unitRange
}
type mmSegment struct {
globstar bool
magic bool
nocase bool
literal string
toks []tok
}
type classItem struct {
ch uint16
escaped bool
}
func isReSpecial(c uint16) bool {
switch c {
case '(', ')', '.', '*', '{', '}', '+', '?', '[', ']', '^', '$', '\\', '!':
return true
}
return false
}
func compileMinimatch(pattern string, nocase bool) ([]mmSegment, error) {
if len(utf16.Encode([]rune(pattern))) > 64*1024 {
return nil, errors.New("pattern is too long")
}
parts := splitRuns(jsTrim(pattern), '/')
segs := make([]mmSegment, len(parts))
for i, part := range parts {
segs[i] = compileSegment(part, nocase)
}
return segs, nil
}
func compileSegment(s string, nocase bool) mmSegment {
if s == "**" {
return mmSegment{globstar: true}
}
if s == "" {
return mmSegment{}
}
p := utf16.Encode([]rune(s))
toks, magic := parseSegment(p, nocase)
if !magic {
return mmSegment{literal: globUnescape(s)}
}
return mmSegment{magic: true, nocase: nocase, toks: toks}
}
// globUnescape replaces \x with x like s.replace(/\\(.)/g, '$1'); JS . excludes line terminators.
func globUnescape(s string) string {
u := utf16.Encode([]rune(s))
out := make([]uint16, 0, len(u))
for i := 0; i < len(u); i++ {
if u[i] == '\\' && i+1 < len(u) && !isLineTerminator(u[i+1]) {
i++
}
out = append(out, u[i])
}
return string(utf16.Decode(out))
}
// parseSegment ports minimatch's parse(), emitting tokens instead of a regexp source.
func parseSegment(p []uint16, nocase bool) (toks []tok, hasMagic bool) {
hasMagic = nocase
escaping := false
var stateChar uint16
inClass := false
classStart := -1
var class []classItem
lit := func(c uint16) { toks = append(toks, tok{kind: tokLit, ch: c}) }
clearStateChar := func() {
switch stateChar {
case 0:
return
case '*':
toks = append(toks, tok{kind: tokStar})
hasMagic = true
case '?':
toks = append(toks, tok{kind: tokAny})
hasMagic = true
default:
lit(stateChar)
}
stateChar = 0
}
fallback := func(cs []uint16, closing bool) {
lit('[')
// parse() returns a non-array for "" and "**", so minimatch splices in the text "undefined".
if len(cs) == 0 || (len(cs) == 2 && cs[0] == '*' && cs[1] == '*') {
for _, c := range "undefined" {
lit(uint16(c))
}
if closing {
lit(']')
}
inClass = false
return
}
subToks, subMagic := parseSegment(cs, nocase)
toks = append(toks, subToks...)
if closing {
lit(']')
}
hasMagic = hasMagic || subMagic
inClass = false
}
for i := range p {
c := p[i]
if escaping && isReSpecial(c) {
if inClass {
class = append(class, classItem{c, true})
} else {
lit(c)
}
escaping = false
continue
}
switch c {
case '\\':
clearStateChar()
escaping = true
case '?', '*', '+', '@', '!':
if inClass {
if c == '!' && i == classStart+1 {
c = '^'
}
class = append(class, classItem{c, false})
continue
}
clearStateChar()
stateChar = c
clearStateChar() // noext
case '(':
if inClass {
class = append(class, classItem{c, false})
} else {
lit(c)
}
case ')':
if inClass {
class = append(class, classItem{c, true})
} else {
lit(c)
}
case '|':
if inClass {
class = append(class, classItem{c, true})
} else {
lit(c)
}
escaping = false
case '[':
clearStateChar()
if inClass {
class = append(class, classItem{c, true})
continue
}
inClass = true
classStart = i
class = class[:0]
case ']':
if i == classStart+1 || !inClass {
if inClass {
class = append(class, classItem{c, true})
} else {
lit(c)
}
escaping = false
continue
}
t, ok := buildClass(class)
if !ok {
fallback(p[classStart+1:i], true)
continue
}
toks = append(toks, t)
hasMagic = true
inClass = false
default:
clearStateChar()
if inClass {
class = append(class, classItem{c, !escaping && isReSpecial(c) && c != '^'})
} else {
lit(c)
}
escaping = false
}
}
if inClass {
fallback(p[classStart+1:], false)
}
clearStateChar()
if escaping {
lit('\\')
}
addPatternStart := len(toks) > 0 && (toks[0].kind == tokStar || toks[0].kind == tokAny || toks[0].kind == tokClass)
if len(toks) > 0 && hasMagic {
toks = append([]tok{{kind: tokNonEmpty}}, toks...)
}
if addPatternStart && p[0] != '.' {
toks = append([]tok{{kind: tokNotDot}}, toks...)
}
return toks, hasMagic
}
// buildClass turns the class body minimatch emits into ranges, ok is false for an out of order range like [z-a], which matches literally.
func buildClass(items []classItem) (tok, bool) {
t := tok{kind: tokClass}
if len(items) > 0 && items[0].ch == '^' && !items[0].escaped {
t.neg = true
items = items[1:]
}
for i := 0; i < len(items); {
lo := items[i].ch
if i+2 < len(items) && items[i+1].ch == '-' && !items[i+1].escaped {
hi := items[i+2].ch
if lo > hi {
return t, false
}
t.ranges = append(t.ranges, unitRange{lo, hi})
i += 3
continue
}
t.ranges = append(t.ranges, unitRange{lo, lo})
i++
}
return t, true
}
func isLineTerminator(c uint16) bool {
return c == '\n' || c == '\r' || c == 0x2028 || c == 0x2029
}
// canonicalize ports the ES Canonicalize() used by non-unicode case-insensitive regexps.
func canonicalize(c uint16) uint16 {
if utf16.IsSurrogate(rune(c)) {
return c
}
up := unicode.ToUpper(rune(c))
if up > 0xffff || (c >= 128 && up < 128) {
return c
}
return uint16(up)
}
func (t *tok) classMatch(c uint16, nocase bool) bool {
in := func(v uint16) bool {
for _, r := range t.ranges {
if v >= r.lo && v <= r.hi {
return true
}
}
return false
}
hit := in(c)
if !hit && nocase && !utf16.IsSurrogate(rune(c)) {
want := canonicalize(c)
for f := unicode.SimpleFold(rune(c)); f != rune(c); f = unicode.SimpleFold(f) {
if f <= 0xffff && canonicalize(uint16(f)) == want && in(uint16(f)) {
hit = true
break
}
}
}
return hit != t.neg
}
func (s *mmSegment) match(f string) bool {
if !s.magic {
return f == s.literal
}
u := utf16.Encode([]rune(f))
n := len(u) + 1
memo := make([]int8, (len(s.toks)+1)*n)
var rec func(ti, pos int) bool
rec = func(ti, pos int) bool {
if ti == len(s.toks) {
return pos == len(u)
}
key := ti*n + pos
if memo[key] != 0 {
return memo[key] > 0
}
t := &s.toks[ti]
hit := false
switch t.kind {
case tokLit:
hit = pos < len(u) && (u[pos] == t.ch || (s.nocase && canonicalize(u[pos]) == canonicalize(t.ch))) && rec(ti+1, pos+1)
case tokAny:
hit = pos < len(u) && u[pos] != '/' && rec(ti+1, pos+1)
case tokClass:
hit = pos < len(u) && t.classMatch(u[pos], s.nocase) && rec(ti+1, pos+1)
case tokStar:
for j := pos; !hit; j++ {
hit = rec(ti+1, j)
if j == len(u) || u[j] == '/' {
break
}
}
case tokNonEmpty:
hit = pos < len(u) && !isLineTerminator(u[pos]) && rec(ti+1, pos)
case tokNotDot:
hit = !dotSegmentAhead(u, pos) && rec(ti+1, pos)
}
memo[key] = -1
if hit {
memo[key] = 1
}
return hit
}
return rec(0, 0)
}
// dotSegmentAhead evaluates (?:^|\/)\.{1,2}(?:$|\/) at pos.
func dotSegmentAhead(u []uint16, pos int) bool {
i := pos
if pos != 0 {
if i >= len(u) || u[i] != '/' {
return false
}
i++
}
for dots := 0; dots < 2 && i < len(u) && u[i] == '.'; dots++ {
i++
if i == len(u) || u[i] == '/' {
return true
}
}
return false
}
// matchOne ports Minimatch.prototype.matchOne for the single pattern set nobrace produces.
func matchOne(file []string, pattern []mmSegment, partial bool) bool {
for i := range pattern {
if pattern[i].globstar {
return matchGlobstar(file, pattern, partial, 0, 0)
}
}
return matchOneFrom(file, pattern, partial, 0)
}
func clampSlice(s []string, from, to int) []string {
to = min(to, len(s))
from = min(from, to)
return s[from:to]
}
func matchOneFrom(file []string, pattern []mmSegment, partial bool, fi int) bool {
fl, pl, pi := len(file), len(pattern), 0
for ; fi < fl && pi < pl; fi, pi = fi+1, pi+1 {
if pattern[pi].globstar || !pattern[pi].match(file[fi]) {
return false
}
}
switch {
case fi == fl && pi == pl:
return true
case fi == fl:
return partial
case pi == pl:
return fi == fl-1 && file[fi] == ""
}
return false
}
func isDotOrDotDot(f string) bool {
return f == "." || f == ".."
}
func matchGlobstar(file []string, pattern []mmSegment, partial bool, fileIndex, patternIndex int) bool {
firstgs, lastgs := -1, -1
for i := patternIndex; i < len(pattern); i++ {
if pattern[i].globstar {
firstgs = i
break
}
}
for i, seg := range slices.Backward(pattern) {
if seg.globstar {
lastgs = i
break
}
}
head := pattern[patternIndex:firstgs]
var body, tail []mmSegment
if partial {
body = pattern[firstgs+1:]
} else {
body = pattern[firstgs+1 : max(lastgs, firstgs+1)]
tail = pattern[lastgs+1:]
}
if len(head) > 0 {
if !matchOneFrom(clampSlice(file, fileIndex, fileIndex+len(head)), head, partial, 0) {
return false
}
fileIndex += len(head)
}
fileTailMatch := 0
if len(tail) > 0 {
if len(tail)+fileIndex > len(file) {
return false
}
tailStart := len(file) - len(tail)
if matchOneFrom(file, tail, partial, tailStart) {
fileTailMatch = len(tail)
} else {
// a/**/* matching a/b/
if file[len(file)-1] != "" || fileIndex+len(tail) == len(file) {
return false
}
tailStart--
if !matchOneFrom(file, tail, partial, tailStart) {
return false
}
fileTailMatch = len(tail) + 1
}
}
if len(body) == 0 {
sawSome := fileTailMatch > 0
for i := fileIndex; i < len(file)-fileTailMatch; i++ {
sawSome = true
if isDotOrDotDot(file[i]) {
return false
}
}
return partial || sawSome
}
type section struct {
body []mmSegment
after int
}
sections := []section{{}}
nonGsParts := 0
nonGsPartsSums := []int{0}
for _, b := range body {
if b.globstar {
nonGsPartsSums = append(nonGsPartsSums, nonGsParts)
sections = append(sections, section{})
} else {
sections[len(sections)-1].body = append(sections[len(sections)-1].body, b)
nonGsParts++
}
}
idx := len(sections) - 1
fileLength := len(file) - fileTailMatch
for si := range sections {
sections[si].after = fileLength - (nonGsPartsSums[idx] + len(sections[si].body))
idx--
}
// minimatch returns false (no match), null (no match, stop trying) or true.
const (
triFalse = iota
triNull
triTrue
)
var bodySections func(fileIndex, bodyIndex, depth int, sawTail bool) int
bodySections = func(fileIndex, bodyIndex, depth int, sawTail bool) int {
if bodyIndex >= len(sections) {
for i := fileIndex; i < len(file); i++ {
sawTail = true
if isDotOrDotDot(file[i]) {
return triFalse
}
}
if sawTail {
return triTrue
}
return triFalse
}
bs := sections[bodyIndex]
for ; fileIndex <= bs.after; fileIndex++ {
m := matchOneFrom(clampSlice(file, 0, fileIndex+len(bs.body)), bs.body, partial, fileIndex)
if m && depth < maxGlobstarRecursion {
if sub := bodySections(fileIndex+len(bs.body), bodyIndex+1, depth+1, sawTail); sub != triFalse {
return sub
}
}
if fileIndex < len(file) && isDotOrDotDot(file[fileIndex]) {
return triFalse
}
}
if partial {
return triTrue
}
return triNull
}
return bodySections(fileIndex, 0, 0, fileTailMatch > 0) == triTrue
}
+284
View File
@@ -0,0 +1,284 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package glob
import (
"errors"
"regexp"
"strings"
)
// flavour selects Windows or POSIX path semantics; the helpers port @actions/glob internal-path-helper.ts.
type flavour struct{ windows bool }
var (
reUNCPrefix = regexp.MustCompile(`^\\\\+[^\\]`)
reBackslashRun = regexp.MustCompile(`\\\\+`)
reSlashRun = regexp.MustCompile(`//+`)
reDriveRoot = regexp.MustCompile(`^[A-Za-z]:\\$`)
reDriveAbs = regexp.MustCompile(`^[A-Za-z]:\\`)
reDrive = regexp.MustCompile(`^[A-Za-z]:`)
reDriveOnly = regexp.MustCompile(`^[A-Za-z]:$`)
reDriveRel = regexp.MustCompile(`^[A-Za-z]:[^\\/]|^[A-Za-z]:$`)
reRelRoot = regexp.MustCompile(`^\\$|^\\[^\\]`)
reRelRootOnly = regexp.MustCompile(`^\\[^\\]`)
reDriveNoSep = regexp.MustCompile(`^[A-Za-z]:[^\\]`)
reUNCRoot = regexp.MustCompile(`^\\\\[^\\]+(\\[^\\]+)?$`)
reUNCRootSlash = regexp.MustCompile(`^\\\\[^\\]+\\[^\\]+\\$`)
)
func (w flavour) sep() string {
if w.windows {
return `\`
}
return "/"
}
func (w flavour) isSep(c byte) bool {
return c == '/' || (w.windows && c == '\\')
}
func (w flavour) normalizeSeparators(p string) string {
if w.windows {
p = strings.ReplaceAll(p, "/", `\`)
prefix := ""
if reUNCPrefix.MatchString(p) {
prefix = `\`
}
return prefix + reBackslashRun.ReplaceAllLiteralString(p, `\`)
}
return reSlashRun.ReplaceAllLiteralString(p, "/")
}
func (w flavour) safeTrimTrailingSeparator(p string) string {
if p == "" {
return ""
}
p = w.normalizeSeparators(p)
if !strings.HasSuffix(p, w.sep()) || p == w.sep() || (w.windows && reDriveRoot.MatchString(p)) {
return p
}
return p[:len(p)-1]
}
func (w flavour) hasAbsoluteRoot(p string) bool {
p = w.normalizeSeparators(p)
if w.windows {
return strings.HasPrefix(p, `\\`) || reDriveAbs.MatchString(p)
}
return strings.HasPrefix(p, "/")
}
func (w flavour) hasRoot(p string) bool {
p = w.normalizeSeparators(p)
if w.windows {
return strings.HasPrefix(p, `\`) || reDrive.MatchString(p)
}
return strings.HasPrefix(p, "/")
}
func (w flavour) dirname(p string) string {
p = w.safeTrimTrailingSeparator(p)
if w.windows && reUNCRoot.MatchString(p) {
return p
}
result := w.nodeDirname(p)
if w.windows && reUNCRootSlash.MatchString(result) {
result = w.safeTrimTrailingSeparator(result)
}
return result
}
// ensureAbsoluteRoot resolves Windows drive and root relative paths against cwd like process.cwd().
func (w flavour) ensureAbsoluteRoot(root, itemPath, cwd string) (string, error) {
if root == "" || itemPath == "" {
return "", errors.New("ensureAbsoluteRoot parameters must not be empty")
}
if w.hasAbsoluteRoot(itemPath) {
return itemPath, nil
}
if w.windows {
if reDriveRel.MatchString(itemPath) {
if !reDriveAbs.MatchString(cwd) {
return "", errors.New("expected current directory to start with an absolute drive root, actual '" + cwd + "'")
}
if strings.EqualFold(itemPath[:1], cwd[:1]) {
if len(itemPath) == 2 {
return itemPath[:1] + `:\` + cwd[3:], nil
}
if !strings.HasSuffix(cwd, `\`) {
cwd += `\`
}
return itemPath[:1] + `:\` + cwd[3:] + itemPath[2:], nil
}
return itemPath[:1] + `:\` + itemPath[2:], nil
} else if reRelRoot.MatchString(w.normalizeSeparators(itemPath)) {
if !reDriveAbs.MatchString(cwd) {
return "", errors.New("expected current directory to start with an absolute drive root, actual '" + cwd + "'")
}
return cwd[:1] + `:\` + itemPath[1:], nil
}
}
if !w.hasAbsoluteRoot(root) {
return "", errors.New("ensureAbsoluteRoot parameter 'root' must have an absolute root")
}
if !strings.HasSuffix(root, "/") && (!w.windows || !strings.HasSuffix(root, `\`)) {
root += w.sep()
}
return root + itemPath, nil
}
// splitPath ports internal-path.ts Path(string).segments.
func (w flavour) splitPath(p string) []string {
p = w.safeTrimTrailingSeparator(p)
if !w.hasRoot(p) {
return strings.Split(p, w.sep())
}
var segments []string
remaining := p
for dir := w.dirname(remaining); dir != remaining; dir = w.dirname(remaining) {
segments = append([]string{w.nodeBasename(remaining)}, segments...)
remaining = dir
}
return append([]string{remaining}, segments...)
}
// joinSegments ports internal-path.ts Path(string[]).toString().
func (w flavour) joinSegments(segments []string) string {
result := w.normalizeSeparators(segments[0])
if w.hasRoot(result) {
result = w.safeTrimTrailingSeparator(result)
}
skipSlash := strings.HasSuffix(result, w.sep()) || (w.windows && reDriveOnly.MatchString(result))
for _, s := range segments[1:] {
if skipSlash {
skipSlash = false
} else {
result += w.sep()
}
result += w.normalizeSeparators(s)
}
return result
}
func isDeviceRoot(c byte) bool {
return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')
}
// nodeDirname ports Node's path.posix.dirname and path.win32.dirname.
func (w flavour) nodeDirname(p string) string {
n := len(p)
if n == 0 {
return "."
}
if !w.windows {
hasRoot := p[0] == '/'
end := -1
matchedSlash := true
for i := n - 1; i >= 1; i-- {
if p[i] == '/' {
if !matchedSlash {
end = i
break
}
} else {
matchedSlash = false
}
}
switch {
case end == -1 && hasRoot:
return "/"
case end == -1:
return "."
case hasRoot && end == 1:
return "//"
}
return p[:end]
}
if n == 1 {
if w.isSep(p[0]) {
return p
}
return "."
}
rootEnd, offset := -1, 0
if w.isSep(p[0]) {
rootEnd, offset = 1, 1
if w.isSep(p[1]) {
j := 2
last := j
for j < n && !w.isSep(p[j]) {
j++
}
if j < n && j != last {
last = j
for j < n && w.isSep(p[j]) {
j++
}
if j < n && j != last {
last = j
for j < n && !w.isSep(p[j]) {
j++
}
if j == n {
return p
}
if j != last {
rootEnd, offset = j+1, j+1
}
}
}
}
} else if isDeviceRoot(p[0]) && p[1] == ':' {
rootEnd = 2
if n > 2 && w.isSep(p[2]) {
rootEnd = 3
}
offset = rootEnd
}
end := -1
matchedSlash := true
for i := n - 1; i >= offset; i-- {
if w.isSep(p[i]) {
if !matchedSlash {
end = i
break
}
} else {
matchedSlash = false
}
}
if end == -1 {
if rootEnd == -1 {
return "."
}
end = rootEnd
}
return p[:end]
}
// nodeBasename ports Node's path.posix.basename and path.win32.basename without the ext argument.
func (w flavour) nodeBasename(p string) string {
start := 0
if w.windows && len(p) >= 2 && isDeviceRoot(p[0]) && p[1] == ':' {
start = 2
}
from, end := start, -1
matchedSlash := true
for i := len(p) - 1; i >= start; i-- {
if w.isSep(p[i]) {
if !matchedSlash {
from = i + 1
break
}
} else if end == -1 {
matchedSlash = false
end = i + 1
}
}
if end == -1 {
return ""
}
return p[from:end]
}
+269
View File
@@ -0,0 +1,269 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package glob
import (
"errors"
"fmt"
"strings"
"unicode/utf16"
)
// pattern ports @actions/glob internal-pattern.ts.
type pattern struct {
negate bool
searchPath string
segments []string
trailingSeparator bool
implicit bool
rootLiteral string
mm []mmSegment
w flavour
}
type parseEnv struct {
w flavour
cwd string
home func() (string, error)
}
func newPattern(s string, implicit bool, env parseEnv) (*pattern, error) {
w := env.w
p := &pattern{implicit: implicit, w: w}
s = jsTrim(s)
for strings.HasPrefix(s, "!") {
p.negate = !p.negate
s = jsTrim(s[1:])
}
s, err := fixupPattern(s, env)
if err != nil {
return nil, err
}
p.segments = w.splitPath(s)
p.trailingSeparator = strings.HasSuffix(w.normalizeSeparators(s), w.sep())
s = w.safeTrimTrailingSeparator(s)
var searchSegments []string
for _, seg := range p.segments {
lit := getLiteral(seg, w)
if lit == "" {
break
}
searchSegments = append(searchSegments, lit)
}
p.searchPath = w.joinSegments(searchSegments)
p.rootLiteral = searchSegments[0]
if w.windows {
s = strings.ReplaceAll(s, `\`, "/")
}
p.mm, err = compileMinimatch(s, w.windows)
if err != nil {
return nil, err
}
return p, nil
}
func fixupPattern(s string, env parseEnv) (string, error) {
w := env.w
if s == "" {
return "", errors.New("pattern cannot be empty")
}
segments := w.splitPath(s)
for i, seg := range segments {
if lit := getLiteral(seg, w); (lit == "." && i != 0) || lit == ".." {
return "", fmt.Errorf("invalid pattern '%s': relative pathing '.' and '..' is not allowed", s)
}
}
if w.hasRoot(s) && getLiteral(segments[0], w) == "" {
return "", fmt.Errorf("invalid pattern '%s': root segment must not contain globs", s)
}
s = w.normalizeSeparators(s)
sep := w.sep()
var root string
var err error
switch {
case s == "." || strings.HasPrefix(s, "."+sep):
s = globEscape(env.cwd, w) + s[1:]
case s == "~" || strings.HasPrefix(s, "~"+sep):
var home string
if env.home != nil {
if home, err = env.home(); err != nil {
return "", err
}
}
if home == "" {
return "", errors.New("unable to determine HOME directory")
}
if home = w.normalizeSeparators(home); !w.hasAbsoluteRoot(home) {
return "", fmt.Errorf("expected HOME directory to be a rooted path, actual '%s'", home)
}
s = globEscape(home, w) + s[1:]
case w.windows && (reDriveOnly.MatchString(s) || reDriveNoSep.MatchString(s)):
if root, err = w.ensureAbsoluteRoot(`C:\dummy-root`, s[:2], env.cwd); err != nil {
return "", err
}
if len(s) > 2 && !strings.HasSuffix(root, `\`) {
root += `\`
}
s = globEscape(root, w) + s[2:]
case w.windows && (s == `\` || reRelRootOnly.MatchString(s)):
if root, err = w.ensureAbsoluteRoot(`C:\dummy-root`, `\`, env.cwd); err != nil {
return "", err
}
if !strings.HasSuffix(root, `\`) {
root += `\`
}
s = globEscape(root, w) + s[1:]
default:
if s, err = w.ensureAbsoluteRoot(globEscape(env.cwd, w), s, env.cwd); err != nil {
return "", err
}
}
return w.normalizeSeparators(s), nil
}
func (p *pattern) match(itemPath string) MatchKind {
w := p.w
if p.segments[len(p.segments)-1] == "**" {
itemPath = w.normalizeSeparators(itemPath)
// Without the trailing separator minimatch would not match the directory preceding the globstar.
if !strings.HasSuffix(itemPath, w.sep()) && !p.implicit {
itemPath += w.sep()
}
} else {
itemPath = w.safeTrimTrailingSeparator(itemPath)
}
if w.windows {
itemPath = strings.ReplaceAll(itemPath, `\`, "/")
}
if !matchOne(splitRuns(itemPath, '/'), p.mm, false) {
return MatchNone
}
if p.trailingSeparator {
return MatchDirectory
}
return MatchAll
}
func (p *pattern) partialMatch(itemPath string) bool {
w := p.w
itemPath = w.safeTrimTrailingSeparator(itemPath)
// The toolkit tests roots with an unanchored regexp because minimatch mishandles them.
if w.dirname(itemPath) == itemPath {
if w.windows {
return strings.Contains(strings.ToUpper(itemPath), strings.ToUpper(p.rootLiteral))
}
return strings.Contains(itemPath, p.rootLiteral)
}
sep := byte('/')
if w.windows {
sep = '\\'
}
return matchOne(splitRuns(itemPath, sep), p.mm, true)
}
// globEscape ports Pattern.globEscape.
func globEscape(s string, w flavour) string {
if !w.windows {
s = strings.ReplaceAll(s, `\`, `\\`)
}
var b strings.Builder
for i := 0; i < len(s); i++ {
if s[i] == '[' && closesBeforeSlash(s[i+1:]) {
b.WriteString("[[]")
continue
}
b.WriteByte(s[i])
}
s = strings.ReplaceAll(b.String(), "?", "[?]")
return strings.ReplaceAll(s, "*", "[*]")
}
// closesBeforeSlash reports whether s matches the lookahead `[^/]+\]`.
func closesBeforeSlash(s string) bool {
for i := 0; i < len(s) && s[i] != '/'; i++ {
if s[i] == ']' && i > 0 {
return true
}
}
return false
}
// getLiteral ports Pattern.getLiteral, returning "" when the segment contains a glob.
func getLiteral(segment string, w flavour) string {
seg := utf16.Encode([]rune(segment))
var lit []uint16
for i := 0; i < len(seg); i++ {
c := seg[i]
switch {
case c == '\\' && !w.windows && i+1 < len(seg):
i++
lit = append(lit, seg[i])
continue
case c == '*' || c == '?':
return ""
case c == '[' && i+1 < len(seg):
var set []uint16
closed := -1
for i2 := i + 1; i2 < len(seg); i2++ {
c2 := seg[i2]
if c2 == '\\' && !w.windows && i2+1 < len(seg) {
i2++
set = append(set, seg[i2])
continue
} else if c2 == ']' {
closed = i2
break
}
set = append(set, c2)
}
if closed >= 0 {
if len(set) > 1 {
return ""
}
if len(set) == 1 {
lit = append(lit, set...)
i = closed
continue
}
}
}
lit = append(lit, c)
}
return string(utf16.Decode(lit))
}
func isJSWhitespace(r rune) bool {
switch r {
case '\t', '\n', '\v', '\f', '\r', ' ', 0xa0, 0x1680, 0x2028, 0x2029, 0x202f, 0x205f, 0x3000, 0xfeff:
return true
}
return r >= 0x2000 && r <= 0x200a
}
// jsTrim matches String.prototype.trim, which differs from strings.TrimSpace on U+0085 and U+FEFF.
func jsTrim(s string) string {
return strings.TrimFunc(s, isJSWhitespace)
}
// splitRuns splits like JS s.split(/c+/).
func splitRuns(s string, c byte) []string {
parts := []string{}
start := 0
for i := 0; i < len(s); {
if s[i] != c {
i++
continue
}
parts = append(parts, s[start:i])
for i < len(s) && s[i] == c {
i++
}
start = i
}
return append(parts, s[start:])
}