Skip to content

Commit

Permalink
Add Twig template exists rule
Browse files Browse the repository at this point in the history
  • Loading branch information
zacharylund committed Aug 21, 2024
1 parent 14eec8c commit d0a7ea1
Show file tree
Hide file tree
Showing 8 changed files with 358 additions and 0 deletions.
9 changes: 9 additions & 0 deletions extension.neon
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ parameters:
constantHassers: true
console_application_loader: null
consoleApplicationLoader: null
twigTemplateDirectories: []
featureToggles:
skipCheckGenericClasses:
- Symfony\Component\Form\AbstractType
Expand Down Expand Up @@ -115,6 +116,7 @@ parametersSchema:
constantHassers: bool()
console_application_loader: schema(string(), nullable())
consoleApplicationLoader: schema(string(), nullable())
twigTemplateDirectories: listOf(string())
])

services:
Expand Down Expand Up @@ -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
122 changes: 122 additions & 0 deletions src/Rules/Symfony/TwigTemplateExistsRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
<?php declare(strict_types = 1);

namespace PHPStan\Rules\Symfony;

use PhpParser\Node;
use PhpParser\Node\Arg;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Expr\Variable;
use PhpParser\Node\Identifier;
use PhpParser\Node\Scalar\String_;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Type\ObjectType;
use function count;
use function file_exists;
use function in_array;
use function is_string;
use function sprintf;

/**
* @implements Rule<MethodCall>
*/
final class TwigTemplateExistsRule implements Rule
{

/** @var list<string> */
private $twigTemplateDirectories;

/** @param list<string> $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;
}

}
71 changes: 71 additions & 0 deletions tests/Rules/Symfony/ExampleTwigController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?php declare(strict_types = 1);

namespace PHPStan\Rules\Symfony;

use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Twig\Environment;
use function rand;

final class ExampleTwigController extends AbstractController
{

public function foo(): void
{
$this->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';
}

}
37 changes: 37 additions & 0 deletions tests/Rules/Symfony/TwigTemplateExistsRuleMoreTemplatesTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php declare(strict_types = 1);

namespace PHPStan\Rules\Symfony;

use PHPStan\Rules\Rule;
use PHPStan\Testing\RuleTestCase;

/**
* @extends RuleTestCase<TwigTemplateExistsRule>
*/
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.',
60,
],
]
);
}

}
29 changes: 29 additions & 0 deletions tests/Rules/Symfony/TwigTemplateExistsRuleNoTemplatesTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php declare(strict_types = 1);

namespace PHPStan\Rules\Symfony;

use PHPStan\Rules\Rule;
use PHPStan\Testing\RuleTestCase;

/**
* @extends RuleTestCase<TwigTemplateExistsRule>
*/
final class TwigTemplateExistsRuleNoTemplatesTest extends RuleTestCase
{

protected function getRule(): Rule
{
return new TwigTemplateExistsRule([]);
}

public function testGetArgument(): void
{
$this->analyse(
[
__DIR__ . '/ExampleTwigController.php',
],
[]
);
}

}
90 changes: 90 additions & 0 deletions tests/Rules/Symfony/TwigTemplateExistsRuleTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?php declare(strict_types = 1);

namespace PHPStan\Rules\Symfony;

use PHPStan\Rules\Rule;
use PHPStan\Testing\RuleTestCase;

/**
* @extends RuleTestCase<TwigTemplateExistsRule>
*/
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.',
21,
],
[
'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.',
34,
],
[
'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.',
43,
],
[
'Twig template "bar.html.twig" does not exist.',
44,
],
[
'Twig template "bar.html.twig" does not exist.',
52,
],
[
'Twig template "bar.html.twig" does not exist.',
56,
],
[
'Twig template "bar.html.twig" does not exist.',
60,
],
[
'Twig template "baz.html.twig" does not exist.',
60,
],
]
);
}

}
Empty file.
Empty file.

0 comments on commit d0a7ea1

Please sign in to comment.