*/ private array $named; /** * Compile routes directly from DiscoveredAttribute objects with subdomain support * @return array, dynamic: array}>> */ public function compile(DiscoveredAttribute ...$discoveredRoutes): array { $compiled = []; $named = []; foreach ($discoveredRoutes as $discoveredAttribute) { // Create actual Route attribute instance $routeAttribute = $discoveredAttribute->createAttributeInstance(); if (! $routeAttribute instanceof Route) { continue; } // Extract route data directly from the Route attribute $method = strtoupper($routeAttribute->method->value); $path = $routeAttribute->getPathAsString(); $routeName = $this->extractRouteName($routeAttribute->name); // Process subdomain patterns $subdomainPatterns = SubdomainPattern::fromInput($routeAttribute->subdomain); // If no subdomains specified, use default if (empty($subdomainPatterns)) { $subdomainPatterns = [new SubdomainPattern('')]; } foreach ($subdomainPatterns as $subdomainPattern) { $subdomainKey = $subdomainPattern->getCompilationKey(); $compiled[$method] ??= []; $compiled[$method][$subdomainKey] ??= ['static' => [], 'dynamic' => []]; if (! str_contains($path, '{')) { // Static route $parameterCollection = $this->createParameterCollection($discoveredAttribute->additionalData['parameters'] ?? []); $staticRoute = new StaticRoute( controller : $discoveredAttribute->className->getFullyQualified(), action : $discoveredAttribute->methodName?->toString() ?? '', parameters : $discoveredAttribute->additionalData['parameters'] ?? [], name : $routeName ?? '', path : $path, attributes : $discoveredAttribute->additionalData['attributes'] ?? [], parameterCollection: $parameterCollection ); $compiled[$method][$subdomainKey]['static'][$path] = $staticRoute; if ($routeName) { $named[$routeName] = $staticRoute; } } else { // Dynamic route $paramNames = []; $regex = $this->convertPathToRegex($path, $paramNames); $parameterCollection = $this->createParameterCollection($discoveredAttribute->additionalData['parameters'] ?? []); $dynamicRoute = new DynamicRoute( regex : $regex, paramNames : $paramNames, controller : $discoveredAttribute->className->getFullyQualified(), action : $discoveredAttribute->methodName?->toString() ?? '', parameters : $discoveredAttribute->additionalData['parameters'] ?? [], paramValues : [], name : $routeName ?? '', path : $path, attributes : $discoveredAttribute->additionalData['attributes'] ?? [], parameterCollection: $parameterCollection ); $compiled[$method][$subdomainKey]['dynamic'][] = $dynamicRoute; if ($routeName) { $named[$routeName] = $dynamicRoute; } } } } if (! isset($this->named)) { $this->named = $named; } return $compiled; } /** * Convert legacy parameters array to ParameterCollection */ private function createParameterCollection(array $parameters): ParameterCollection { $methodParameters = []; foreach ($parameters as $param) { $methodParameters[] = new MethodParameter( name: $param['name'], type: $param['type'], isBuiltin: $param['isBuiltin'], hasDefault: $param['hasDefault'], default: $param['default'] ?? null ); } return new ParameterCollection(...$methodParameters); } /** * @param array $routes * @return array */ public function compileNamedRoutes(array $routes): array { return $this->named; } /** * Konvertiert zB. /user/{id}/edit → ~^/user/([^/]+)/edit$~ und gibt ['id'] als Parameternamen zurück. * * @param string $path * @param array &$paramNames * @return string */ private function convertPathToRegex(string $path, array &$paramNames): string { $paramNames = []; $regex = preg_replace_callback('#\{(\w+)(\*)?}#', function ($matches) use (&$paramNames) { $paramNames[] = $matches[1]; // Wenn {id*} dann erlaube Slashes, aber mache es non-greedy if (isset($matches[2]) && $matches[2] === '*') { return '(.+?)'; // Non-greedy: matcht so wenig wie möglich } return '([^/]+)'; // Keine Slashes }, $path); return '~^' . $regex . '$~'; } public function getAttributeClass(): string { return Route::class; } /** * Compile optimized routes directly from DiscoveredAttribute objects with subdomain support */ public function compileOptimized(DiscoveredAttribute ...$discoveredRoutes): CompiledRoutes { $compiled = $this->compile(...$discoveredRoutes); $optimizedStatic = []; $optimizedDynamic = []; foreach ($compiled as $method => $subdomainRoutes) { foreach ($subdomainRoutes as $subdomainKey => $routes) { $optimizedStatic[$method][$subdomainKey] = $routes['static']; if (! empty($routes['dynamic'])) { $optimizedDynamic[$method][$subdomainKey] = $this->createCompiledPattern($routes['dynamic']); } } } return new CompiledRoutes($optimizedStatic, $optimizedDynamic, $this->named); } /** * Creates optimized compiled patterns with route prioritization * @param array $dynamicRoutes */ private function createCompiledPattern(array $dynamicRoutes): CompiledPattern { // Sort routes by priority (specific patterns first, then by path complexity) $prioritizedRoutes = $this->prioritizeRoutes($dynamicRoutes); // Group routes into batches for smaller regex patterns $routeBatches = $this->groupRoutesByComplexity($prioritizedRoutes); $compiledBatches = []; /** @var array $routeData */ $routeData = []; $globalIndex = 0; foreach ($routeBatches as $batchName => $batch) { $patterns = []; $currentIndex = 1; // After full match (Index 0) foreach ($batch as $route) { $pattern = $this->stripAnchors($route->regex); $patterns[] = "({$pattern})"; // This route's main capture group index $routeGroupIndex = $currentIndex; // Parameter-Mapping berechnen - parameters are INSIDE the route pattern // So their indices are routeGroupIndex + 1, routeGroupIndex + 2, etc. $paramMap = []; $paramOffset = 1; // Parameters start right after the route's capture group foreach ($route->paramNames as $paramName) { $paramMap[$paramName] = $routeGroupIndex + $paramOffset++; } // Now advance currentIndex past ALL capture groups in this route's pattern // That's 1 (the route group) + count(paramNames) (the parameters inside it) $currentIndex += 1 + count($route->paramNames); $routeData[$globalIndex] = new RouteData( route: $route, paramMap: $paramMap, routeGroupIndex: $routeGroupIndex, batch: $batchName, regex: '~^' . $this->stripAnchors($route->regex) . '$~' // Pre-compiled individual regex ); $globalIndex++; } $compiledBatches[$batchName] = [ 'regex' => '~^(?:' . implode('|', $patterns) . ')$~', 'routes' => array_slice($routeData, $globalIndex - count($batch), count($batch), true), ]; } // Use the first batch's regex for backward compatibility, but store all batches $primaryRegex = ! empty($compiledBatches) ? reset($compiledBatches)['regex'] : '~^$~'; return new CompiledPattern($primaryRegex, $routeData, $compiledBatches); } /** * Prioritize routes by specificity and expected frequency * @param array $routes * @return array */ private function prioritizeRoutes(array $routes): array { usort($routes, function ($a, $b) { // 1. API routes first (typically more frequent) $aIsApi = str_starts_with($a->path, '/api/'); $bIsApi = str_starts_with($b->path, '/api/'); if ($aIsApi !== $bIsApi) { return $bIsApi <=> $aIsApi; // API routes first } // 2. Routes with fewer parameters first (more specific) $aParamCount = count($a->paramNames); $bParamCount = count($b->paramNames); if ($aParamCount !== $bParamCount) { return $aParamCount <=> $bParamCount; } // 3. Shorter paths first (typically more specific) $aLength = strlen($a->path); $bLength = strlen($b->path); return $aLength <=> $bLength; }); return $routes; } /** * Group routes by complexity for optimized batch processing * @param array $routes * @return array> */ private function groupRoutesByComplexity(array $routes): array { $groups = [ 'simple' => [], // /api/{id}, /user/{id} - 1 parameter 'medium' => [], // /api/{type}/{id} - 2 parameters 'complex' => [], // 3+ parameters or wildcards ]; foreach ($routes as $route) { $paramCount = count($route->paramNames); $hasWildcard = str_contains($route->path, '{*}'); if ($hasWildcard || $paramCount >= 3) { $groups['complex'][] = $route; } elseif ($paramCount === 2) { $groups['medium'][] = $route; } else { $groups['simple'][] = $route; } } // Remove empty groups return array_filter($groups, fn ($group) => ! empty($group)); } private function stripAnchors(string $regex): string { if (preg_match('/^~\^(.+)\$~$/', $regex, $matches)) { return $matches[1]; } return $regex; } /** * Extract route name as string from enum or string */ private function extractRouteName(RouteNameInterface|string|null $routeName): string { if ($routeName instanceof RouteNameInterface) { return $routeName->value; } return (string) ($routeName ?? ''); } }