import type { IMatcher, IPathMatchResult } from '../types.js'; /** * PathMatcher provides comprehensive path matching functionality * Supporting exact matches, wildcards, and parameter extraction */ export class PathMatcher implements IMatcher { /** * Convert a path pattern to a regex and extract parameter names * Supports: * - Exact paths: /api/users * - Wildcards: /api/* * - Parameters: /api/users/:id * - Mixed: /api/users/:id/* */ private static patternToRegex(pattern: string): { regex: RegExp; paramNames: string[] } { const paramNames: string[] = []; let regexPattern = pattern; // Escape special regex characters except : and * regexPattern = regexPattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&'); // Handle path parameters (:param) regexPattern = regexPattern.replace(/:(\w+)/g, (match, paramName) => { paramNames.push(paramName); return '([^/]+)'; // Match any non-slash characters }); // Handle wildcards regexPattern = regexPattern.replace(/\*/g, '(.*)'); // Ensure the pattern matches from start regexPattern = `^${regexPattern}`; // If pattern doesn't end with wildcard, ensure it matches to end // But only for patterns that don't have parameters or wildcards if (!pattern.includes('*') && !pattern.includes(':') && !pattern.endsWith('/')) { regexPattern = `${regexPattern}$`; } return { regex: new RegExp(regexPattern), paramNames }; } /** * Match a path pattern against a request path * @param pattern The pattern to match * @param path The request path to test * @returns Match result with params and remainder */ static match(pattern: string, path: string): IPathMatchResult { // Handle null/undefined cases if (!pattern || !path) { return { matches: false }; } // Normalize paths (remove trailing slashes unless it's just "/") const normalizedPattern = pattern === '/' ? '/' : pattern.replace(/\/$/, ''); const normalizedPath = path === '/' ? '/' : path.replace(/\/$/, ''); // Exact match (most common case) if (normalizedPattern === normalizedPath) { return { matches: true, pathMatch: normalizedPath, pathRemainder: '', params: {} }; } // Pattern matching (wildcards and parameters) const { regex, paramNames } = this.patternToRegex(normalizedPattern); const match = normalizedPath.match(regex); if (!match) { return { matches: false }; } // Extract parameters const params: Record = {}; paramNames.forEach((name, index) => { params[name] = match[index + 1]; }); // Calculate path match and remainder let pathMatch = match[0]; let pathRemainder = normalizedPath.substring(pathMatch.length); // Handle wildcard captures if (normalizedPattern.includes('*') && match.length > paramNames.length + 1) { const wildcardCapture = match[match.length - 1]; if (wildcardCapture) { pathRemainder = wildcardCapture; pathMatch = normalizedPath.substring(0, normalizedPath.length - wildcardCapture.length); } } // Clean up path match (remove trailing slash if present) if (pathMatch !== '/' && pathMatch.endsWith('/')) { pathMatch = pathMatch.slice(0, -1); } return { matches: true, pathMatch, pathRemainder, params }; } /** * Check if a pattern contains parameters or wildcards */ static isDynamicPattern(pattern: string): boolean { return pattern.includes(':') || pattern.includes('*'); } /** * Calculate the specificity of a path pattern * Higher values mean more specific patterns */ static calculateSpecificity(pattern: string): number { if (!pattern) return 0; let score = 0; // Exact paths are most specific if (!this.isDynamicPattern(pattern)) { score += 100; } // Count path segments const segments = pattern.split('/').filter(s => s.length > 0); score += segments.length * 10; // Count static segments (more static = more specific) const staticSegments = segments.filter(s => !s.startsWith(':') && s !== '*'); score += staticSegments.length * 20; // Penalize wildcards and parameters const wildcards = (pattern.match(/\*/g) || []).length; const params = (pattern.match(/:/g) || []).length; score -= wildcards * 30; // Wildcards are very generic score -= params * 10; // Parameters are somewhat generic // Bonus for longer patterns score += pattern.length; return score; } /** * Find all matching patterns from a list * Returns patterns sorted by specificity (most specific first) */ static findAllMatches(patterns: string[], path: string): Array<{ pattern: string; result: IPathMatchResult; }> { const matches = patterns .map(pattern => ({ pattern, result: this.match(pattern, path) })) .filter(({ result }) => result.matches); // Sort by specificity (highest first) return matches.sort((a, b) => this.calculateSpecificity(b.pattern) - this.calculateSpecificity(a.pattern) ); } /** * Instance method for interface compliance */ match(pattern: string, path: string): IPathMatchResult { return PathMatcher.match(pattern, path); } }