Skip to content

Commit d0a7ea1

Browse files
committed
Add Twig template exists rule
1 parent 14eec8c commit d0a7ea1

8 files changed

+358
-0
lines changed

extension.neon

+9
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ parameters:
1111
constantHassers: true
1212
console_application_loader: null
1313
consoleApplicationLoader: null
14+
twigTemplateDirectories: []
1415
featureToggles:
1516
skipCheckGenericClasses:
1617
- Symfony\Component\Form\AbstractType
@@ -115,6 +116,7 @@ parametersSchema:
115116
constantHassers: bool()
116117
console_application_loader: schema(string(), nullable())
117118
consoleApplicationLoader: schema(string(), nullable())
119+
twigTemplateDirectories: listOf(string())
118120
])
119121

120122
services:
@@ -365,3 +367,10 @@ services:
365367
-
366368
factory: PHPStan\Type\Symfony\ExtensionGetConfigurationReturnTypeExtension
367369
tags: [phpstan.broker.dynamicMethodReturnTypeExtension]
370+
371+
-
372+
class: PHPStan\Rules\Symfony\TwigTemplateExistsRule
373+
arguments:
374+
twigTemplateDirectories: %symfony.twigTemplateDirectories%
375+
tags:
376+
- phpstan.rules.rule
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\Symfony;
4+
5+
use PhpParser\Node;
6+
use PhpParser\Node\Arg;
7+
use PhpParser\Node\Expr\MethodCall;
8+
use PhpParser\Node\Expr\Variable;
9+
use PhpParser\Node\Identifier;
10+
use PhpParser\Node\Scalar\String_;
11+
use PHPStan\Analyser\Scope;
12+
use PHPStan\Rules\Rule;
13+
use PHPStan\Rules\RuleErrorBuilder;
14+
use PHPStan\Type\ObjectType;
15+
use function count;
16+
use function file_exists;
17+
use function in_array;
18+
use function is_string;
19+
use function sprintf;
20+
21+
/**
22+
* @implements Rule<MethodCall>
23+
*/
24+
final class TwigTemplateExistsRule implements Rule
25+
{
26+
27+
/** @var list<string> */
28+
private $twigTemplateDirectories;
29+
30+
/** @param list<string> $twigTemplateDirectories */
31+
public function __construct(array $twigTemplateDirectories)
32+
{
33+
$this->twigTemplateDirectories = $twigTemplateDirectories;
34+
}
35+
36+
public function getNodeType(): string
37+
{
38+
return MethodCall::class;
39+
}
40+
41+
public function processNode(Node $node, Scope $scope): array
42+
{
43+
if (count($this->twigTemplateDirectories) === 0) {
44+
return [];
45+
}
46+
47+
$templateArg = $this->getTwigTemplateArg($node, $scope);
48+
49+
if ($templateArg === null) {
50+
return [];
51+
}
52+
53+
$templateNames = [];
54+
55+
if ($templateArg->value instanceof Variable && is_string($templateArg->value->name)) {
56+
$varType = $scope->getVariableType($templateArg->value->name);
57+
58+
foreach ($varType->getConstantStrings() as $constantString) {
59+
$templateNames[] = $constantString->getValue();
60+
}
61+
} elseif ($templateArg->value instanceof String_) {
62+
$templateNames[] = $templateArg->value->value;
63+
}
64+
65+
if (count($templateNames) === 0) {
66+
return [];
67+
}
68+
69+
$errors = [];
70+
71+
foreach ($templateNames as $templateName) {
72+
if ($this->twigTemplateExists($templateName)) {
73+
continue;
74+
}
75+
76+
$errors[] = RuleErrorBuilder::message(sprintf(
77+
'Twig template "%s" does not exist.',
78+
$templateName
79+
))->line($templateArg->getStartLine())->identifier('twig.templateNotFound')->build();
80+
}
81+
82+
return $errors;
83+
}
84+
85+
private function getTwigTemplateArg(MethodCall $node, Scope $scope): ?Arg
86+
{
87+
if (!$node->name instanceof Identifier) {
88+
return null;
89+
}
90+
91+
$argType = $scope->getType($node->var);
92+
$methodName = $node->name->name;
93+
94+
if ((new ObjectType('Symfony\Bundle\FrameworkBundle\Controller\AbstractController'))->isSuperTypeOf($argType)->yes() && in_array($methodName, ['render', 'renderView', 'renderBlockView', 'renderBlock', 'renderForm', 'stream'], true)) {
95+
return $node->getArgs()[0] ?? null;
96+
}
97+
98+
if ((new ObjectType('Twig\Environment'))->isSuperTypeOf($argType)->yes() && in_array($methodName, ['render', 'display', 'load'], true)) {
99+
return $node->getArgs()[0] ?? null;
100+
}
101+
102+
if ((new ObjectType('Symfony\Bridge\Twig\Mime\TemplatedEmail'))->isSuperTypeOf($argType)->yes() && in_array($methodName, ['htmlTemplate', 'textTemplate'], true)) {
103+
return $node->getArgs()[0] ?? null;
104+
}
105+
106+
return null;
107+
}
108+
109+
private function twigTemplateExists(string $templateName): bool
110+
{
111+
foreach ($this->twigTemplateDirectories as $twigTemplateDirectory) {
112+
$templatePath = $twigTemplateDirectory . '/' . $templateName;
113+
114+
if (file_exists($templatePath)) {
115+
return true;
116+
}
117+
}
118+
119+
return false;
120+
}
121+
122+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\Symfony;
4+
5+
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
6+
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
7+
use Twig\Environment;
8+
use function rand;
9+
10+
final class ExampleTwigController extends AbstractController
11+
{
12+
13+
public function foo(): void
14+
{
15+
$this->render('foo.html.twig');
16+
$this->renderBlock('foo.html.twig');
17+
$this->renderBlockView('foo.html.twig');
18+
$this->renderForm('foo.html.twig');
19+
$this->renderView('foo.html.twig');
20+
$this->stream('foo.html.twig');
21+
22+
$this->render('bar.html.twig');
23+
$this->renderBlock('bar.html.twig');
24+
$this->renderBlockView('bar.html.twig');
25+
$this->renderForm('bar.html.twig');
26+
$this->renderView('bar.html.twig');
27+
$this->stream('bar.html.twig');
28+
29+
$twig = new Environment();
30+
31+
$twig->render('foo.html.twig');
32+
$twig->display('foo.html.twig');
33+
$twig->load('foo.html.twig');
34+
35+
$twig->render('bar.html.twig');
36+
$twig->display('bar.html.twig');
37+
$twig->load('bar.html.twig');
38+
39+
$templatedEmail = new TemplatedEmail();
40+
41+
$templatedEmail->htmlTemplate('foo.html.twig');
42+
$templatedEmail->textTemplate('foo.html.twig');
43+
44+
$templatedEmail->textTemplate('bar.html.twig');
45+
$templatedEmail->textTemplate('bar.html.twig');
46+
47+
$name = 'foo.html.twig';
48+
49+
$this->render($name);
50+
51+
$name = 'bar.html.twig';
52+
53+
$this->render($name);
54+
55+
$name = rand(0, 1) ? 'foo.html.twig' : 'bar.html.twig';
56+
57+
$this->render($name);
58+
59+
$name = rand(0, 1) ? 'bar.html.twig' : 'baz.html.twig';
60+
61+
$this->render($name);
62+
63+
$this->render($this->getName());
64+
}
65+
66+
private function getName(): string
67+
{
68+
return 'baz.html.twig';
69+
}
70+
71+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\Symfony;
4+
5+
use PHPStan\Rules\Rule;
6+
use PHPStan\Testing\RuleTestCase;
7+
8+
/**
9+
* @extends RuleTestCase<TwigTemplateExistsRule>
10+
*/
11+
final class TwigTemplateExistsRuleMoreTemplatesTest extends RuleTestCase
12+
{
13+
14+
protected function getRule(): Rule
15+
{
16+
return new TwigTemplateExistsRule([
17+
__DIR__ . '/data',
18+
__DIR__ . '/templates',
19+
]);
20+
}
21+
22+
public function testGetArgument(): void
23+
{
24+
$this->analyse(
25+
[
26+
__DIR__ . '/ExampleTwigController.php',
27+
],
28+
[
29+
[
30+
'Twig template "baz.html.twig" does not exist.',
31+
60,
32+
],
33+
]
34+
);
35+
}
36+
37+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\Symfony;
4+
5+
use PHPStan\Rules\Rule;
6+
use PHPStan\Testing\RuleTestCase;
7+
8+
/**
9+
* @extends RuleTestCase<TwigTemplateExistsRule>
10+
*/
11+
final class TwigTemplateExistsRuleNoTemplatesTest extends RuleTestCase
12+
{
13+
14+
protected function getRule(): Rule
15+
{
16+
return new TwigTemplateExistsRule([]);
17+
}
18+
19+
public function testGetArgument(): void
20+
{
21+
$this->analyse(
22+
[
23+
__DIR__ . '/ExampleTwigController.php',
24+
],
25+
[]
26+
);
27+
}
28+
29+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\Symfony;
4+
5+
use PHPStan\Rules\Rule;
6+
use PHPStan\Testing\RuleTestCase;
7+
8+
/**
9+
* @extends RuleTestCase<TwigTemplateExistsRule>
10+
*/
11+
final class TwigTemplateExistsRuleTest extends RuleTestCase
12+
{
13+
14+
protected function getRule(): Rule
15+
{
16+
return new TwigTemplateExistsRule([__DIR__ . '/templates']);
17+
}
18+
19+
public function testGetArgument(): void
20+
{
21+
$this->analyse(
22+
[
23+
__DIR__ . '/ExampleTwigController.php',
24+
],
25+
[
26+
[
27+
'Twig template "bar.html.twig" does not exist.',
28+
21,
29+
],
30+
[
31+
'Twig template "bar.html.twig" does not exist.',
32+
22,
33+
],
34+
[
35+
'Twig template "bar.html.twig" does not exist.',
36+
23,
37+
],
38+
[
39+
'Twig template "bar.html.twig" does not exist.',
40+
24,
41+
],
42+
[
43+
'Twig template "bar.html.twig" does not exist.',
44+
25,
45+
],
46+
[
47+
'Twig template "bar.html.twig" does not exist.',
48+
26,
49+
],
50+
[
51+
'Twig template "bar.html.twig" does not exist.',
52+
34,
53+
],
54+
[
55+
'Twig template "bar.html.twig" does not exist.',
56+
35,
57+
],
58+
[
59+
'Twig template "bar.html.twig" does not exist.',
60+
36,
61+
],
62+
[
63+
'Twig template "bar.html.twig" does not exist.',
64+
43,
65+
],
66+
[
67+
'Twig template "bar.html.twig" does not exist.',
68+
44,
69+
],
70+
[
71+
'Twig template "bar.html.twig" does not exist.',
72+
52,
73+
],
74+
[
75+
'Twig template "bar.html.twig" does not exist.',
76+
56,
77+
],
78+
[
79+
'Twig template "bar.html.twig" does not exist.',
80+
60,
81+
],
82+
[
83+
'Twig template "baz.html.twig" does not exist.',
84+
60,
85+
],
86+
]
87+
);
88+
}
89+
90+
}

tests/Rules/Symfony/data/bar.html.twig

Whitespace-only changes.

tests/Rules/Symfony/templates/foo.html.twig

Whitespace-only changes.

0 commit comments

Comments
 (0)