Skip to content

Commit c2e2fdb

Browse files
committed
fix(matcher): normalize . as path separator for micromatch
- Treat unescaped `.` as `/` so patterns behave like nested paths - Keeps escaped `\.` as literal dot - Fixes false positive where `"a.b.c.d"` matched `"*.d"` - Ensures `"**/*.d"` works correctly for nested path cases
1 parent 205a257 commit c2e2fdb

File tree

2 files changed

+77
-45
lines changed

2 files changed

+77
-45
lines changed

lib/src/matcher.test.ts

Lines changed: 41 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,55 @@
11
import { describe, it, expect } from "vitest";
22
import { basicMatcher, loadMatcher } from "./matcher";
33

4-
describe("basicMatcher", () => {
5-
it("matches exact strings", () => {
6-
expect(basicMatcher.isMatch("foo.bar", ["foo.bar"])).toBe(true);
7-
expect(basicMatcher.isMatch("foo.bar", ["foo.baz"])).toBe(false);
8-
});
4+
for (const matcherName of ["basicMatcher", "micromatch", "picomatch"] as const) {
5+
describe(matcherName, async () => {
6+
const matcher = matcherName === "basicMatcher" ? basicMatcher : await loadMatcher(matcherName);
7+
it("matches exact strings", () => {
8+
expect(matcher.isMatch("foo.bar", ["foo.bar"])).toBe(true);
9+
expect(matcher.isMatch("foo.bar", ["foo.baz"])).toBe(false);
10+
});
911

10-
it("matches with single * segment", () => {
11-
expect(basicMatcher.isMatch("foo.bar", ["foo.*"])).toBe(true);
12-
expect(basicMatcher.isMatch("foo.baz.qux", ["foo.*.qux"])).toBe(true);
13-
expect(basicMatcher.isMatch("foo.bar", ["*.baz"])).toBe(false);
14-
});
12+
it("matches with single * segment", () => {
13+
expect(matcher.isMatch("foo.bar", ["foo.*"])).toBe(true);
14+
expect(matcher.isMatch("foo.baz.qux", ["foo.*.qux"])).toBe(true);
15+
expect(matcher.isMatch("foo.bar", ["*.baz"])).toBe(false);
16+
});
1517

16-
it("matches with ** for nested paths", () => {
17-
expect(basicMatcher.isMatch("a.b.c", ["a.**"])).toBe(true);
18-
expect(basicMatcher.isMatch("a.b.c.d", ["a.**.d"])).toBe(true);
19-
expect(basicMatcher.isMatch("a.b.c.d", ["**.d"])).toBe(true);
20-
expect(basicMatcher.isMatch("a.b.c.d", ["*.d"])).toBe(false);
21-
expect(basicMatcher.isMatch("a.b.c", ["**"])).toBe(true);
22-
});
18+
it("matches with ** for nested paths", () => {
19+
expect(matcher.isMatch("a.b.c", ["a.**"])).toBe(true);
20+
expect(matcher.isMatch("a.b.c.d", ["a.**.d"])).toBe(true);
21+
expect(matcher.isMatch("a.b.c.d", ["**.d"])).toBe(true);
22+
expect(matcher.isMatch("a.b.c.d", ["*.d"])).toBe(false);
23+
expect(matcher.isMatch("a.b.c", ["**"])).toBe(true);
24+
});
2325

24-
it("supports prefix-* and *-suffix in segments", () => {
25-
expect(basicMatcher.isMatch("foo.helloWorld", ["foo.hello*"])).toBe(true);
26-
expect(basicMatcher.isMatch("foo.helloWorld", ["foo.*World"])).toBe(true);
27-
expect(basicMatcher.isMatch("foo.helloWorld", ["foo.hel*ld"])).toBe(true);
28-
expect(basicMatcher.isMatch("foo.helloWorld", ["foo.hel*xyz"])).toBe(false);
29-
});
26+
it("supports prefix-* and *-suffix in segments", () => {
27+
expect(matcher.isMatch("foo.helloWorld", ["foo.hello*"])).toBe(true);
28+
expect(matcher.isMatch("foo.helloWorld", ["foo.*World"])).toBe(true);
29+
expect(matcher.isMatch("foo.helloWorld", ["foo.hel*ld"])).toBe(true);
30+
expect(matcher.isMatch("foo.helloWorld", ["foo.hel*xyz"])).toBe(false);
31+
});
3032

31-
it("returns true if any pattern matches", () => {
32-
expect(basicMatcher.isMatch("foo.bar", ["no.match", "foo.*"])).toBe(true);
33-
});
33+
it("returns true if any pattern matches", () => {
34+
expect(matcher.isMatch("foo.bar", ["no.match", "foo.*"])).toBe(true);
35+
});
3436

35-
it("handles empty patterns", () => {
36-
expect(basicMatcher.isMatch("foo.bar", [])).toBe(false);
37-
});
37+
it("handles empty patterns", () => {
38+
expect(matcher.isMatch("foo.bar", [])).toBe(false);
39+
});
3840

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);
41+
it("respects escapes", () => {
42+
expect(matcher.isMatch("foo.helloWorld", ["foo.hello*"])).toBe(true);
43+
expect(matcher.isMatch("foo.helloWorld", ["foo.hello\\*"])).toBe(false);
44+
expect(matcher.isMatch("foo.hello*", ["foo.hello\\*"])).toBe(true);
4345

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-
expect(basicMatcher.isMatch("a.c.b", ["a.***"])).toBe(false);
46+
expect(matcher.isMatch("a.b.c", ["a.**"])).toBe(true);
47+
expect(matcher.isMatch("a.b.c", ["a.\\*\\*"])).toBe(false);
48+
expect(matcher.isMatch("a.**", ["a.\\*\\*"])).toBe(true);
49+
expect(matcher.isMatch("a.c.b", ["a.***"])).toBe(false);
50+
});
4851
});
49-
});
52+
}
5053

