From 396dc78c5dda9c6d362477d0c9f392a68a43d902 Mon Sep 17 00:00:00 2001 From: Zachary Lund Date: Mon, 12 Aug 2024 10:33:05 -0500 Subject: [PATCH] Add Twig template exists rule --- extension.neon | 9 ++ src/Rules/Symfony/TwigTemplateExistsRule.php | 122 ++++++++++++++++++ tests/Rules/Symfony/ExampleTwigController.php | 71 ++++++++++ ...wigTemplateExistsRuleMoreTemplatesTest.php | 37 ++++++ .../TwigTemplateExistsRuleNoTemplatesTest.php | 29 +++++ .../Symfony/TwigTemplateExistsRuleTest.php | 90 +++++++++++++ tests/Rules/Symfony/data/bar.html.twig | 0 tests/Rules/Symfony/templates/foo.html.twig | 0 8 files changed, 358 insertions(+) create mode 100644 src/Rules/Symfony/TwigTemplateExistsRule.php create mode 100644 tests/Rules/Symfony/ExampleTwigController.php create mode 100644 tests/Rules/Symfony/TwigTemplateExistsRuleMoreTemplatesTest.php create mode 100644 tests/Rules/Symfony/TwigTemplateExistsRuleNoTemplatesTest.php create mode 100644 tests/Rules/Symfony/TwigTemplateExistsRuleTest.php create mode 100644 tests/Rules/Symfony/data/bar.html.twig create mode 100644 tests/Rules/Symfony/templates/foo.html.twig diff --git a/extension.neon b/extension.neon index 512f9908..629bd28a 100644 --- a/extension.neon +++ b/extension.neon @@ -11,6 +11,7 @@ parameters: constantHassers: true console_application_loader: null consoleApplicationLoader: null + twigTemplateDirectories: [] featureToggles: skipCheckGenericClasses: - Symfony\Component\Form\AbstractType @@ -115,6 +116,7 @@ parametersSchema: constantHassers: bool() console_application_loader: schema(string(), nullable()) consoleApplicationLoader: schema(string(), nullable()) + twigTemplateDirectories: listOf(string()) ]) services: @@ -365,3 +367,10 @@ services: - factory: PHPStan\Type\Symfony\ExtensionGetConfigurationReturnTypeExtension tags: [phpstan.broker.dynamicMethodReturnTypeExtension] + + - + class: PHPStan\Rules\Symfony\TwigTemplateExistsRule + arguments: + twigTemplateDirectories: %symfony.twigTemplateDirectories% + tags: + - phpstan.rules.rule diff --git a/src/Rules/Symfony/TwigTemplateExistsRule.php b/src/Rules/Symfony/TwigTemplateExistsRule.php new file mode 100644 index 00000000..64a1c0d6 --- /dev/null +++ b/src/Rules/Symfony/TwigTemplateExistsRule.php @@ -0,0 +1,122 @@ + + */ +final class TwigTemplateExistsRule implements Rule +{ + + /** @var list */ + private $twigTemplateDirectories; + + /** @param list $twigTemplateDirectories */ + public function __construct(array $twigTemplateDirectories) + { + $this->twigTemplateDirectories = $twigTemplateDirectories; + } + + public function getNodeType(): string + { + return MethodCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (count($this->twigTemplateDirectories) === 0) { + return []; + } + + $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->twigTemplateExists($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; + } + + private function twigTemplateExists(string $templateName): bool + { + foreach ($this->twigTemplateDirectories as $twigTemplateDirectory) { + $templatePath = $twigTemplateDirectory . '/' . $templateName; + + if (file_exists($templatePath)) { + return true; + } + } + + return false; + } + +} 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/TwigTemplateExistsRuleMoreTemplatesTest.php b/tests/Rules/Symfony/TwigTemplateExistsRuleMoreTemplatesTest.php new file mode 100644 index 00000000..e43e0199 --- /dev/null +++ b/tests/Rules/Symfony/TwigTemplateExistsRuleMoreTemplatesTest.php @@ -0,0 +1,37 @@ + + */ +final class TwigTemplateExistsRuleMoreTemplatesTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new TwigTemplateExistsRule([ + __DIR__ . '/data', + __DIR__ . '/templates', + ]); + } + + public function testGetArgument(): void + { + $this->analyse( + [ + __DIR__ . '/ExampleTwigController.php', + ], + [ + [ + 'Twig template "baz.html.twig" does not exist.', + 61, + ], + ] + ); + } + +} diff --git a/tests/Rules/Symfony/TwigTemplateExistsRuleNoTemplatesTest.php b/tests/Rules/Symfony/TwigTemplateExistsRuleNoTemplatesTest.php new file mode 100644 index 00000000..8d9e685b --- /dev/null +++ b/tests/Rules/Symfony/TwigTemplateExistsRuleNoTemplatesTest.php @@ -0,0 +1,29 @@ + + */ +final class TwigTemplateExistsRuleNoTemplatesTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new TwigTemplateExistsRule([]); + } + + 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..927026ce --- /dev/null +++ b/tests/Rules/Symfony/TwigTemplateExistsRuleTest.php @@ -0,0 +1,90 @@ + + */ +final class TwigTemplateExistsRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new TwigTemplateExistsRule([__DIR__ . '/templates']); + } + + 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/data/bar.html.twig b/tests/Rules/Symfony/data/bar.html.twig new file mode 100644 index 00000000..e69de29b diff --git a/tests/Rules/Symfony/templates/foo.html.twig b/tests/Rules/Symfony/templates/foo.html.twig new file mode 100644 index 00000000..e69de29b