mirror of
https://gitea.com/gitea/runner.git
synced 2026-11-06 07:10:28 +00:00
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:
1 parent
bb6d793cb9
commit
b88d111cc5
5 files changed
+1542
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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:])
|
||||
}
|
||||
Reference in new issue
Block a user