85 lines
3.0 KiB
TypeScript
85 lines
3.0 KiB
TypeScript
/**
|
|
* Glob-style field-path matcher for masking rule evaluation.
|
|
*
|
|
* Supports three matching modes that cover the common cases for nested-object
|
|
* traversal:
|
|
* - Exact match (`"user.email"` → `"user.email"`)
|
|
* - Single-segment wildcard (`*`) — matches any one dot-separated segment
|
|
* - Multi-segment wildcard (`**`) — matches any number of dot-separated
|
|
* segments (including zero when at the end of a pattern)
|
|
*/
|
|
|
|
/**
|
|
* Test whether a concrete `fieldPath` matches the supplied glob `pattern`.
|
|
*
|
|
* Rules:
|
|
* - `*` matches exactly one path segment (characters between dots).
|
|
* - `**` matches zero or more path segments.
|
|
* - All other characters must match literally (case-sensitive).
|
|
* - Leading/trailing dots are treated as empty segments and behave
|
|
* consistently with the wildcard rules above.
|
|
*
|
|
* @param fieldPath - The concrete dot-notation path, e.g. `"user.address.city"`.
|
|
* @param pattern - The glob pattern to test against, e.g. `"user.*"` or `"**.email"`.
|
|
* @returns `true` when `fieldPath` satisfies `pattern`, `false` otherwise.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* matchPath('user.email', 'user.email'); // true — exact
|
|
* matchPath('user.email', 'user.*'); // true — single-segment wildcard
|
|
* matchPath('user.address.city', 'user.**'); // true — multi-segment wildcard
|
|
* matchPath('billing.email', '**.email'); // true — leading multi-segment
|
|
* matchPath('user.name', 'user.email'); // false — no match
|
|
* ```
|
|
*/
|
|
export function matchPath(fieldPath: string, pattern: string): boolean {
|
|
const pathSegs = fieldPath.split(".");
|
|
const patSegs = pattern.split(".");
|
|
return matchSegments(pathSegs, 0, patSegs, 0);
|
|
}
|
|
|
|
/**
|
|
* Recursive backtracking matcher that aligns path segments with pattern
|
|
* segments, handling `**` greedily with fall-back.
|
|
*/
|
|
function matchSegments(
|
|
path: readonly string[],
|
|
pi: number,
|
|
pat: readonly string[],
|
|
si: number
|
|
): boolean {
|
|
// Both exhausted → match.
|
|
if (pi === path.length && si === pat.length) return true;
|
|
|
|
// Pattern exhausted but path has remaining segments → no match.
|
|
if (si === pat.length) return false;
|
|
|
|
const seg = pat[si];
|
|
|
|
if (seg === "**") {
|
|
// `**` can match zero or more path segments.
|
|
// Try consuming zero segments (advance only pattern).
|
|
if (matchSegments(path, pi, pat, si + 1)) return true;
|
|
// Try consuming one path segment at a time.
|
|
for (let consumed = pi; consumed < path.length; consumed++) {
|
|
if (matchSegments(path, consumed + 1, pat, si + 1)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Path is exhausted but pattern still has non-`**` segments → no match.
|
|
if (pi === path.length) return false;
|
|
|
|
// `*` matches any single segment.
|
|
if (seg === "*") {
|
|
return matchSegments(path, pi + 1, pat, si + 1);
|
|
}
|
|
|
|
// Literal segment — must match exactly.
|
|
if (path[pi] === seg) {
|
|
return matchSegments(path, pi + 1, pat, si + 1);
|
|
}
|
|
|
|
return false;
|
|
}
|