Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow case-insensitive filename globbing; add nocaseglob #1073

Merged
merged 1 commit into from
Apr 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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