@@ -2,6 +2,7 @@ package codeowners
22
33import (
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
3334func 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