diff --git a/composer.json b/composer.json index f132544d..44e9701c 100644 --- a/composer.json +++ b/composer.json @@ -36,7 +36,8 @@ "symfony/messenger": "^5.4", "symfony/polyfill-php80": "^1.24", "symfony/serializer": "^5.4", - "symfony/service-contracts": "^2.2.0" + "symfony/service-contracts": "^2.2.0", + "twig/twig": "^3.0" }, "config": { "sort-packages": true diff --git a/extension.neon b/extension.neon index 512f9908..567ec50d 100644 --- a/extension.neon +++ b/extension.neon @@ -11,6 +11,7 @@ parameters: constantHassers: true console_application_loader: null consoleApplicationLoader: null + twigEnvironmentLoader: null featureToggles: skipCheckGenericClasses: - Symfony\Component\Form\AbstractType @@ -115,6 +116,7 @@ parametersSchema: constantHassers: bool() console_application_loader: schema(string(), nullable()) consoleApplicationLoader: schema(string(), nullable()) + twigEnvironmentLoader: schema(string(), nullable()) ]) services: @@ -365,3 +367,8 @@ services: - factory: PHPStan\Type\Symfony\ExtensionGetConfigurationReturnTypeExtension tags: [phpstan.broker.dynamicMethodReturnTypeExtension] + + - + class: PHPStan\Symfony\TwigEnvironmentResolver + arguments: + twigEnvironmentLoader: %symfony.twigEnvironmentLoader% diff --git a/rules.neon b/rules.neon index cedcea7a..baaa6e1f 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\TwigTemplateExistsRule diff --git a/src/Rules/Symfony/TwigTemplateExistsRule.php b/src/Rules/Symfony/TwigTemplateExistsRule.php new file mode 100644 index 00000000..e40cc184 --- /dev/null +++ b/src/Rules/Symfony/TwigTemplateExistsRule.php @@ -0,0 +1,104 @@ + + */ +final class TwigTemplateExistsRule implements Rule +{ + + /** @var TwigEnvironmentResolver */ + private $twigEnvironmentResolver; + + public function __construct(TwigEnvironmentResolver $twigEnvironmentResolver) + { + $this->twigEnvironmentResolver = $twigEnvironmentResolver; + } + + public function getNodeType(): string + { + return MethodCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $templateArg = $this->getTwigTemplateArg($node, $scope); + + if ($templateArg === null) { + return []; + } + + $templateNames = []; + + if ($templateArg->value instanceof Variable && is_string($templateArg->value->name)) { + $varType = $scope->getVariableType($templateArg->value->name); + + foreach ($varType->getConstantStrings() as $constantString) { + $templateNames[] = $constantString->getValue(); + } + } elseif ($templateArg->value instanceof String_) { + $templateNames[] = $templateArg->value->value; + } + + if (count($templateNames) === 0) { + return []; + } + + $errors = []; + + foreach ($templateNames as $templateName) { + if ($this->twigEnvironmentResolver->templateExists($templateName)) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf( + 'Twig template "%s" does not exist.', + $templateName + ))->line($templateArg->getStartLine())->identifier('twig.templateNotFound')->build(); + } + + return $errors; + } + + private function getTwigTemplateArg(MethodCall $node, Scope $scope): ?Arg + { + if (!$node->name instanceof Identifier) { + return null; + } + + $argType = $scope->getType($node->var); + $methodName = $node->name->name; + + if ((new ObjectType('Symfony\Bundle\FrameworkBundle\Controller\AbstractController'))->isSuperTypeOf($argType)->yes() && in_array($methodName, ['render', 'renderView', 'renderBlockView', 'renderBlock', 'renderForm', 'stream'], true)) { + return $node->getArgs()[0] ?? null; + } + + if ((new ObjectType('Twig\Environment'))->isSuperTypeOf($argType)->yes() && in_array($methodName, ['render', 'display', 'load'], true)) { + return $node->getArgs()[0] ?? null; + } + + if ((new ObjectType('Symfony\Bridge\Twig\Mime\TemplatedEmail'))->isSuperTypeOf($argType)->yes() && in_array($methodName, ['htmlTemplate', 'textTemplate'], true)) { + return $node->getArgs()[0] ?? null; + } + + return null; + } + +} diff --git a/src/Symfony/TwigEnvironmentResolver.php b/src/Symfony/TwigEnvironmentResolver.php new file mode 100644 index 00000000..9ffe0245 --- /dev/null +++ b/src/Symfony/TwigEnvironmentResolver.php @@ -0,0 +1,55 @@ +twigEnvironmentLoader = $twigEnvironmentLoader; + } + + private function getTwigEnvironment(): ?Environment + { + if ($this->twigEnvironmentLoader === null) { + return null; + } + + if ($this->twigEnvironment !== null) { + return $this->twigEnvironment; + } + + if (!file_exists($this->twigEnvironmentLoader) + || !is_readable($this->twigEnvironmentLoader) + ) { + throw new ShouldNotHappenException(sprintf('Cannot load Twig environment. Check the parameters.symfony.twigEnvironmentLoader setting in PHPStan\'s config. The offending value is "%s".', $this->twigEnvironmentLoader)); + } + + return $this->twigEnvironment = require $this->twigEnvironmentLoader; + } + + public function templateExists(string $name): bool + { + $twigEnvironment = $this->getTwigEnvironment(); + + if ($twigEnvironment === null) { + return true; + } + + return $twigEnvironment->getLoader()->exists($name); + } + +} diff --git a/tests/Rules/Symfony/ExampleTwigController.php b/tests/Rules/Symfony/ExampleTwigController.php new file mode 100644 index 00000000..c434d180 --- /dev/null +++ b/tests/Rules/Symfony/ExampleTwigController.php @@ -0,0 +1,71 @@ +render('foo.html.twig'); + $this->renderBlock('foo.html.twig'); + $this->renderBlockView('foo.html.twig'); + $this->renderForm('foo.html.twig'); + $this->renderView('foo.html.twig'); + $this->stream('foo.html.twig'); + + $this->render('bar.html.twig'); + $this->renderBlock('bar.html.twig'); + $this->renderBlockView('bar.html.twig'); + $this->renderForm('bar.html.twig'); + $this->renderView('bar.html.twig'); + $this->stream('bar.html.twig'); + + $twig = new Environment(); + + $twig->render('foo.html.twig'); + $twig->display('foo.html.twig'); + $twig->load('foo.html.twig'); + + $twig->render('bar.html.twig'); + $twig->display('bar.html.twig'); + $twig->load('bar.html.twig'); + + $templatedEmail = new TemplatedEmail(); + + $templatedEmail->htmlTemplate('foo.html.twig'); + $templatedEmail->textTemplate('foo.html.twig'); + + $templatedEmail->textTemplate('bar.html.twig'); + $templatedEmail->textTemplate('bar.html.twig'); + + $name = 'foo.html.twig'; + + $this->render($name); + + $name = 'bar.html.twig'; + + $this->render($name); + + $name = rand(0, 1) ? 'foo.html.twig' : 'bar.html.twig'; + + $this->render($name); + + $name = rand(0, 1) ? 'bar.html.twig' : 'baz.html.twig'; + + $this->render($name); + + $this->render($this->getName()); + } + + private function getName(): string + { + return 'baz.html.twig'; + } + +} diff --git a/tests/Rules/Symfony/TwigTemplateExistsRuleNoTemplatesTest.php b/tests/Rules/Symfony/TwigTemplateExistsRuleNoTemplatesTest.php new file mode 100644 index 00000000..7c65fe8b --- /dev/null +++ b/tests/Rules/Symfony/TwigTemplateExistsRuleNoTemplatesTest.php @@ -0,0 +1,30 @@ + + */ +final class TwigTemplateExistsRuleNoTemplatesTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new TwigTemplateExistsRule(new TwigEnvironmentResolver(null)); + } + + public function testGetArgument(): void + { + $this->analyse( + [ + __DIR__ . '/ExampleTwigController.php', + ], + [] + ); + } + +} diff --git a/tests/Rules/Symfony/TwigTemplateExistsRuleTest.php b/tests/Rules/Symfony/TwigTemplateExistsRuleTest.php new file mode 100644 index 00000000..c3f96195 --- /dev/null +++ b/tests/Rules/Symfony/TwigTemplateExistsRuleTest.php @@ -0,0 +1,91 @@ + + */ +final class TwigTemplateExistsRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new TwigTemplateExistsRule(new TwigEnvironmentResolver(__DIR__ . '/twig_environment_loader.php')); + } + + public function testGetArgument(): void + { + $this->analyse( + [ + __DIR__ . '/ExampleTwigController.php', + ], + [ + [ + 'Twig template "bar.html.twig" does not exist.', + 22, + ], + [ + 'Twig template "bar.html.twig" does not exist.', + 23, + ], + [ + 'Twig template "bar.html.twig" does not exist.', + 24, + ], + [ + 'Twig template "bar.html.twig" does not exist.', + 25, + ], + [ + 'Twig template "bar.html.twig" does not exist.', + 26, + ], + [ + 'Twig template "bar.html.twig" does not exist.', + 27, + ], + [ + 'Twig template "bar.html.twig" does not exist.', + 35, + ], + [ + 'Twig template "bar.html.twig" does not exist.', + 36, + ], + [ + 'Twig template "bar.html.twig" does not exist.', + 37, + ], + [ + 'Twig template "bar.html.twig" does not exist.', + 44, + ], + [ + 'Twig template "bar.html.twig" does not exist.', + 45, + ], + [ + 'Twig template "bar.html.twig" does not exist.', + 53, + ], + [ + 'Twig template "bar.html.twig" does not exist.', + 57, + ], + [ + 'Twig template "bar.html.twig" does not exist.', + 61, + ], + [ + 'Twig template "baz.html.twig" does not exist.', + 61, + ], + ] + ); + } + +} diff --git a/tests/Rules/Symfony/twig_environment_loader.php b/tests/Rules/Symfony/twig_environment_loader.php new file mode 100644 index 00000000..822a8e8a --- /dev/null +++ b/tests/Rules/Symfony/twig_environment_loader.php @@ -0,0 +1,10 @@ + 'foo']); + +return new Environment($loader);