Skip to content

Commit ead5629

Browse files
committed
Update matcher to support escape
1 parent 7fcc1aa commit ead5629

File tree

3 files changed

+129
-36
lines changed

3 files changed

+129
-36
lines changed

lib/src/matcher.test.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ describe("basicMatcher", () => {
1717
expect(basicMatcher.isMatch("a.b.c", ["a.**"])).toBe(true);
1818
expect(basicMatcher.isMatch("a.b.c.d", ["a.**.d"])).toBe(true);
1919
expect(basicMatcher.isMatch("a.b.c.d", ["**.d"])).toBe(true);
20+
expect(basicMatcher.isMatch("a.b.c.d", ["*.d"])).toBe(false);
2021
expect(basicMatcher.isMatch("a.b.c", ["**"])).toBe(true);
2122
});
2223

@@ -34,6 +35,16 @@ describe("basicMatcher", () => {
3435
it("handles empty patterns", () => {
3536
expect(basicMatcher.isMatch("foo.bar", [])).toBe(false);
3637
});
38+
39+
it("respects escapes", () => {
40+
expect(basicMatcher.isMatch("foo.helloWorld", ["foo.hello*"])).toBe(true);
41+
expect(basicMatcher.isMatch("foo.helloWorld", ["foo.hello\\*"])).toBe(false);
42+
expect(basicMatcher.isMatch("foo.hello*", ["foo.hello\\*"])).toBe(true);
43+
44+
expect(basicMatcher.isMatch("a.b.c", ["a.**"])).toBe(true);
45+
expect(basicMatcher.isMatch("a.b.c", ["a.\\*\\*"])).toBe(false);
46+
expect(basicMatcher.isMatch("a.**", ["a.\\*\\*"])).toBe(true);
47+
});
3748
});
3849

3950
describe("loadMatcher", () => {
@@ -56,7 +67,7 @@ describe("loadMatcher", () => {
5667

5768
it("loads picomatch when available", () => {
5869
const pm = loadMatcher("picomatch");
59-
expect(pm.isMatch("ok", ["ok*"])).toBe(true);
70+
expect(pm.isMatch("okmyfield", ["ok*"])).toBe(true);
6071
expect(pm.isMatch("fail", ["pattern"])).toBe(false);
6172
});
6273

lib/src/matcher.ts

Lines changed: 115 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
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

1313
export 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
*/
2428
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)),
2830
};
2931

3032
/**
3133
* Attempts to load a named matcher adapter.
3234
* @param name `"micromatch" | "picomatch"`
3335
* @returns A Matcher implementation.
3436
*/
37+
3538
export 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+
71119
const 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
*/
83128
const 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
*/
117164
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
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
};

lib/src/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,15 +38,15 @@ type ForbidBangEnd<T extends string> = T extends `${string}!` ? never : T;
3838
* - Values: one or more strategies, or nested RuleTree
3939
*/
4040
export type RuleTree<T extends string = BasicMergeStrategies> = {
41-
[fieldGlob: string]: ForbidBangEnd<T>[] | RuleTree<ForbidBangEnd<T>>;
41+
[fieldGlob: string]: T[] | RuleTree<T>;
4242
};
4343

4444
/**
4545
* High-level config object for conflict resolution.
4646
*/
4747
export interface Config<T extends string = BasicMergeStrategies, TContext = unknown> {
4848
/** Fallback strategy when no rule matches */
49-
defaultStrategy?: ForbidBangEnd<T> | ForbidBangEnd<T>[];
49+
defaultStrategy?: T | T[];
5050

5151
/** Rule tree mapping globs → strategies */
5252
rules?: RuleTree<T>;

0 commit comments

Comments
 (0)