mirror of
https://gitea.com/gitea/runner.git
synced 2026-11-14 03:43:42 +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)
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -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