Skip to content

Commit 8cf3fc3

Browse files
committed
Add WithCaseInsensitive GlobOption
This PR adds the `WithCaseInsensitive` `GlobOption` to allow for case insensitive matches in glob paths.
1 parent 5da61cf commit 8cf3fc3

File tree

5 files changed

+79
-52
lines changed

5 files changed

+79
-52
lines changed

doublestar_test.go

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ var matchTests = []MatchTest{
3535
{"/*", "/debug/", false, false, nil, false, false, true, false, 0, 0},
3636
{"/*", "//", false, false, nil, false, false, true, false, 0, 0},
3737
{"abc", "abc", true, true, nil, false, false, true, true, 1, 1},
38-
{"*", "abc", true, true, nil, false, false, true, true, 23, 18},
38+
{"*", "abc", true, true, nil, false, false, true, true, 24, 18},
3939
{"*c", "abc", true, true, nil, false, false, true, true, 2, 2},
4040
{"*/", "a/", true, true, nil, false, false, true, false, 0, 0},
4141
{"a*", "a", true, true, nil, false, false, true, true, 9, 9},
@@ -63,8 +63,8 @@ var matchTests = []MatchTest{
6363
{"a[!a]b", "a☺b", true, true, nil, false, false, false, true, 1, 1},
6464
{"a???b", "a☺b", false, false, nil, false, false, true, true, 0, 0},
6565
{"a[^a][^a][^a]b", "a☺b", false, false, nil, false, false, true, true, 0, 0},
66-
{"[a-ζ]*", "α", true, true, nil, false, false, true, true, 20, 17},
67-
{"*[a-ζ]", "A", false, false, nil, false, false, true, true, 20, 17},
66+
{"[a-ζ]*", "α", true, true, nil, false, false, true, true, 21, 17},
67+
{"*[a-ζ]", "A", false, false, nil, false, false, true, true, 21, 17},
6868
{"a?b", "a/b", false, false, nil, false, false, true, true, 1, 1},
6969
{"a*b", "a/b", false, false, nil, false, false, true, true, 1, 1},
7070
{"[\\]a]", "]", true, true, nil, false, false, true, !onWindows, 2, 2},
@@ -297,8 +297,8 @@ func TestMatchUnvalidated(t *testing.T) {
297297
{"[", "a", ErrBadPattern, ErrBadPattern}, // Error right up front, needs to fail whether validate or not
298298
}
299299
for idx, tt := range unvalidatedTests {
300-
_, errValidated := matchWithSeparator(tt.pattern, tt.testPath, '/', true)
301-
_, errUnvalidated := matchWithSeparator(tt.pattern, tt.testPath, '/', false)
300+
_, errValidated := matchWithSeparator(tt.pattern, tt.testPath, '/', true, false)
301+
_, errUnvalidated := matchWithSeparator(tt.pattern, tt.testPath, '/', false, false)
302302
if errValidated != tt.expectedErrValidated {
303303
t.Errorf("#%v. Validated error of Match(%#q, %#q) = %v want %v", idx, tt.pattern, tt.testPath, errValidated, tt.expectedErrValidated)
304304
}
@@ -393,7 +393,7 @@ func testPathMatchFakeWith(t *testing.T, idx int, tt MatchTest) {
393393

394394
pattern := strings.ReplaceAll(tt.pattern, "/", "\\")
395395
testPath := strings.ReplaceAll(tt.testPath, "/", "\\")
396-
ok, err := matchWithSeparator(pattern, testPath, '\\', true)
396+
ok, err := matchWithSeparator(pattern, testPath, '\\', true, false)
397397
if ok != tt.shouldMatch || err != tt.expectedErr {
398398
t.Errorf("#%v. PathMatch(%#q, %#q) = %v, %v want %v, %v", idx, pattern, testPath, ok, err, tt.shouldMatch, tt.expectedErr)
399399
}
@@ -535,26 +535,36 @@ func testStandardGlob(t *testing.T, idx int, fn string, tt MatchTest, fsys fs.FS
535535
}
536536

537537
func TestFilepathGlob(t *testing.T) {
538-
doFilepathGlobTest(t)
538+
doFilepathGlobTest(t, matchTests)
539539
}
540540

541541
func TestFilepathGlobWithFailOnIOErrors(t *testing.T) {
542-
doFilepathGlobTest(t, WithFailOnIOErrors())
542+
doFilepathGlobTest(t, matchTests, WithFailOnIOErrors())
543543
}
544544

545545
func TestFilepathGlobWithFailOnPatternNotExist(t *testing.T) {
546-
doFilepathGlobTest(t, WithFailOnPatternNotExist())
546+
doFilepathGlobTest(t, matchTests, WithFailOnPatternNotExist())
547547
}
548548

549549
func TestFilepathGlobWithFilesOnly(t *testing.T) {
550-
doFilepathGlobTest(t, WithFilesOnly())
550+
doFilepathGlobTest(t, matchTests, WithFilesOnly())
551551
}
552552

553553
func TestFilepathGlobWithNoFollow(t *testing.T) {
554-
doFilepathGlobTest(t, WithNoFollow())
554+
doFilepathGlobTest(t, matchTests, WithNoFollow())
555555
}
556556

557-
func doFilepathGlobTest(t *testing.T, opts ...GlobOption) {
557+
func TestFilepathGlobWithCaseInsensitive(t *testing.T) {
558+
var insensitiveTest, sensitiveTest MatchTest
559+
insensitiveTest = MatchTest{"**/test", "cases", false, false, nil, false, false, false, !onWindows, 1, 1}
560+
sensitiveTest = insensitiveTest
561+
sensitiveTest.numResults = 3
562+
563+
doFilepathGlobTest(t, []MatchTest{insensitiveTest})
564+
doFilepathGlobTest(t, []MatchTest{sensitiveTest}, WithCaseInsensitive())
565+
}
566+
567+
func doFilepathGlobTest(t *testing.T, tests []MatchTest, opts ...GlobOption) {
558568
glob := newGlob(opts...)
559569
fsys := os.DirFS("test")
560570

@@ -564,7 +574,7 @@ func doFilepathGlobTest(t *testing.T, opts ...GlobOption) {
564574
}()
565575
os.Chdir("test")
566576

567-
for idx, tt := range matchTests {
577+
for idx, tt := range tests {
568578
// Patterns ending with a slash are treated semantically different by
569579
// FilepathGlob vs Glob because FilepathGlob runs filepath.Clean, which
570580
// will remove the trailing slash.
@@ -822,6 +832,9 @@ func TestMain(m *testing.M) {
822832
mkdirp("test", "axbxcxdxexxx")
823833
mkdirp("test", "b")
824834
mkdirp("test", "e", "[x]", "[y]")
835+
mkdirp("test", "cases", "test_a")
836+
mkdirp("test", "cases", "test_b")
837+
mkdirp("test", "cases", "test_c")
825838

826839
// create test files
827840
touch("test", "1")
@@ -853,6 +866,10 @@ func TestMain(m *testing.M) {
853866
touch("test", "e", "[]")
854867
touch("test", "e", "[x]", "[y]", "z")
855868

869+
touch("test", "cases", "test_a", "test")
870+
touch("test", "cases", "test_b", "TEST")
871+
touch("test", "cases", "test_c", "Test")
872+
856873
touch("test", "}")
857874

858875
if !onWindows {

glob.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,7 @@ func (g *glob) globDir(fsys fs.FS, dir, pattern string, matches []string, canMat
225225
var matched bool
226226
for _, info := range dirs {
227227
name := info.Name()
228-
matched, e = matchWithSeparator(pattern, name, '/', false)
228+
matched, e = matchWithSeparator(pattern, name, '/', false, g.alphaCaseInsensitive)
229229
if e != nil {
230230
return
231231
}

globoptions.go

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ type glob struct {
88
failOnPatternNotExist bool
99
filesOnly bool
1010
noFollow bool
11+
alphaCaseInsensitive bool
1112
}
1213

1314
// GlobOption represents a setting that can be passed to Glob, GlobWalk, and
@@ -28,7 +29,6 @@ func newGlob(opts ...GlobOption) *glob {
2829
// encountered. Note that if the glob pattern references a path that does not
2930
// exist (such as `nonexistent/path/*`), this is _not_ considered an IO error:
3031
// it is considered a pattern with no matches.
31-
//
3232
func WithFailOnIOErrors() GlobOption {
3333
return func(g *glob) {
3434
g.failOnIOErrors = true
@@ -42,7 +42,6 @@ func WithFailOnIOErrors() GlobOption {
4242
// `{...}`) are expanded before this check. In other words, a pattern such as
4343
// `{a,b}/*` may fail if either `a` or `b` do not exist but `*/{a,b}` will
4444
// never fail because the star may match nothing.
45-
//
4645
func WithFailOnPatternNotExist() GlobOption {
4746
return func(g *glob) {
4847
g.failOnPatternNotExist = true
@@ -56,7 +55,6 @@ func WithFailOnPatternNotExist() GlobOption {
5655
// Note: if combined with the WithNoFollow option, symlinks to directories
5756
// _will_ be included in the result since no attempt is made to follow the
5857
// symlink.
59-
//
6058
func WithFilesOnly() GlobOption {
6159
return func(g *glob) {
6260
g.filesOnly = true
@@ -76,17 +74,25 @@ func WithFilesOnly() GlobOption {
7674
// Note: if combined with the WithFilesOnly option, symlinks to directories
7775
// _will_ be included in the result since no attempt is made to follow the
7876
// symlink.
79-
//
8077
func WithNoFollow() GlobOption {
8178
return func(g *glob) {
8279
g.noFollow = true
8380
}
8481
}
8582

83+
// WithCaseInsensitive is an option that can be passed to Glob, GlobWalk, or
84+
// FilepathGlob. If passed, doublestar will treat all alphabetic characters as
85+
// case insensitive (i.e. "a" in the pattern would match "a" or "A"). This is
86+
// useful for platforms like Windows where paths are case insensitive by default.
87+
func WithCaseInsensitive() GlobOption {
88+
return func(g *glob) {
89+
g.alphaCaseInsensitive = true
90+
}
91+
}
92+
8693
// forwardErrIfFailOnIOErrors is used to wrap the return values of I/O
8794
// functions. When failOnIOErrors is enabled, it will return err; otherwise, it
8895
// always returns nil.
89-
//
9096
func (g *glob) forwardErrIfFailOnIOErrors(err error) error {
9197
if g.failOnIOErrors {
9298
return err
@@ -97,7 +103,6 @@ func (g *glob) forwardErrIfFailOnIOErrors(err error) error {
97103
// handleErrNotExist handles fs.ErrNotExist errors. If
98104
// WithFailOnPatternNotExist has been enabled and canFail is true, this will
99105
// return ErrPatternNotExist. Otherwise, it will return nil.
100-
//
101106
func (g *glob) handlePatternNotExist(canFail bool) error {
102107
if canFail && g.failOnPatternNotExist {
103108
return ErrPatternNotExist

globwalk.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,6 @@ type GlobWalkFunc func(path string, d fs.DirEntry) error
5050
//
5151
// Note: users should _not_ count on the returned error,
5252
// doublestar.ErrBadPattern, being equal to path.ErrBadPattern.
53-
//
5453
func GlobWalk(fsys fs.FS, pattern string, fn GlobWalkFunc, opts ...GlobOption) error {
5554
if !ValidatePattern(pattern) {
5655
return ErrBadPattern
@@ -290,7 +289,7 @@ func (g *glob) globDirWalk(fsys fs.FS, dir, pattern string, canMatchFiles, befor
290289
var matched bool
291290
for _, info := range dirs {
292291
name := info.Name()
293-
matched, e = matchWithSeparator(pattern, name, '/', false)
292+
matched, e = matchWithSeparator(pattern, name, '/', false, g.alphaCaseInsensitive)
294293
if e != nil {
295294
return
296295
}

match.go

Lines changed: 36 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,30 +2,31 @@ package doublestar
22

33
import (
44
"path/filepath"
5+
"unicode"
56
"unicode/utf8"
67
)
78

89
// Match reports whether name matches the shell pattern.
910
// The pattern syntax is:
1011
//
11-
// pattern:
12-
// { term }
13-
// term:
14-
// '*' matches any sequence of non-path-separators
15-
// '/**/' matches zero or more directories
16-
// '?' matches any single non-path-separator character
17-
// '[' [ '^' '!' ] { character-range } ']'
18-
// character class (must be non-empty)
19-
// starting with `^` or `!` negates the class
20-
// '{' { term } [ ',' { term } ... ] '}'
21-
// alternatives
22-
// c matches character c (c != '*', '?', '\\', '[')
23-
// '\\' c matches character c
12+
// pattern:
13+
// { term }
14+
// term:
15+
// '*' matches any sequence of non-path-separators
16+
// '/**/' matches zero or more directories
17+
// '?' matches any single non-path-separator character
18+
// '[' [ '^' '!' ] { character-range } ']'
19+
// character class (must be non-empty)
20+
// starting with `^` or `!` negates the class
21+
// '{' { term } [ ',' { term } ... ] '}'
22+
// alternatives
23+
// c matches character c (c != '*', '?', '\\', '[')
24+
// '\\' c matches character c
2425
//
25-
// character-range:
26-
// c matches character c (c != '\\', '-', ']')
27-
// '\\' c matches character c
28-
// lo '-' hi matches character c for lo <= c <= hi
26+
// character-range:
27+
// c matches character c (c != '\\', '-', ']')
28+
// '\\' c matches character c
29+
// lo '-' hi matches character c for lo <= c <= hi
2930
//
3031
// Match returns true if `name` matches the file name `pattern`. `name` and
3132
// `pattern` are split on forward slash (`/`) characters and may be relative or
@@ -48,9 +49,8 @@ import (
4849
//
4950
// Note: users should _not_ count on the returned error,
5051
// doublestar.ErrBadPattern, being equal to path.ErrBadPattern.
51-
//
5252
func Match(pattern, name string) (bool, error) {
53-
return matchWithSeparator(pattern, name, '/', true)
53+
return matchWithSeparator(pattern, name, '/', true, false)
5454
}
5555

5656
// MatchUnvalidated can provide a small performance improvement if you don't
@@ -60,7 +60,7 @@ func Match(pattern, name string) (bool, error) {
6060
// of `name` before reaching the end of `pattern`, such as `Match("a/b/c",
6161
// "a")`.
6262
func MatchUnvalidated(pattern, name string) bool {
63-
matched, _ := matchWithSeparator(pattern, name, '/', false)
63+
matched, _ := matchWithSeparator(pattern, name, '/', false, false)
6464
return matched
6565
}
6666

@@ -73,9 +73,8 @@ func MatchUnvalidated(pattern, name string) bool {
7373
// assumes that both `pattern` and `name` are using the system's path
7474
// separator. If you can't be sure of that, use filepath.ToSlash() on both
7575
// `pattern` and `name`, and then use the Match() function instead.
76-
//
7776
func PathMatch(pattern, name string) (bool, error) {
78-
return matchWithSeparator(pattern, name, filepath.Separator, true)
77+
return matchWithSeparator(pattern, name, filepath.Separator, true, false)
7978
}
8079

8180
// PathMatchUnvalidated can provide a small performance improvement if you
@@ -85,15 +84,15 @@ func PathMatch(pattern, name string) (bool, error) {
8584
// end of `name` before reaching the end of `pattern`, such as `Match("a/b/c",
8685
// "a")`.
8786
func PathMatchUnvalidated(pattern, name string) bool {
88-
matched, _ := matchWithSeparator(pattern, name, filepath.Separator, false)
87+
matched, _ := matchWithSeparator(pattern, name, filepath.Separator, false, false)
8988
return matched
9089
}
9190

92-
func matchWithSeparator(pattern, name string, separator rune, validate bool) (matched bool, err error) {
93-
return doMatchWithSeparator(pattern, name, separator, validate, -1, -1, -1, -1, 0, 0)
91+
func matchWithSeparator(pattern, name string, separator rune, validate bool, caseInsensitive bool) (matched bool, err error) {
92+
return doMatchWithSeparator(pattern, name, separator, validate, caseInsensitive, -1, -1, -1, -1, 0, 0)
9493
}
9594

96-
func doMatchWithSeparator(pattern, name string, separator rune, validate bool, doublestarPatternBacktrack, doublestarNameBacktrack, starPatternBacktrack, starNameBacktrack, patIdx, nameIdx int) (matched bool, err error) {
95+
func doMatchWithSeparator(pattern, name string, separator rune, validate bool, caseInsensitive bool, doublestarPatternBacktrack, doublestarNameBacktrack, starPatternBacktrack, starNameBacktrack, patIdx, nameIdx int) (matched bool, err error) {
9796
patLen := len(pattern)
9897
nameLen := len(name)
9998
startOfSegment := true
@@ -194,7 +193,7 @@ MATCH:
194193
}
195194

196195
// check if the rune matches
197-
if patRune == nameRune {
196+
if matchRune(patRune, nameRune, caseInsensitive) {
198197
matched = true
199198
break
200199
}
@@ -240,14 +239,14 @@ MATCH:
240239
}
241240
commaIdx += patIdx
242241

243-
result, err := doMatchWithSeparator(pattern[:beforeIdx]+pattern[patIdx:commaIdx]+pattern[closingIdx+1:], name, separator, validate, doublestarPatternBacktrack, doublestarNameBacktrack, starPatternBacktrack, starNameBacktrack, beforeIdx, nameIdx)
242+
result, err := doMatchWithSeparator(pattern[:beforeIdx]+pattern[patIdx:commaIdx]+pattern[closingIdx+1:], name, separator, validate, caseInsensitive, doublestarPatternBacktrack, doublestarNameBacktrack, starPatternBacktrack, starNameBacktrack, beforeIdx, nameIdx)
244243
if result || err != nil {
245244
return result, err
246245
}
247246

248247
patIdx = commaIdx + 1
249248
}
250-
return doMatchWithSeparator(pattern[:beforeIdx]+pattern[patIdx:closingIdx]+pattern[closingIdx+1:], name, separator, validate, doublestarPatternBacktrack, doublestarNameBacktrack, starPatternBacktrack, starNameBacktrack, beforeIdx, nameIdx)
249+
return doMatchWithSeparator(pattern[:beforeIdx]+pattern[patIdx:closingIdx]+pattern[closingIdx+1:], name, separator, validate, caseInsensitive, doublestarPatternBacktrack, doublestarNameBacktrack, starPatternBacktrack, starNameBacktrack, beforeIdx, nameIdx)
251250

252251
case '\\':
253252
if separator != '\\' {
@@ -262,7 +261,7 @@ MATCH:
262261
default:
263262
patRune, patRuneLen := utf8.DecodeRuneInString(pattern[patIdx:])
264263
nameRune, nameRuneLen := utf8.DecodeRuneInString(name[nameIdx:])
265-
if patRune != nameRune {
264+
if !matchRune(patRune, nameRune, caseInsensitive) {
266265
if separator != '\\' && patIdx > 0 && pattern[patIdx-1] == '\\' {
267266
// if this rune was meant to be escaped, we need to move patIdx
268267
// back to the backslash before backtracking or validating below
@@ -317,6 +316,13 @@ MATCH:
317316
return isZeroLengthPattern(pattern[patIdx:], separator, validate)
318317
}
319318

319+
func matchRune(a, b rune, caseInsensitive bool) bool {
320+
if caseInsensitive {
321+
return unicode.ToLower(a) == unicode.ToLower(b)
322+
}
323+
return a == b
324+
}
325+
320326
func isZeroLengthPattern(pattern string, separator rune, validate bool) (ret bool, err error) {
321327
// `/**`, `**/`, and `/**/` are special cases - a pattern such as `path/to/a/**` or `path/to/a/**/`
322328
// *should* match `path/to/a` because `a` might be a directory

0 commit comments

Comments
 (0)