From f54bc69b7a7f3c2a9fbc1e641d54450ebb4aae89 Mon Sep 17 00:00:00 2001 From: Larry Clapp Date: Tue, 16 Apr 2024 11:29:52 -0400 Subject: [PATCH] Allow case-insensitive filename globbing - Add a FoldCase flag to the expand.Config struct, which is used to configure how expand.Fields behaves. - Similarly, add a FoldCase Mode to pattern.Regexp. Setting the flag adds (?i) to the front of the generated regular expression. - Use the former to set the latter when calling expand.Fields. Rationale: I use expand.Fields to do filename expansion, for which I'd like to do case-insensitive matching. E.g. I'd like "c*" to match both "cmd" and "CHANGELOG.md" --- expand/expand.go | 9 +++++++- expand/expand_test.go | 50 +++++++++++++++++++++++++++++++++++++++++++ pattern/pattern.go | 6 +++++- 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/expand/expand.go b/expand/expand.go index e4cabb021..98c7bd757 100644 --- a/expand/expand.go +++ b/expand/expand.go @@ -76,6 +76,9 @@ type Config struct { // as errors. NoUnset bool + // FoldCase causes case-insensitive pattern matching in file-name globbing. + FoldCase bool + bufferAlloc bytes.Buffer // TODO: use strings.Builder fieldAlloc [4]fieldPart fieldsAlloc [4][]fieldPart @@ -946,7 +949,11 @@ func (cfg *Config) glob(base, pat string) ([]string, error) { } continue } - expr, err := pattern.Regexp(part, pattern.Filenames|pattern.EntireString) + mode := pattern.Filenames | pattern.EntireString + if cfg.FoldCase { + mode |= pattern.FoldCase + } + expr, err := pattern.Regexp(part, mode) if err != nil { return nil, err } diff --git a/expand/expand_test.go b/expand/expand_test.go index 5951e1234..6a0549a29 100644 --- a/expand/expand_test.go +++ b/expand/expand_test.go @@ -4,6 +4,7 @@ package expand import ( + "io/fs" "os" "reflect" "strings" @@ -90,3 +91,52 @@ func TestFieldsIdempotency(t *testing.T) { } } } + +func Test_glob(t *testing.T) { + cfg := &Config{ + ReadDir2: func(string) ([]fs.DirEntry, error) { + return []fs.DirEntry{ + &mockFileInfo{name: "a"}, + &mockFileInfo{name: "ab"}, + &mockFileInfo{name: "A"}, + &mockFileInfo{name: "AB"}, + }, nil + }, + } + + tests := []struct { + foldCase bool + pat string + want []string + }{ + {false, "a*", []string{"a", "ab"}}, + {false, "A*", []string{"A", "AB"}}, + {false, "*b", []string{"ab"}}, + {false, "b*", nil}, + {true, "a*", []string{"a", "ab", "A", "AB"}}, + {true, "A*", []string{"a", "ab", "A", "AB"}}, + {true, "*b", []string{"ab", "AB"}}, + {true, "b*", nil}, + } + for _, tc := range tests { + cfg.FoldCase = tc.foldCase + got, err := cfg.glob("/", tc.pat) + if err != nil { + t.Fatalf("did not want error, got %v", err) + } + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("wanted %q, got %q", tc.want, got) + } + } +} + +type mockFileInfo struct { + name string + typ fs.FileMode + fs.DirEntry // Stub out everything but Name() & Type() +} + +var _ fs.DirEntry = (*mockFileInfo)(nil) + +func (fi *mockFileInfo) Name() string { return fi.name } +func (fi *mockFileInfo) Type() fs.FileMode { return fi.typ } diff --git a/pattern/pattern.go b/pattern/pattern.go index 7cd98d149..a1f3c4186 100644 --- a/pattern/pattern.go +++ b/pattern/pattern.go @@ -34,6 +34,7 @@ const ( Filenames // "*" and "?" don't match slashes; only "**" does Braces // support "{a,b}" and "{1..4}" EntireString // match the entire string using ^$ delimiters + FoldCase // Do case-insensitive match (that is, use (?i) in the regexp) ) var numRange = regexp.MustCompile(`^([+-]?\d+)\.\.([+-]?\d+)}`) @@ -68,6 +69,9 @@ noopLoop: // Enable matching `\n` with the `.` metacharacter as globs match `\n` buf.WriteString("(?s)") dotMeta := false + if mode&FoldCase != 0 { + buf.WriteString("(?i)") + } if mode&EntireString != 0 { buf.WriteString("^") } @@ -242,7 +246,7 @@ writeLoop: if mode&EntireString != 0 { buf.WriteString("$") } - // No `.` metacharacters were used, so don't return the flag. + // No `.` metacharacters were used, so don't return the (?s) flag. if !dotMeta { return string(buf.Bytes()[4:]), nil }