Skip to content

Commit

Permalink
interp: fix multi-line shell pattern match equality
Browse files Browse the repository at this point in the history
commit be03afe added the multi-line
flag to the regexp generated for `==` and `=!` testing. However, this
flag is confusing as it allows for a regular expression to match one
line out of a multi-line input.

    (m) multi-line mode: ^ and $ match begin/end line in addition to
    begin/end text (default false)

This means we are now matching against just one line in a multi-line
string, rather than the entire text. Instead, to match Bash's behavior,
we add the `s` flag to the regexp:

    (s) let . match \n (default false)

With this a `*` in a shell pattern will match `\n`.

Glob patterns should always match newline characters, so add the `s`
flag when the `.` metacharacter is used. This behavior worked by
accident with file globs because rather than matching with `.*` we match
with `[^/]*` which does match `\n`.
  • Loading branch information
lollipopman committed Jun 7, 2022
1 parent 169e304 commit 5bb30cf
Show file tree
Hide file tree
Showing 6 changed files with 64 additions and 23 deletions.
13 changes: 7 additions & 6 deletions expand/expand.go
Original file line number Diff line number Diff line change
Expand Up @@ -845,21 +845,22 @@ func (cfg *Config) glob(base, pat string) ([]string, error) {

// If dir is not a directory, we keep the stack as-is and continue.
newMatches = newMatches[:0]
newMatches, _ = cfg.globDir(base, dir, rxGlobStar, wantDir, newMatches)
newMatches, _ = cfg.globDir(base, dir, rxGlobStar, false, wantDir, newMatches)
for i := len(newMatches) - 1; i >= 0; i-- {
stack = append(stack, newMatches[i])
}
}
continue
}
expr, err := pattern.Regexp(part, pattern.Filenames)
expr, err := pattern.Regexp(part, pattern.Filenames|pattern.EntireString)
if err != nil {
return nil, err
}
rx := regexp.MustCompile("^" + expr + "$")
rx := regexp.MustCompile(expr)
matchHidden := part[0] == byte('.')
var newMatches []string
for _, dir := range matches {
newMatches, err = cfg.globDir(base, dir, rx, wantDir, newMatches)
newMatches, err = cfg.globDir(base, dir, rx, matchHidden, wantDir, newMatches)
if err != nil {
return nil, err
}
Expand All @@ -869,7 +870,7 @@ func (cfg *Config) glob(base, pat string) ([]string, error) {
return matches, nil
}

func (cfg *Config) globDir(base, dir string, rx *regexp.Regexp, wantDir bool, matches []string) ([]string, error) {
func (cfg *Config) globDir(base, dir string, rx *regexp.Regexp, matchHidden bool, wantDir bool, matches []string) ([]string, error) {
fullDir := dir
if !filepath.IsAbs(dir) {
fullDir = filepath.Join(base, dir)
Expand All @@ -896,7 +897,7 @@ func (cfg *Config) globDir(base, dir string, rx *regexp.Regexp, wantDir bool, ma
// Not a symlink nor a directory.
continue
}
if !strings.HasPrefix(rx.String(), `^\.`) && name[0] == '.' {
if !matchHidden && name[0] == '.' {
continue
}
if rx.MatchString(name) {
Expand Down
22 changes: 22 additions & 0 deletions interp/interp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1555,6 +1555,23 @@ var runTests = []runTest{
"[[ \"multiline\ntext\" == *text* ]] && echo x; [[ \"multiline\ntext\" == *multiline* ]] && echo y",
"x\ny\n",
},
// * should match a newline
{
"[[ \"multiline\ntext\" == multiline*text ]] && echo x",
"x\n",
},
{
"[[ \"multiline\ntext\" == text ]]",
"exit status 1",
},
{
`case $'a\nb' in a*b) echo match ;; esac`,
"match\n",
},
{
`a=$'a\nb'; echo "${a/a*b/sub}"`,
"sub\n",
},
{
"mkdir a; cd a; test -f b && echo x; >b; test -f b && echo y",
"y\n",
Expand Down Expand Up @@ -3214,6 +3231,11 @@ hello, world
`mapfile -t -d "" < <(printf "a\0b\n"); for x in "${MAPFILE[@]}"; do echo "$x"; done`,
"a\nb\n\n",
},
// Windows does not support having a `\n` in a filename
{
`> $'bar\nbaz'; echo bar*baz`,
"bar\nbaz\n",
},
}

var runTestsWindows = []runTest{
Expand Down
4 changes: 2 additions & 2 deletions interp/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -705,11 +705,11 @@ func (r *Runner) flattenAssign(as *syntax.Assign) []*syntax.Assign {
}

func match(pat, name string) bool {
expr, err := pattern.Regexp(pat, 0)
expr, err := pattern.Regexp(pat, pattern.EntireString)
if err != nil {
return false
}
rx := regexp.MustCompile("(?m)^" + expr + "$")
rx := regexp.MustCompile(expr)
return rx.MatchString(name)
}

Expand Down
2 changes: 1 addition & 1 deletion pattern/example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ func ExampleRegexp() {
fmt.Println(rx.MatchString("foobarbaz"))
// Output:
// foo?bar*
// foo.bar.*
// (?s)foo.bar.*
// true
// false
}
Expand Down
26 changes: 22 additions & 4 deletions pattern/pattern.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,10 @@ func (e SyntaxError) Error() string { return e.msg }
func (e SyntaxError) Unwrap() error { return e.err }

const (
Shortest Mode = 1 << iota // prefer the shortest match.
Filenames // "*" and "?" don't match slashes; only "**" does
Braces // support "{a,b}" and "{1..4}"
Shortest Mode = 1 << iota // prefer the shortest match.
Filenames // "*" and "?" don't match slashes; only "**" does
Braces // support "{a,b}" and "{1..4}"
EntireString // match the entire string using ^$ delimiters
)

var numRange = regexp.MustCompile(`^([+-]?\d+)\.\.([+-]?\d+)}`)
Expand All @@ -59,11 +60,17 @@ noopLoop:
break noopLoop
}
}
if !any { // short-cut without a string copy
if !any && mode&EntireString == 0 { // short-cut without a string copy
return pat, nil
}
closingBraces := []int{}
var buf bytes.Buffer
// Enable matching `\n` with the `.` metacharacter as globs match `\n`
buf.WriteString("(?s)")
dotMeta := false
if mode&EntireString != 0 {
buf.WriteString("^")
}
writeLoop:
for i := 0; i < len(pat); i++ {
switch c := pat[i]; c {
Expand All @@ -72,8 +79,10 @@ writeLoop:
if i++; i < len(pat) && pat[i] == '*' {
if i++; i < len(pat) && pat[i] == '/' {
buf.WriteString("(.*/|)")
dotMeta = true
} else {
buf.WriteString(".*")
dotMeta = true
i--
}
} else {
Expand All @@ -82,6 +91,7 @@ writeLoop:
}
} else {
buf.WriteString(".*")
dotMeta = true
}
if mode&Shortest != 0 {
buf.WriteByte('?')
Expand All @@ -91,6 +101,7 @@ writeLoop:
buf.WriteString("[^/]")
} else {
buf.WriteByte('.')
dotMeta = true
}
case '\\':
if i++; i >= len(pat) {
Expand Down Expand Up @@ -228,6 +239,13 @@ writeLoop:
}
}
}
if mode&EntireString != 0 {
buf.WriteString("$")
}
// No `.` metacharacters were used, so don't return the flag.
if !dotMeta {
return string(buf.Bytes()[4:]), nil
}
return buf.String(), nil
}

Expand Down
20 changes: 10 additions & 10 deletions pattern/pattern_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,21 +19,21 @@ var translateTests = []struct {
{pat: `foo`, want: `foo`},
{pat: `foóà中`, mode: Filenames | Braces, want: `foóà中`},
{pat: `.`, want: `\.`},
{pat: `foo*`, want: `foo.*`},
{pat: `foo*`, mode: Shortest, want: `foo.*?`},
{pat: `foo*`, want: `(?s)foo.*`},
{pat: `foo*`, mode: Shortest, want: `(?s)foo.*?`},
{pat: `foo*`, mode: Shortest | Filenames, want: `foo[^/]*?`},
{pat: `*foo`, mode: Filenames, want: `[^/]*foo`},
{pat: `**`, want: `.*.*`},
{pat: `**`, mode: Filenames, want: `.*`},
{pat: `/**/foo`, want: `/.*.*/foo`},
{pat: `/**/foo`, mode: Filenames, want: `/(.*/|)foo`},
{pat: `/**/à`, mode: Filenames, want: `/(.*/|)à`},
{pat: `/**foo`, mode: Filenames, want: `/.*foo`},
{pat: `**`, want: `(?s).*.*`},
{pat: `**`, mode: Filenames, want: `(?s).*`},
{pat: `/**/foo`, want: `(?s)/.*.*/foo`},
{pat: `/**/foo`, mode: Filenames, want: `(?s)/(.*/|)foo`},
{pat: `/**/à`, mode: Filenames, want: `(?s)/(.*/|)à`},
{pat: `/**foo`, mode: Filenames, want: `(?s)/.*foo`},
{pat: `\*`, want: `\*`},
{pat: `\`, wantErr: true},
{pat: `?`, want: `.`},
{pat: `?`, want: `(?s).`},
{pat: `?`, mode: Filenames, want: `[^/]`},
{pat: `?à`, want: `.à`},
{pat: `?à`, want: `(?s).à`},
{pat: `\a`, want: `a`},
{pat: `(`, want: `\(`},
{pat: `a|b`, want: `a\|b`},
Expand Down

0 comments on commit 5bb30cf

Please sign in to comment.