Skip to content

Commit

Permalink
Allow case-insensitive filename globbing; add nocaseglob
Browse files Browse the repository at this point in the history
- 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"
  • Loading branch information
theclapp authored and mvdan committed Apr 27, 2024
1 parent a89b0be commit 9b91d69
Show file tree
Hide file tree
Showing 6 changed files with 92 additions and 3 deletions.
10 changes: 9 additions & 1 deletion expand/expand.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
50 changes: 50 additions & 0 deletions expand/expand_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package expand

import (
"io/fs"
"os"
"reflect"
"strings"
Expand Down Expand Up @@ -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 }
10 changes: 9 additions & 1 deletion interp/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,11 @@ var bashOptsTable = [...]bashOpt{
defaultState: false,
supported: true,
},
{
name: "nocaseglob",
defaultState: false,
supported: true,
},
{
name: "nullglob",
defaultState: false,
Expand Down Expand Up @@ -565,7 +570,6 @@ var bashOptsTable = [...]bashOpt{
{name: "login_shell"},
{name: "mailwarn"},
{name: "no_empty_cmd_completion"},
{name: "nocaseglob"},
{name: "nocasematch"},
{
name: "progcomp",
Expand All @@ -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
Expand All @@ -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
)

Expand Down
18 changes: 18 additions & 0 deletions interp/interp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
1 change: 1 addition & 0 deletions interp/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -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]
}
Expand Down
6 changes: 5 additions & 1 deletion pattern/pattern.go
Original file line number Diff line number Diff line change
Expand Up @@ -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+)}`)
Expand Down Expand Up @@ -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("^")
}
Expand Down Expand Up @@ -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
}
Expand Down

0 comments on commit 9b91d69

Please sign in to comment.