diff --git a/README.md b/README.md index 7fe1a8bb..bd67f323 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ This extension provides following features: * Provides correct return types for `TreeBuilder` and `NodeDefinition` objects. * Notifies you when you try to get an unregistered service from the container. * Notifies you when you try to get a private service from the container. +* Notifies you when you try to generate a URL for a non-existing route name. * Optionally correct return types for `InputInterface::getArgument()`, `::getOption`, `::hasArgument`, and `::hasOption`. @@ -148,3 +149,13 @@ Call the new env in your `console-application.php`: ```php $kernel = new \App\Kernel('phpstan_env', (bool) $_SERVER['APP_DEBUG']); ``` + +# Analysis of generating URLs + +You have to provide a path to `url_generating_routes.php` for the url generating analysis to work. + +```yaml +parameters: + symfony: + urlGeneratingRulesFile: var/cache/dev/url_generating_routes.xml +``` diff --git a/composer.json b/composer.json index 08f5b685..ba64b2d5 100644 --- a/composer.json +++ b/composer.json @@ -33,6 +33,7 @@ "symfony/http-foundation": "^5.1", "symfony/messenger": "^4.2 || ^5.0", "symfony/polyfill-php80": "^1.24", + "symfony/routing": "^4.4 || ^5.0", "symfony/serializer": "^4.0 || ^5.0" }, "config": { diff --git a/extension.neon b/extension.neon index 4870f24c..6d5874ba 100644 --- a/extension.neon +++ b/extension.neon @@ -11,6 +11,8 @@ parameters: constantHassers: true console_application_loader: null consoleApplicationLoader: null + url_generating_rules_file: null + urlGeneratingRulesFile: null stubFiles: - stubs/Psr/Cache/CacheItemInterface.stub - stubs/Symfony/Bundle/FrameworkBundle/KernelBrowser.stub @@ -65,6 +67,8 @@ parametersSchema: constantHassers: bool() console_application_loader: schema(string(), nullable()) consoleApplicationLoader: schema(string(), nullable()) + url_generating_rules_file: schema(string(), nullable()) + urlGeneratingRulesFile: schema(string(), nullable()) ]) services: @@ -89,6 +93,13 @@ services: - factory: @symfony.parameterMapFactory::create() + # url generating routes map + symfony.urlGeneratingRoutesMapFactory: + class: PHPStan\Symfony\UrlGeneratingRoutesMapFactory + factory: PHPStan\Symfony\PhpUrlGeneratingRoutesMapFactory + - + factory: @symfony.urlGeneratingRoutesMapFactory::create() + # ControllerTrait::get()/has() return type - factory: PHPStan\Type\Symfony\ServiceDynamicReturnTypeExtension(Symfony\Component\DependencyInjection\ContainerInterface) diff --git a/rules.neon b/rules.neon index cedcea7a..2ef6f7f3 100644 --- a/rules.neon +++ b/rules.neon @@ -5,4 +5,5 @@ rules: - PHPStan\Rules\Symfony\InvalidArgumentDefaultValueRule - PHPStan\Rules\Symfony\UndefinedOptionRule - PHPStan\Rules\Symfony\InvalidOptionDefaultValueRule + - PHPStan\Rules\Symfony\UrlGeneratorInterfaceUnknownRouteRule diff --git a/src/Rules/Symfony/UrlGeneratorInterfaceUnknownRouteRule.php b/src/Rules/Symfony/UrlGeneratorInterfaceUnknownRouteRule.php new file mode 100644 index 00000000..645acb2f --- /dev/null +++ b/src/Rules/Symfony/UrlGeneratorInterfaceUnknownRouteRule.php @@ -0,0 +1,75 @@ + + */ +final class UrlGeneratorInterfaceUnknownRouteRule implements Rule +{ + + /** @var UrlGeneratingRoutesMap */ + private $urlGeneratingRoutesMap; + + public function __construct(UrlGeneratingRoutesMap $urlGeneratingRoutesMap) + { + $this->urlGeneratingRoutesMap = $urlGeneratingRoutesMap; + } + + public function getNodeType(): string + { + return MethodCall::class; + } + + /** + * @param \PhpParser\Node $node + * @param \PHPStan\Analyser\Scope $scope + * @return (string|\PHPStan\Rules\RuleError)[] errors + */ + public function processNode(Node $node, Scope $scope): array + { + if (!$node instanceof MethodCall) { + throw new ShouldNotHappenException(); + } + + if (!$node->name instanceof Node\Identifier) { + return []; + } + + if (in_array($node->name->name, ['generate', 'generateUrl'], true) === false || !isset($node->getArgs()[0])) { + return []; + } + + $argType = $scope->getType($node->var); + $isControllerType = (new ObjectType('Symfony\Bundle\FrameworkBundle\Controller\Controller'))->isSuperTypeOf($argType); + $isAbstractControllerType = (new ObjectType('Symfony\Bundle\FrameworkBundle\Controller\AbstractController'))->isSuperTypeOf($argType); + $isUrlGeneratorInterface = (new ObjectType('Symfony\Component\Routing\Generator\UrlGeneratorInterface'))->isSuperTypeOf($argType); + if ( + $isControllerType->no() + && $isAbstractControllerType->no() + && $isUrlGeneratorInterface->no() + ) { + return []; + } + + $routeName = $this->urlGeneratingRoutesMap::getRouteNameFromNode($node->getArgs()[0]->value, $scope); + if ($routeName === null) { + return []; + } + + if ($this->urlGeneratingRoutesMap->hasRouteName($routeName) === false) { + return [sprintf('Route with name "%s" does not exist.', $routeName)]; + } + + return []; + } + +} diff --git a/src/Symfony/Configuration.php b/src/Symfony/Configuration.php index 4c1f1a31..6cad7b6d 100644 --- a/src/Symfony/Configuration.php +++ b/src/Symfony/Configuration.php @@ -31,4 +31,9 @@ public function getConsoleApplicationLoader(): ?string return $this->parameters['consoleApplicationLoader'] ?? $this->parameters['console_application_loader'] ?? null; } + public function getUrlGeneratingRoutesFile(): ?string + { + return $this->parameters['urlGeneratingRulesFile'] ?? $this->parameters['url_generating_rules_file'] ?? null; + } + } diff --git a/src/Symfony/DefaultUrlGeneratingRoutesMap.php b/src/Symfony/DefaultUrlGeneratingRoutesMap.php new file mode 100644 index 00000000..9adf750b --- /dev/null +++ b/src/Symfony/DefaultUrlGeneratingRoutesMap.php @@ -0,0 +1,41 @@ +routes = $routes; + } + + public function hasRouteName(string $name): bool + { + foreach ($this->routes as $route) { + if ($route->getName() === $name) { + return true; + } + } + + return false; + } + + public static function getRouteNameFromNode(Expr $node, Scope $scope): ?string + { + $strings = TypeUtils::getConstantStrings($scope->getType($node)); + return count($strings) === 1 ? $strings[0]->getValue() : null; + } + +} diff --git a/src/Symfony/FakeUrlGeneratingRoutesMap.php b/src/Symfony/FakeUrlGeneratingRoutesMap.php new file mode 100644 index 00000000..56c2971b --- /dev/null +++ b/src/Symfony/FakeUrlGeneratingRoutesMap.php @@ -0,0 +1,21 @@ +urlGeneratingRoutesFile = $configuration->getUrlGeneratingRoutesFile(); + } + + public function create(): UrlGeneratingRoutesMap + { + if ($this->urlGeneratingRoutesFile === null) { + return new FakeUrlGeneratingRoutesMap(); + } + + if (file_exists($this->urlGeneratingRoutesFile) === false) { + throw new UrlGeneratingRoutesFileNotExistsException(sprintf('File %s containing route generator information does not exist.', $this->urlGeneratingRoutesFile)); + } + + $urlGeneratingRoutes = require $this->urlGeneratingRoutesFile; + + if (!is_array($urlGeneratingRoutes)) { + throw new UrlGeneratingRoutesFileNotExistsException(sprintf('File %s containing route generator information cannot be parsed.', $this->urlGeneratingRoutesFile)); + } + + /** @var \PHPStan\Symfony\UrlGeneratingRoutesDefinition[] $routes */ + $routes = []; + foreach ($urlGeneratingRoutes as $routeName => $routeConfiguration) { + if (!is_string($routeName)) { + continue; + } + + if (!is_array($routeConfiguration) || !isset($routeConfiguration[1]['_controller'])) { + continue; + } + + $routes[] = new UrlGeneratingRoute($routeName, $routeConfiguration[1]['_controller']); + } + + return new DefaultUrlGeneratingRoutesMap($routes); + } + +} diff --git a/src/Symfony/UrlGeneratingRoute.php b/src/Symfony/UrlGeneratingRoute.php new file mode 100644 index 00000000..3666a5bc --- /dev/null +++ b/src/Symfony/UrlGeneratingRoute.php @@ -0,0 +1,33 @@ +name = $name; + $this->controller = $controller; + } + + public function getName(): string + { + return $this->name; + } + + public function getController(): ?string + { + return $this->controller; + } + +} diff --git a/src/Symfony/UrlGeneratingRoutesDefinition.php b/src/Symfony/UrlGeneratingRoutesDefinition.php new file mode 100644 index 00000000..25c5b73e --- /dev/null +++ b/src/Symfony/UrlGeneratingRoutesDefinition.php @@ -0,0 +1,12 @@ +generateUrl('someRoute1'); + } + + public function generateNonExistingRoute(): void + { + $this->generateUrl('unknown'); + } + +} diff --git a/tests/Rules/Symfony/UrlGeneratorInterfaceUnknownRouteRuleTest.php b/tests/Rules/Symfony/UrlGeneratorInterfaceUnknownRouteRuleTest.php new file mode 100644 index 00000000..d28cb70f --- /dev/null +++ b/tests/Rules/Symfony/UrlGeneratorInterfaceUnknownRouteRuleTest.php @@ -0,0 +1,47 @@ + + */ +final class UrlGeneratorInterfaceUnknownRouteRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new UrlGeneratorInterfaceUnknownRouteRule((new PhpUrlGeneratingRoutesMapFactory(new Configuration(['urlGeneratingRulesFile' => __DIR__ . '/url_generating_routes.php'])))->create()); + } + + public function testGenerate(): void + { + if (!class_exists('Symfony\Bundle\FrameworkBundle\Controller\AbstractController')) { + self::markTestSkipped(); + } + + $this->analyse( + [ + __DIR__ . '/ExampleControllerWithRouting.php', + ], + [ + [ + 'Route with name "unknown" does not exist.', + 17, + ], + ] + ); + } + + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/../../../extension.neon', + ]; + } + +} diff --git a/tests/Rules/Symfony/url_generating_routes.php b/tests/Rules/Symfony/url_generating_routes.php new file mode 100644 index 00000000..64c1dd72 --- /dev/null +++ b/tests/Rules/Symfony/url_generating_routes.php @@ -0,0 +1,7 @@ + [[], ['_controller' => 'SomeController'] /* ... */], + 'someRoute2' => [[], ['_controller' => 'SomeController'] /* ... */], + 'someRoute3' => [[], ['_controller' => 'SomeController'] /* ... */], +]; diff --git a/tests/Symfony/DefaultUrlGeneratingRoutesMapTest.php b/tests/Symfony/DefaultUrlGeneratingRoutesMapTest.php new file mode 100644 index 00000000..e0062c47 --- /dev/null +++ b/tests/Symfony/DefaultUrlGeneratingRoutesMapTest.php @@ -0,0 +1,57 @@ + __DIR__ . '/url_generating_routes.php'])); + $validator($factory->create()->hasRouteName($name)); + } + + /** + * @return \Iterator + */ + public function hasRouteNameProvider(): Iterator + { + yield [ + 'unknown', + function (bool $hasRoute): void { + self::assertFalse($hasRoute); + }, + ]; + yield [ + 'some.non.existing.route', + function (bool $hasRoute): void { + self::assertFalse($hasRoute); + }, + ]; + yield [ + 'someRoute1', + function (bool $hasRoute): void { + self::assertTrue($hasRoute); + }, + ]; + yield [ + 'someRoute2', + function (bool $hasRoute): void { + self::assertTrue($hasRoute); + }, + ]; + yield [ + 'someRoute3', + function (bool $hasRoute): void { + self::assertTrue($hasRoute); + }, + ]; + } + +} diff --git a/tests/Symfony/url_generating_routes.php b/tests/Symfony/url_generating_routes.php new file mode 100644 index 00000000..64c1dd72 --- /dev/null +++ b/tests/Symfony/url_generating_routes.php @@ -0,0 +1,7 @@ + [[], ['_controller' => 'SomeController'] /* ... */], + 'someRoute2' => [[], ['_controller' => 'SomeController'] /* ... */], + 'someRoute3' => [[], ['_controller' => 'SomeController'] /* ... */], +];