Skip to content

Commit 7fcc1aa

Browse files
committed
Create matcher utilities
1 parent ecb5096 commit 7fcc1aa

File tree

2 files changed

+192
-0
lines changed

2 files changed

+192
-0
lines changed

lib/src/matcher.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2+
import { basicMatcher, loadMatcher } from "./matcher";
3+
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+
});
9+
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+
});
15+
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", ["**"])).toBe(true);
21+
});
22+
23+
it("supports prefix-* and *-suffix in segments", () => {
24+
expect(basicMatcher.isMatch("foo.helloWorld", ["foo.hello*"])).toBe(true);
25+
expect(basicMatcher.isMatch("foo.helloWorld", ["foo.*World"])).toBe(true);
26+
expect(basicMatcher.isMatch("foo.helloWorld", ["foo.hel*ld"])).toBe(true);
27+
expect(basicMatcher.isMatch("foo.helloWorld", ["foo.hel*xyz"])).toBe(false);
28+
});
29+
30+
it("returns true if any pattern matches", () => {
31+
expect(basicMatcher.isMatch("foo.bar", ["no.match", "foo.*"])).toBe(true);
32+
});
33+
34+
it("handles empty patterns", () => {
35+
expect(basicMatcher.isMatch("foo.bar", [])).toBe(false);
36+
});
37+
});
38+
39+
describe("loadMatcher", () => {
40+
const realRequire = require;
41+
42+
beforeEach(() => {
43+
vi.resetModules();
44+
});
45+
46+
afterEach(() => {
47+
// restore require if patched
48+
global.require = realRequire;
49+
});
50+
51+
it("loads micromatch when available", () => {
52+
const mm = loadMatcher("micromatch");
53+
expect(mm.isMatch("foo", ["foo"])).toBe(true);
54+
expect(mm.isMatch("bar", ["foo"])).toBe(false);
55+
});
56+
57+
it("loads picomatch when available", () => {
58+
const pm = loadMatcher("picomatch");
59+
expect(pm.isMatch("ok", ["ok*"])).toBe(true);
60+
expect(pm.isMatch("fail", ["pattern"])).toBe(false);
61+
});
62+
63+
it("throws on unknown matcher", () => {
64+
expect(() => loadMatcher("invalid" as any)).toThrow(/Unknown matcher/);
65+
});
66+
});

lib/src/matcher.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
/**
2+
* Matcher abstraction + adapters for micromatch/picomatch (optional).
3+
*
4+
* By default, a minimal matcher is used that understands:
5+
* - `*` → matches any single field segment
6+
* - `**` → matches any number of nested field segments
7+
* - Literal prefix/suffix globs like `prefix-*` or `*-suffix`
8+
*
9+
* Micromatch or Picomatch can be loaded at runtime if available,
10+
* but they are treated as optional peer dependencies.
11+
*/
12+
13+
export interface Matcher {
14+
/**
15+
* Returns true if `str` matches at least one of the provided glob `patterns`.
16+
*/
17+
isMatch: (str: string, patterns: string[]) => boolean;
18+
}
19+
20+
/**
21+
* Minimal homegrown matcher (default).
22+
* Only supports `*` and `**` operators on dot-paths.
23+
*/
24+
export const basicMatcher: Matcher = {
25+
isMatch: (str, patterns) => {
26+
return patterns.some(pattern => matchOne(str, pattern));
27+
},
28+
};
29+
30+
/**
31+
* Attempts to load a named matcher adapter.
32+
* @param name `"micromatch" | "picomatch"`
33+
* @returns A Matcher implementation.
34+
*/
35+
export const loadMatcher = (name: "micromatch" | "picomatch"): Matcher => {
36+
if (name === "micromatch") {
37+
try {
38+
const micromatch = require("micromatch");
39+
return { isMatch: (str, pats) => micromatch.isMatch(str, pats) };
40+
} catch {
41+
throw new Error(
42+
`micromatch is not installed. Please add it as a dependency if you want to use it.`,
43+
);
44+
}
45+
}
46+
47+
if (name === "picomatch") {
48+
try {
49+
const picomatch = require("picomatch");
50+
return {
51+
isMatch: (str, pats) => {
52+
const fn = picomatch(pats);
53+
return fn(str);
54+
},
55+
};
56+
} catch {
57+
throw new Error(
58+
`picomatch is not installed. Please add it as a dependency if you want to use it.`,
59+
);
60+
}
61+
}
62+
63+
throw new Error(`Unknown matcher name: ${name}`);
64+
};
65+
66+
/* ---------------- Internal helpers ---------------- */
67+
68+
/**
69+
* Matches a single string against a glob pattern using only * and ** semantics.
70+
*/
71+
const matchOne = (str: string, pattern: string): boolean => {
72+
const strSegments = str.split(".");
73+
const patSegments = pattern.split(".");
74+
75+
return matchSegments(strSegments, patSegments);
76+
};
77+
78+
/**
79+
* Matches field segments against pattern segments.
80+
* - `*` = any single segment
81+
* - `**` = zero or more segments
82+
*/
83+
const matchSegments = (strSegments: string[], patternSegments: string[]): boolean => {
84+
let si = 0;
85+
let pi = 0;
86+
87+
while (si < strSegments.length && pi < patternSegments.length) {
88+
const pat = patternSegments[pi];
89+
if (pat === "**") {
90+
// match remainder greedily
91+
if (pi === patternSegments.length - 1) return true;
92+
for (let skip = 0; si + skip <= strSegments.length; skip++) {
93+
if (matchSegments(strSegments.slice(si + skip), patternSegments.slice(pi + 1))) {
94+
return true;
95+
}
96+
}
97+
return false;
98+
}
99+
100+
if (!segmentMatches(strSegments[si], pat)) return false;
101+
102+
si++;
103+
pi++;
104+
}
105+
106+
// consume trailing ** patterns
107+
while (pi < patternSegments.length && patternSegments[pi] === "**") pi++;
108+
109+
return si === strSegments.length && pi === patternSegments.length;
110+
};
111+
112+
/**
113+
* Matches a single field segment against a pattern segment.
114+
* - `*` = any
115+
* - `prefix-*` / `*-suffix` = prefix/suffix match
116+
*/
117+
const segmentMatches = (seg: string, pat: string): boolean => {
118+
if (pat === "*") return true;
119+
120+
if (pat.includes("*")) {
121+
const [pre, suf] = pat.split("*");
122+
return seg.startsWith(pre) && seg.endsWith(suf);
123+
}
124+
125+
return seg === pat;
126+
};

0 commit comments

Comments
 (0)