method !== strtoupper($method)) { return false; } return preg_match($this->compile($route->path), $path) === 1; } /** * Extract named route parameters from a matched path. * * @return array */ public function extractParams(RouteDef $route, string $path): array { if (preg_match($this->compile($route->path), $path, $matches) !== 1) { return []; } $params = []; foreach ($matches as $key => $value) { if (is_string($key)) { $params[$key] = $value; } } return $params; } /** * Order two routes so the more specific one sorts first. * * Segments are compared left to right; a literal segment beats a * {parameter} segment at the same position. So /entries/archive sorts * before /entries/{slug} and /admin/entries/preview before * /admin/entries/{id}, independent of registration order. */ public function compare(RouteDef $a, RouteDef $b): int { $segmentsA = $this->segments($a->path); $segmentsB = $this->segments($b->path); $length = max(count($segmentsA), count($segmentsB)); for ($i = 0; $i < $length; $i++) { $rankA = $this->rank($segmentsA[$i] ?? null); $rankB = $this->rank($segmentsB[$i] ?? null); if ($rankA !== $rankB) { return $rankA <=> $rankB; } } return 0; } private function compile(string $path): string { $escaped = preg_quote($path, '#'); $pattern = preg_replace( '#\\\\\{([a-zA-Z_]+)\\\\\}#', '(?P<$1>[^/]+)', $escaped, ); return '#^' . $pattern . '$#'; } /** * @return list */ private function segments(string $path): array { $trimmed = trim($path, '/'); if ($trimmed === '') { return []; } return explode('/', $trimmed); } /** * Lower rank = more specific: literal (0) < parameter (1) < missing (2). */ private function rank(?string $segment): int { if ($segment === null) { return 2; } if (str_starts_with($segment, '{') && str_ends_with($segment, '}')) { return 1; } return 0; } }