5154
describe("loadMatcher", () => {
5255
it("loads micromatch when available", async () => {

lib/src/matcher.ts

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* - `*` → matches any single field segment
66
* - `**` → matches any number of nested field segments
77
* - Prefix/suffix like `prefix-*`, `*-suffix`
8-
* - Backslash escaping (micromatch-style): `a\.b` → literal dot, `\*` → literal asterisk
8+
* - Backslash escaping (micromatch-style): `a\/b` → literal slash, `\*` → literal asterisk
99
*
1010
* Micromatch/Picomatch can be loaded at runtime as optional peers.
1111
*/
@@ -26,7 +26,10 @@ export interface Matcher {
2626
* - Backslash escaping for `*` and `.`
2727
*/
2828
export const basicMatcher: Matcher = {
29-
isMatch: (str, patterns) => patterns.some(p => matchOne(str, p)),
29+
isMatch: (str, patterns) => {
30+
const internalStr = toInternal(str);
31+
return patterns.some(p => matchOne(internalStr, toInternal(p)));
32+
},
3033
};
3134

3235
/**
@@ -50,7 +53,7 @@ export const loadMatcher = async (name: "micromatch" | "picomatch"): Promise<Mat
5053
return {
5154
isMatch: (str, pats) => {
5255
try {
53-
return micromatch.isMatch(str, pats);
56+
return micromatch.isMatch(toInternal(str), pats.map(toInternal));
5457
} catch (err) {
5558
/* v8 ignore next 4 - difficult to simulate this case with micromatch/picomatch in devDeps */
5659
throw new Error(`micromatch failed to run isMatch: ${(err as Error).message}`);
@@ -73,8 +76,8 @@ export const loadMatcher = async (name: "micromatch" | "picomatch"): Promise<Mat
7376
return {
7477
isMatch: (str, pats) => {
7578
try {
76-
const fn = picomatch(pats);
77-
return fn(str);
79+
const fn = picomatch(pats.map(toInternal));
80+
return fn(toInternal(str));
7881
} catch (err) {
7982
/* v8 ignore next 4 - difficult to simulate this case with micromatch/picomatch in devDeps */
8083
throw new Error(`picomatch failed to run isMatch: ${(err as Error).message}`);
@@ -88,6 +91,32 @@ export const loadMatcher = async (name: "micromatch" | "picomatch"): Promise<Mat
8891

8992
/* ---------------- Internal helpers ---------------- */
9093

94+
/**
95+
* Convert a pattern/field path into internal form (`/` separated).
96+
* - Unescaped `.` → `/`
97+
* - Unescaped `/` → `/`
98+
* - Escaped `\.` → `.`
99+
* - Escaped `\/` → `/`
100+
*/
101+
const toInternal = (input: string): string => {
102+
let out = "";
103+
let escaped = false;
104+
for (const ch of input) {
105+
if (escaped) {
106+
out += ch; // take literally
107+
escaped = false;
108+
} else if (ch === "\\") {
109+
escaped = true;
110+
out += "\\"; // preserve backslash for downstream escape handling
111+
} else if (ch === ".") {
112+
out += "/"; // dot becomes slash
113+
} else {
114+
out += ch;
115+
}
116+
}
117+
return out;
118+
};
119+
91120
/**
92121
* Splits a glob pattern into dot-separated segments, honoring backslash escaping.
93122
* - Do not split on `\.` (literal dot remains within the same segment)
@@ -108,7 +137,7 @@ const splitPattern = (pattern: string): string[] => {
108137
escaped = false;
109138
} else if (ch === "\\") {
110139
escaped = true;
111-
} else if (ch === ".") {
140+
} else if (ch === "/") {
112141
segments.push(buf);
113142
buf = "";
114143
} else {
@@ -121,7 +150,7 @@ const splitPattern = (pattern: string): string[] => {
121150
};
122151

123152
const matchOne = (str: string, pattern: string): boolean => {
124-
const strSegments = str.split(".");
153+
const strSegments = str.split("/");
125154
const patSegments = splitPattern(pattern);
126155
return matchSegments(strSegments, patSegments);
127156
};

0 commit comments

Comments
 (0)