11/**
22 * Matcher abstraction + adapters for micromatch/picomatch (optional).
33 *
4- * By default, a minimal matcher is used that understands:
4+ * Default matcher understands:
55 * - `*` → matches any single field segment
66 * - `**` → 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
89 *
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.
1111 */
1212
1313export interface Matcher {
@@ -19,45 +19,64 @@ export interface Matcher {
1919
2020/**
2121 * 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 `.`
2327 */
2428export 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 ) ) ,
2830} ;
2931
3032/**
3133 * Attempts to load a named matcher adapter.
3234 * @param name `"micromatch" | "picomatch"`
3335 * @returns A Matcher implementation.
3436 */
37+
3538export const loadMatcher = ( name : "micromatch" | "picomatch" ) : Matcher => {
3639 if ( name === "micromatch" ) {
40+ let micromatch : any ;
3741 try {
38- const micromatch = require ( "micromatch" ) ;
39- return { isMatch : ( str , pats ) => micromatch . isMatch ( str , pats ) } ;
42+ micromatch = require ( "micromatch" ) ;
4043 } catch {
4144 throw new Error (
4245 `micromatch is not installed. Please add it as a dependency if you want to use it.` ,
4346 ) ;
4447 }
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+ } ;
4558 }
4659
4760 if ( name === "picomatch" ) {
61+ let picomatch : any ;
4862 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" ) ;
5664 } catch {
5765 throw new Error (
5866 `picomatch is not installed. Please add it as a dependency if you want to use it.` ,
5967 ) ;
6068 }
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+ } ;
6180 }
6281
6382 throw new Error ( `Unknown matcher name: ${ name } ` ) ;
@@ -66,29 +85,55 @@ export const loadMatcher = (name: "micromatch" | "picomatch"): Matcher => {
6685/* ---------------- Internal helpers ---------------- */
6786
6887/**
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"]
7094 */
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+
71119const matchOne = ( str : string , pattern : string ) : boolean => {
72120 const strSegments = str . split ( "." ) ;
73- const patSegments = pattern . split ( "." ) ;
74-
121+ const patSegments = splitPattern ( pattern ) ;
75122 return matchSegments ( strSegments , patSegments ) ;
76123} ;
77124
78125/**
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).
82127 */
83128const matchSegments = ( strSegments : string [ ] , patternSegments : string [ ] ) : boolean => {
84129 let si = 0 ;
85130 let pi = 0 ;
86131
87132 while ( si < strSegments . length && pi < patternSegments . length ) {
88133 const pat = patternSegments [ pi ] ;
134+
89135 if ( pat === "**" ) {
90- // match remainder greedily
91- if ( pi === patternSegments . length - 1 ) return true ;
136+ if ( pi === patternSegments . length - 1 ) return true ; // consume rest
92137 for ( let skip = 0 ; si + skip <= strSegments . length ; skip ++ ) {
93138 if ( matchSegments ( strSegments . slice ( si + skip ) , patternSegments . slice ( pi + 1 ) ) ) {
94139 return true ;
@@ -110,17 +155,54 @@ const matchSegments = (strSegments: string[], patternSegments: string[]): boolea
110155} ;
111156
112157/**
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)
116163 */
117164const 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
119197
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 ;
123202 }
124203
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 ) ;
126208} ;
0 commit comments