From 1b907b711156f028c67aa9fe497f649dd3fc9d15 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 nocaseglob - 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. - Use all of the above to implement the "nocaseglob" shopt. 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 | 10 ++++++++- expand/expand_test.go | 50 +++++++++++++++++++++++++++++++++++++++++++ interp/api.go | 10 ++++++++- interp/interp_test.go | 18 ++++++++++++++++ interp/runner.go | 1 + pattern/pattern.go | 6 +++++- 6 files changed, 92 insertions(+), 3 deletions(-) diff --git a/expand/expand.go b/expand/expand.go index e4cabb021..4e38d437e 100644 --- a/expand/expand.go +++ b/expand/expand.go @@ -68,6 +68,10 @@ type Config struct { // "**". GlobStar bool + // NoCaseGlob corresponds to the shell option that causes case-insensitive + // pattern matching in pathname expansion. + NoCaseGlob bool + // NullGlob corresponds to the shell option that allows globbing // patterns which match nothing to result in zero fields. NullGlob bool @@ -946,7 +950,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.NoCaseGlob { + mode |= pattern.NoGlobCase + } + 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..ede52c5eb 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 { + noCaseGlob 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.NoCaseGlob = tc.noCaseGlob + 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/interp/api.go b/interp/api.go index 92eb86663..67ca89aca 100644 --- a/interp/api.go +++ b/interp/api.go @@ -493,6 +493,11 @@ var bashOptsTable = [...]bashOpt{ defaultState: false, supported: true, }, + { + name: "nocaseglob", + defaultState: false, + supported: true, + }, { name: "nullglob", defaultState: false, @@ -565,7 +570,6 @@ var bashOptsTable = [...]bashOpt{ {name: "login_shell"}, {name: "mailwarn"}, {name: "no_empty_cmd_completion"}, - {name: "nocaseglob"}, {name: "nocasematch"}, { name: "progcomp", @@ -589,6 +593,7 @@ var bashOptsTable = [...]bashOpt{ // know which option we're after at compile time. First come the shell options, // then the bash options. const ( + // These correspond to indexes in shellOptsTable optAllExport = iota optErrExit optNoExec @@ -597,8 +602,11 @@ const ( optXTrace optPipeFail + // These correspond to indexes (offset by the above seven items) of + // supported options in bashOptsTable optExpandAliases optGlobStar + optNoCaseGlob optNullGlob ) diff --git a/interp/interp_test.go b/interp/interp_test.go index f65b721c2..d23124121 100644 --- a/interp/interp_test.go +++ b/interp/interp_test.go @@ -2139,6 +2139,24 @@ done <<< 2`, "shopt -s foo", "shopt: invalid option name \"foo\"\nexit status 1 #JUSTERR", }, + { + // Beware that macOS file systems are by default case-preserving but + // case-insensitive, so e.g. "touch x X" creates only one file. + "touch a ab Ac Ad; shopt -u nocaseglob; echo a*", + "a ab\n", + }, + { + "touch a ab Ac Ad; shopt -s nocaseglob; echo a*", + "Ac Ad a ab\n", + }, + { + "touch a ab abB Ac Ad; shopt -u nocaseglob; echo *b", + "ab\n", + }, + { + "touch a ab abB Ac Ad; shopt -s nocaseglob; echo *b", + "ab abB\n", + }, // IFS {`echo -n "$IFS"`, " \t\n"}, diff --git a/interp/runner.go b/interp/runner.go index 7df486fd7..54d747ffc 100644 --- a/interp/runner.go +++ b/interp/runner.go @@ -163,6 +163,7 @@ func (r *Runner) updateExpandOpts() { } } r.ecfg.GlobStar = r.opts[optGlobStar] + r.ecfg.NoCaseGlob = r.opts[optNoCaseGlob] r.ecfg.NullGlob = r.opts[optNullGlob] r.ecfg.NoUnset = r.opts[optNoUnset] } diff --git a/pattern/pattern.go b/pattern/pattern.go index 7cd98d149..e5b30a764 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 + NoGlobCase // 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&NoGlobCase != 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 }