|
| 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