Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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: arrayOf(schema(string(), nullable()))
])

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
134 changes: 134 additions & 0 deletions src/Rules/Symfony/TwigTemplateExistsRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
<?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 preg_match;
use function sprintf;

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

/** @var array<string, string|null> */
private $twigTemplateDirectories;

/** @param array<string, string|null> $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;
}
Comment on lines +50 to +58
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you can avoid the if/else and write

Suggested change
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;
}
$argType = $scope->getType($templateArg->value);
foreach ($varType->getConstantStrings() as $constantString) {
$templateNames[] = $constantString->getValue();
}


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
{
if (preg_match('#^@(.+)\/(.+)$#', $templateName, $matches) === 1) {
$templateNamespace = $matches[1];
$templateName = $matches[2];
} else {
$templateNamespace = null;
}

foreach ($this->twigTemplateDirectories as $twigTemplateDirectory => $namespace) {
if ($namespace !== $templateNamespace) {
continue;
}

$templatePath = $twigTemplateDirectory . '/' . $templateName;

if (file_exists($templatePath)) {
return true;
}
}

return false;
}

}
75 changes: 75 additions & 0 deletions tests/Rules/Symfony/ExampleTwigController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?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());

$this->render('@admin/backend.html.twig');
$this->render('@admin/foo.html.twig');
$this->render('backend.html.twig');
}

private function getName(): string
{
return 'baz.html.twig';
}

}
46 changes: 46 additions & 0 deletions tests/Rules/Symfony/TwigTemplateExistsRuleMoreTemplatesTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?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__ . '/twig/templates' => null,
__DIR__ . '/twig/admin' => 'admin',
__DIR__ . '/twig/user' => null,
]);
}

public function testGetArgument(): void
{
$this->analyse(
[
__DIR__ . '/ExampleTwigController.php',
],
[
[
'Twig template "baz.html.twig" does not exist.',
61,
],
[
'Twig template "@admin/foo.html.twig" does not exist.',
66,
],
[
'Twig template "backend.html.twig" does not exist.',
67,
],
]
);
}

}
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',
],
[]
);
}

}
Loading