diff --git a/fuzz_test.go b/fuzz_test.go index 54e179b..8e4101a 100644 --- a/fuzz_test.go +++ b/fuzz_test.go @@ -148,8 +148,9 @@ func FuzzMatchRegexp(f *testing.F) { t.Fatalf("bad regexp translation of %#q: %v", pattern, err) } var ( - got = p.Match(s) - want = re.MatchString(s) + got = p.Match(s) + gotBytes = p.MatchBytes([]byte(s)) + want = re.MatchString(s) ) if got != want { t.Errorf( @@ -157,6 +158,12 @@ func FuzzMatchRegexp(f *testing.F) { string(sep), pattern, re, s, got, want, ) } + if gotBytes != want { + t.Errorf( + "sep=%q pattern=%#q (regexp %#q) string=%#q: MatchBytes()=%t, regexp=%t", + string(sep), pattern, re, s, gotBytes, want, + ) + } }) } diff --git a/glob.go b/glob.go index 5249b2f..65a40cd 100644 --- a/glob.go +++ b/glob.go @@ -2,11 +2,20 @@ package glob import ( "fmt" + "unsafe" "github.com/gobwas/glob/internal/debug" "github.com/gobwas/glob/syntax" ) +// Glob represents a compiled glob pattern. +type Glob interface { + Match(string) bool + MatchBytes([]byte) bool +} + +var _ Glob = (*Pattern)(nil) + // SyntaxError is returned by [Compile] when the given pattern can not be // parsed. Offset points at the place in the pattern the error was detected // at, so the tooling can do things like: @@ -89,6 +98,11 @@ func (p *Pattern) Separators() []rune { return p.sep } +// MatchBytes reports whether b matches the pattern. +func (p *Pattern) MatchBytes(b []byte) bool { + return p.Match(unsafe.String(unsafe.SliceData(b), len(b))) +} + func init() { // The matcher tree is unexported; hand its rendering to the in-module // tooling (cmd/globtest -v) without widening the public API. diff --git a/glob_test.go b/glob_test.go index baba220..ac5a101 100644 --- a/glob_test.go +++ b/glob_test.go @@ -701,6 +701,23 @@ func TestPatternMatch(t *testing.T) { t.Errorf("too slow: %.4fms", latency.Seconds()*1000) } }) + t.Run("glob-bytes"+suffix, func(t *testing.T) { + var ( + pat = MustCompile(test.pat, test.sep...) + start = time.Now() + result = pat.MatchBytes([]byte(test.str)) + latency = time.Since(start) + ) + if result != test.exp { + t.Errorf( + "pattern %q matching bytes %q should be %v but got %v", + test.pat, test.str, test.exp, result, + ) + } + if latency > 5*time.Millisecond { + t.Errorf("too slow: %.4fms", latency.Seconds()*1000) + } + }) if len(test.sep) == 0 && !strings.ContainsAny(test.pat, "{}") { // NOTE: filepath.Match doesn't support `{}`. t.Run("filepath"+suffix, func(t *testing.T) { @@ -1128,3 +1145,145 @@ func TestPatternSeparatorsAliasing(t *testing.T) { t.Errorf("Match() picked up the modification of the given slice") } } + +func TestPatternMatchBytes(t *testing.T) { + t.Run("nil slice", func(t *testing.T) { + p := MustCompile("") + if !p.MatchBytes(nil) { + t.Errorf("empty pattern MatchBytes(nil) = false; want true") + } + p2 := MustCompile("a") + if p2.MatchBytes(nil) { + t.Errorf("non-empty pattern MatchBytes(nil) = true; want false") + } + }) + + t.Run("empty slice", func(t *testing.T) { + p := MustCompile("") + if !p.MatchBytes([]byte{}) { + t.Errorf("empty pattern MatchBytes([]byte{}) = false; want true") + } + p2 := MustCompile("a") + if p2.MatchBytes([]byte{}) { + t.Errorf("non-empty pattern MatchBytes([]byte{}) = true; want false") + } + }) + + t.Run("subslice with offset and capacity", func(t *testing.T) { + p := MustCompile("world") + buf := []byte("hello world 12345") + sub := buf[6:11] + if !p.MatchBytes(sub) { + t.Errorf("MatchBytes(%q) = false; want true", sub) + } + if p.MatchBytes(buf[0:5]) { + t.Errorf("MatchBytes(%q) = true; want false", buf[0:5]) + } + }) + + t.Run("binary and null bytes", func(t *testing.T) { + p := MustCompile("a\x00b") + if !p.MatchBytes([]byte("a\x00b")) { + t.Errorf("MatchBytes(a\\x00b) = false; want true") + } + if p.MatchBytes([]byte("a\x00c")) { + t.Errorf("MatchBytes(a\\x00c) = true; want false") + } + }) +} + +func TestGlobInterface(t *testing.T) { + var g Glob = MustCompile("*.github.com") + if !g.Match("api.github.com") { + t.Errorf("Glob.Match() = false; want true") + } + if !g.MatchBytes([]byte("api.github.com")) { + t.Errorf("Glob.MatchBytes() = false; want true") + } + if g.Match("api.google.com") { + t.Errorf("Glob.Match() = true; want false") + } + if g.MatchBytes([]byte("api.google.com")) { + t.Errorf("Glob.MatchBytes() = true; want false") + } +} + +func TestPatternMatchBytesZeroAllocs(t *testing.T) { + cases := []struct { + pat string + b []byte + }{ + {"google.com", []byte("google.com")}, + {"abc*", []byte("abcdef")}, + {"*def", []byte("abcdef")}, + {"ab*ef", []byte("abcdef")}, + {"https://*.google.*", []byte("https://account.google.com")}, + {"{cat,bat,[fr]at}", []byte("fat")}, + } + for _, c := range cases { + p := MustCompile(c.pat) + _ = p.MatchBytes(c.b) + allocs := testing.AllocsPerRun(100, func() { + if !p.MatchBytes(c.b) { + t.Fatalf("pattern %q did not match", c.pat) + } + }) + if allocs > 0 { + t.Errorf("pattern %q: MatchBytes allocated %f times; want 0", c.pat, allocs) + } + } +} + +func BenchmarkCompareMatchAndMatchBytes(b *testing.B) { + for _, test := range []struct { + name string + glob string + str string + }{ + { + name: "plain", + glob: "google.com", + str: "google.com", + }, + { + name: "prefix", + glob: "abc*", + str: "abcdef", + }, + { + name: "suffix", + glob: "*def", + str: "abcdef", + }, + { + name: "wildcard", + glob: "https://*.google.*", + str: "https://account.google.com", + }, + { + name: "alternatives", + glob: "{https://*.google.*,*yandex.*,*yahoo.*,*mail.ru}", + str: "http://yahoo.com", + }, + } { + pat := MustCompile(test.glob) + str := test.str + bts := []byte(str) + + b.Run(test.name+"-string", func(b *testing.B) { + for b.Loop() { + pat.Match(str) + } + }) + b.Run(test.name+"-bytes", func(b *testing.B) { + for b.Loop() { + pat.MatchBytes(bts) + } + }) + b.Run(test.name+"-bytes-to-string-alloc", func(b *testing.B) { + for b.Loop() { + pat.Match(string(bts)) + } + }) + } +} diff --git a/issues_test.go b/issues_test.go index 1282aea..7375771 100644 --- a/issues_test.go +++ b/issues_test.go @@ -12,6 +12,8 @@ func TestOpenIssues(t *testing.T) { sep []rune exp bool }{ + {32, "*.github.com", "api.github.com", nil, true}, + {32, "*.github.com", "github.com", nil, false}, {33, "test*201901*.12*", "test.20190101.120000", nil, true}, {33, "test.*201901*.14*", "test.20190101.130000", nil, false}, {33, "test.*201901*.14*", "test.20190101.140000", nil, true}, @@ -48,10 +50,14 @@ func TestOpenIssues(t *testing.T) { {66, `{**/daxing,daxing}/**/*dev*.yaml`, "playground/daxing/generated/dev.yaml", nil, true}, {66, `{**/daxing}/**/*dev*.yaml`, "playground/daxing/generated/dev.yaml", nil, true}, } { - got := MustCompile(c.pat, c.sep...).Match(c.str) + p := MustCompile(c.pat, c.sep...) + got := p.Match(c.str) if got != c.exp { t.Errorf("issue #%d: Match(%q, %q) = %t; want %t", c.issue, c.pat, c.str, got, c.exp) } + if gotBytes := p.MatchBytes([]byte(c.str)); gotBytes != c.exp { + t.Errorf("issue #%d: MatchBytes(%q, %q) = %t; want %t", c.issue, c.pat, c.str, gotBytes, c.exp) + } } // Crashers: old versions panicked, new must return a syntax error. if _, err := Compile("/{a{*.json{", '/'); err == nil { @@ -61,3 +67,14 @@ func TestOpenIssues(t *testing.T) { t.Errorf("issue #56: Compile succeeded; want error") } } + +func TestIssue32(t *testing.T) { + var g Glob + g = MustCompile("*.github.com") + if !g.MatchBytes([]byte("api.github.com")) { + t.Errorf("issue #32: MatchBytes() = false; want true") + } + if g.MatchBytes([]byte("api.google.com")) { + t.Errorf("issue #32: MatchBytes() = true; want false") + } +}