1
1
/**
2
2
* Matcher abstraction + adapters for micromatch/picomatch (optional).
3
3
*
4
- * By default, a minimal matcher is used that understands:
4
+ * Default matcher understands:
5
5
* - `*` → matches any single field segment
6
6
* - `**` → matches any number of nested field segments
7
- * - Literal prefix/suffix globs like `prefix-*` or `*-suffix`
7
+ * - Prefix/suffix like `prefix-*`, `*-suffix`
8
+ * - Backslash escaping (micromatch-style): `a\.b` → literal dot, `\*` → literal asterisk
8
9
*
9
- * Micromatch or Picomatch can be loaded at runtime if available,
10
- * but they are treated as optional peer dependencies.
10
+ * Micromatch/Picomatch can be loaded at runtime as optional peers.
11
11
*/
12
12
13
13
export interface Matcher {
@@ -19,45 +19,64 @@ export interface Matcher {
19
19
20
20
/**
21
21
* Minimal homegrown matcher (default).
22
- * Only supports `*` and `**` operators on dot-paths.
22
+ * Supports:
23
+ * - `*` → matches any single field segment
24
+ * - `**` → matches any number of nested field segments
25
+ * - Literal prefix/suffix like `prefix-*`, `*-suffix`
26
+ * - Backslash escaping for `*` and `.`
23
27
*/
24
28
export const basicMatcher : Matcher = {
25
- isMatch : ( str , patterns ) => {
26
- return patterns . some ( pattern => matchOne ( str , pattern ) ) ;
27
- } ,
29
+ isMatch : ( str , patterns ) => patterns . some ( p => matchOne ( str , p ) ) ,
28
30
} ;
29
31
30
32
/**
31
33
* Attempts to load a named matcher adapter.
32
34
* @param name `"micromatch" | "picomatch"`
33
35
* @returns A Matcher implementation.
34
36
*/
37
+
35
38
export const loadMatcher = ( name : "micromatch" | "picomatch" ) : Matcher => {
36
39
if ( name === "micromatch" ) {
40
+ let micromatch : any ;
37
41
try {
38
- const micromatch = require ( "micromatch" ) ;
39
- return { isMatch : ( str , pats ) => micromatch . isMatch ( str , pats ) } ;
42
+ micromatch = require ( "micromatch" ) ;
40
43
} catch {
41
44
throw new Error (
42
45
`micromatch is not installed. Please add it as a dependency if you want to use it.` ,
43
46
) ;
44
47
}
48
+
49
+ return {
50
+ isMatch : ( str , pats ) => {
51
+ try {
52
+ return micromatch . isMatch ( str , pats ) ;
53
+ } catch ( err ) {
54
+ throw new Error ( `micromatch failed to run isMatch: ${ ( err as Error ) . message } ` ) ;
55
+ }
56
+ } ,
57
+ } ;
45
58
}
46
59
47
60
if ( name === "picomatch" ) {
61
+ let picomatch : any ;
48
62
try {
49
- const picomatch = require ( "picomatch" ) ;
50
- return {
51
- isMatch : ( str , pats ) => {
52
- const fn = picomatch ( pats ) ;
53
- return fn ( str ) ;
54
- } ,
55
- } ;
63
+ picomatch = require ( "picomatch" ) ;
56
64
} catch {
57
65
throw new Error (
58
66
`picomatch is not installed. Please add it as a dependency if you want to use it.` ,
59
67
) ;
60
68
}
69
+
70
+ return {
71
+ isMatch : ( str , pats ) => {
72
+ try {
73
+ const fn = picomatch ( pats ) ;
74
+ return fn ( str ) ;
75
+ } catch ( err ) {
76
+ throw new Error ( `picomatch failed to run isMatch: ${ ( err as Error ) . message } ` ) ;
77
+ }
78
+ } ,
79
+ } ;
61
80
}
62
81
63
82
throw new Error ( `Unknown matcher name: ${ name } ` ) ;
@@ -66,29 +85,55 @@ export const loadMatcher = (name: "micromatch" | "picomatch"): Matcher => {
66
85
/* ---------------- Internal helpers ---------------- */
67
86
68
87
/**
69
- * Matches a single string against a glob pattern using only * and ** semantics.
88
+ * Splits a glob pattern into dot-separated segments, honoring backslash escaping.
89
+ * - Do not split on `\.` (literal dot remains within the same segment)
90
+ * - Preserve backslashes so segment-level matching can distinguish escaped `*`
91
+ * Examples:
92
+ * "a\.b.c" → ["a\.b", "c"]
93
+ * "foo\*bar" → ["foo\*bar"]
70
94
*/
95
+ const splitPattern = ( pattern : string ) : string [ ] => {
96
+ const segments : string [ ] = [ ] ;
97
+ let buf = "" ;
98
+ let escaped = false ;
99
+
100
+ for ( const ch of pattern ) {
101
+ if ( escaped ) {
102
+ // keep the backslash for segment matching stage
103
+ buf += "\\" + ch ;
104
+ escaped = false ;
105
+ } else if ( ch === "\\" ) {
106
+ escaped = true ;
107
+ } else if ( ch === "." ) {
108
+ segments . push ( buf ) ;
109
+ buf = "" ;
110
+ } else {
111
+ buf += ch ;
112
+ }
113
+ }
114
+ if ( escaped ) buf += "\\" ; // trailing backslash is literal
115
+ segments . push ( buf ) ;
116
+ return segments ;
117
+ } ;
118
+
71
119
const matchOne = ( str : string , pattern : string ) : boolean => {
72
120
const strSegments = str . split ( "." ) ;
73
- const patSegments = pattern . split ( "." ) ;
74
-
121
+ const patSegments = splitPattern ( pattern ) ;
75
122
return matchSegments ( strSegments , patSegments ) ;
76
123
} ;
77
124
78
125
/**
79
- * Matches field segments against pattern segments.
80
- * - `*` = any single segment
81
- * - `**` = zero or more segments
126
+ * `**` matches zero or more segments (only when unescaped as a whole segment).
82
127
*/
83
128
const matchSegments = ( strSegments : string [ ] , patternSegments : string [ ] ) : boolean => {
84
129
let si = 0 ;
85
130
let pi = 0 ;
86
131
87
132
while ( si < strSegments . length && pi < patternSegments . length ) {
88
133
const pat = patternSegments [ pi ] ;
134
+
89
135
if ( pat === "**" ) {
90
- // match remainder greedily
91
- if ( pi === patternSegments . length - 1 ) return true ;
136
+ if ( pi === patternSegments . length - 1 ) return true ; // consume rest
92
137
for ( let skip = 0 ; si + skip <= strSegments . length ; skip ++ ) {
93
138
if ( matchSegments ( strSegments . slice ( si + skip ) , patternSegments . slice ( pi + 1 ) ) ) {
94
139
return true ;
@@ -110,17 +155,54 @@ const matchSegments = (strSegments: string[], patternSegments: string[]): boolea
110
155
} ;
111
156
112
157
/**
113
- * Matches a single field segment against a pattern segment.
114
- * - `*` = any
115
- * - `prefix-*` / `*-suffix` = prefix/suffix match
158
+ * Segment-level match with micromatch-like escapes:
159
+ * - Unescaped `*` → wildcard (prefix/suffix; exactly one wildcard supported)
160
+ * - `\*` → literal `*`
161
+ * - `\.` → literal `.`
162
+ * - Backslash escapes any next char (becomes literal)
116
163
*/
117
164
const segmentMatches = ( seg : string , pat : string ) : boolean => {
118
- if ( pat === "*" ) return true ;
165
+ // Parse `pat`, tracking a single UNESCAPED `*` position
166
+ let escaped = false ;
167
+ let sawUnescapedStar = false ;
168
+ let pre = "" ;
169
+ let buf = "" ;
170
+
171
+ for ( let i = 0 ; i < pat . length ; i ++ ) {
172
+ const ch = pat [ i ] ;
173
+
174
+ if ( escaped ) {
175
+ buf += ch ; // take literally
176
+ escaped = false ;
177
+ continue ;
178
+ }
179
+ if ( ch === "\\" ) {
180
+ escaped = true ; // next char is literal
181
+ continue ;
182
+ }
183
+ if ( ch === "*" ) {
184
+ if ( ! sawUnescapedStar ) {
185
+ sawUnescapedStar = true ;
186
+ pre = buf ; // capture prefix; start collecting suffix into `buf` anew
187
+ buf = "" ;
188
+ continue ;
189
+ }
190
+ // Additional unescaped '*' are treated as literal in suffix per our minimal semantics
191
+ buf += "*" ;
192
+ continue ;
193
+ }
194
+ buf += ch ;
195
+ }
196
+ if ( escaped ) buf += "\\" ; // trailing backslash literal
119
197
120
- if ( pat . includes ( "*" ) ) {
121
- const [ pre , suf ] = pat . split ( "*" ) ;
122
- return seg . startsWith ( pre ) && seg . endsWith ( suf ) ;
198
+ if ( ! sawUnescapedStar ) {
199
+ // No wildcard: exact match after unescaping (remove backslashes)
200
+ const literal = buf . replace ( / \\ ( .) / g, "$1" ) ;
201
+ return seg === literal ;
123
202
}
124
203
125
- return seg === pat ;
204
+ // Wildcard with single unescaped `*`: prefix/suffix
205
+ const suf = buf . replace ( / \\ ( .) / g, "$1" ) ;
206
+ const prefix = pre . replace ( / \\ ( .) / g, "$1" ) ;
207
+ return seg . startsWith ( prefix ) && seg . endsWith ( suf ) ;
126
208
} ;
0 commit comments