Skip to content

Commit 3745468

Browse files
committed
Follow other pathspec implementations' match logic
1 parent 51cebad commit 3745468

File tree

2 files changed

+105
-94
lines changed

2 files changed

+105
-94
lines changed

example_test.go

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ func Example() {
2525
}
2626

2727
func ExampleParseFile() {
28-
f := bytes.NewBufferString("src/**/*.[hc] @acme/c-developers # C headers and source")
28+
f := bytes.NewBufferString("src/**/*.go @acme/go-developers # Go code")
2929
ruleset, err := codeowners.ParseFile(f)
3030
if err != nil {
3131
panic(err)
@@ -36,29 +36,29 @@ func ExampleParseFile() {
3636
fmt.Println(ruleset[0].Comment)
3737
// Output:
3838
// 1
39-
// src/**/*.[hc]
40-
// @acme/c-developers
41-
// C headers and source
39+
// src/**/*.go
40+
// @acme/go-developers
41+
// Go code
4242
}
4343

4444
func ExampleRuleset_Match() {
45-
f := bytes.NewBufferString("src/**/*.[hc] @acme/c-developers # C headers and source")
45+
f := bytes.NewBufferString("src/**/*.go @acme/go-developers # Go code")
4646
ruleset, _ := codeowners.ParseFile(f)
4747

4848
match, _ := ruleset.Match("src")
4949
fmt.Println("src", match != nil)
5050

51-
match, _ = ruleset.Match("src/foo.c")
52-
fmt.Println("src/foo.c", match != nil)
51+
match, _ = ruleset.Match("src/foo.go")
52+
fmt.Println("src/foo.go", match != nil)
5353

54-
match, _ = ruleset.Match("src/foo/bar.h")
55-
fmt.Println("src/foo/bar.h", match != nil)
54+
match, _ = ruleset.Match("src/foo/bar.go")
55+
fmt.Println("src/foo/bar.go", match != nil)
5656

5757
match, _ = ruleset.Match("src/foo.rs")
5858
fmt.Println("src/foo.rs", match != nil)
5959
// Output:
6060
// src false
61-
// src/foo.c true
62-
// src/foo/bar.h true
61+
// src/foo.go true
62+
// src/foo/bar.go true
6363
// src/foo.rs false
6464
}

match.go

Lines changed: 94 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package codeowners
22

33
import (
44
"fmt"
5+
"os"
56
"regexp"
67
"strings"
78
)
@@ -31,101 +32,111 @@ func (p pattern) match(testPath string) (bool, error) {
3132

3233
// buildPatternRegex compiles a new regexp object from a gitignore-style pattern string
3334
func buildPatternRegex(pattern string) (*regexp.Regexp, error) {
34-
var re strings.Builder
35+
// Handle specific edge cases first
36+
switch {
37+
case strings.Contains(pattern, "***"):
38+
return nil, fmt.Errorf("pattern cannot contain three consecutive asterisks")
39+
case pattern == "":
40+
return nil, fmt.Errorf("empty pattern")
41+
case pattern == "/":
42+
// "/" doesn't match anything
43+
return regexp.Compile(`\A\z`)
44+
}
45+
46+
segs := strings.Split(pattern, "/")
3547

36-
// The pattern is anchored if it starts with a slash, or has a slash before the
37-
// final character
38-
slashPos := strings.IndexByte(pattern, '/')
39-
anchored := slashPos != -1 && slashPos != len(pattern)-1
40-
if anchored {
41-
// Patterns with a non-terminal slash can only match from the start of the string
42-
re.WriteString(`\A`)
48+
if segs[0] == "" {
49+
// Leading slash: match is relative to root
50+
segs = segs[1:]
4351
} else {
44-
// Patterns without a non-terminal slash can match anywhere, but still need to
45-
// consider string and path-segment boundaries
46-
re.WriteString(`(?:\A|/)`)
52+
// No leading slash - check for a single segment pattern, which matches
53+
// relative to any descendent path (equivalent to a leading **/)
54+
if len(segs) == 1 || (len(segs) == 2 && segs[1] == "") {
55+
if segs[0] != "**" {
56+
segs = append([]string{"**"}, segs...)
57+
}
58+
}
4759
}
4860

49-
// For consistency, strip leading and trailing slashes from the pattern, but
50-
// keep track of whether it's a directory-only pattern (has a trailing slash)
51-
matchesDir := pattern[len(pattern)-1] == '/'
52-
patternRunes := []rune(strings.Trim(pattern, "/"))
53-
54-
inCharClass := false
55-
escaped := false
56-
for i := 0; i < len(patternRunes); i++ {
57-
ch := patternRunes[i]
58-
59-
// If the previous character was a backslash, treat this as a literal
60-
if escaped {
61-
re.WriteString(regexp.QuoteMeta(string(ch)))
62-
escaped = false
63-
continue
64-
}
61+
if len(segs) > 1 && segs[len(segs)-1] == "" {
62+
// Trailing slash is equivalent to "/**"
63+
segs[len(segs)-1] = "**"
64+
}
6565

66-
switch ch {
67-
case '\\':
68-
// Escape the next character
69-
escaped = true
70-
71-
case '*':
72-
// Check for double-asterisk wildcards (^**/, /**/, /**$)
73-
if i+1 < len(patternRunes) && patternRunes[i+1] == '*' {
74-
leftAnchored := i == 0
75-
leadingSlash := i > 0 && patternRunes[i-1] == '/'
76-
rightAnchored := i+2 == len(patternRunes)
77-
trailingSlash := i+2 < len(patternRunes) && patternRunes[i+2] == '/'
78-
79-
if (leftAnchored || leadingSlash) && (rightAnchored || trailingSlash) {
80-
re.WriteString(`.*`)
81-
82-
// Leading (**/) and middle (/**/) wildcards have two extra characters to
83-
// skip, and with trailing wildcards (/**) we're at the end anyway
84-
i += 2
85-
break
86-
}
66+
sep := string(os.PathSeparator)
67+
68+
lastSegIndex := len(segs) - 1
69+
needSlash := false
70+
var re strings.Builder
71+
re.WriteString(`\A`)
72+
for i, seg := range segs {
73+
switch seg {
74+
case "**":
75+
switch {
76+
case i == 0 && i == lastSegIndex:
77+
// If the pattern is just "**" we match everything
78+
re.WriteString(`.+`)
79+
case i == 0:
80+
// If the pattern starts with "**" we match any leading path segment
81+
re.WriteString(`(?:.+` + sep + `)?`)
82+
needSlash = false
83+
case i == lastSegIndex:
84+
// If the pattern ends with "**" we match any trailing path segment
85+
re.WriteString(sep + `.*`)
86+
default:
87+
// If the pattern contains "**" we match zero or more path segments
88+
re.WriteString(`(?:` + sep + `.+)?`)
89+
needSlash = true
8790
}
8891

89-
// If it's not a double-asterisk, treat it as a regular wildcard
90-
re.WriteString(`[^/]*`)
91-
92-
case '?':
93-
// Single-character wildcard
94-
re.WriteString(`[^/]`)
95-
96-
case '[':
97-
// Open a character class
98-
inCharClass = true
99-
re.WriteRune(ch)
100-
101-
case ']':
102-
// Close the character class if we're in one, or treat as a literal
103-
if inCharClass {
104-
re.WriteRune(ch)
105-
inCharClass = false
106-
} else {
107-
re.WriteString(regexp.QuoteMeta(string(ch)))
92+
case "*":
93+
if needSlash {
94+
re.WriteString(sep)
10895
}
10996

97+
// Regular wildcard - match any characters except the separator
98+
re.WriteString(`[^` + sep + `]+`)
99+
needSlash = true
100+
110101
default:
111-
// Escape literal characters so they don't interfere with the regex
112-
re.WriteString(regexp.QuoteMeta(string(ch)))
113-
}
114-
}
102+
if needSlash {
103+
re.WriteString(sep)
104+
}
115105

116-
if inCharClass {
117-
return nil, fmt.Errorf("unterminated character class in pattern %s", pattern)
118-
}
106+
escape := false
107+
for _, ch := range seg {
108+
if escape {
109+
escape = false
110+
re.WriteString(regexp.QuoteMeta(string(ch)))
111+
continue
112+
}
119113

120-
if matchesDir {
121-
// This will match either a directory that's prefix of a path provided, or
122-
// a suffix if we assume that tested directories always have a trailing slash
123-
re.WriteString(`/`)
124-
} else {
125-
// End the match either at the end of the string or at a slash (in the case that
126-
// we've matched a directory)
127-
re.WriteString(`(?:\z|/)`)
128-
}
114+
// Other pathspec implementations handle character classes here (e.g.
115+
// [AaBb]), but CODEOWNERS doesn't support that so we don't need to
116+
switch ch {
117+
case '\\':
118+
escape = true
119+
case '*':
120+
// Multi-character wildcard
121+
re.WriteString(`[^` + sep + `]*`)
122+
case '?':
123+
// Single-character wildcard
124+
re.WriteString(`[^` + sep + `]`)
125+
default:
126+
// Regular character
127+
re.WriteString(regexp.QuoteMeta(string(ch)))
128+
}
129+
}
130+
131+
if i == lastSegIndex {
132+
// As there's no trailing slash (that'd hit the '**' case), we
133+
// need to match descendent paths
134+
re.WriteString(`(?:` + sep + `.*)?`)
135+
}
129136

137+
needSlash = true
138+
}
139+
}
140+
re.WriteString(`\z`)
130141
return regexp.Compile(re.String())
131142
}

0 commit comments

Comments
 (0)