From 396dc78c5dda9c6d362477d0c9f392a68a43d902 Mon Sep 17 00:00:00 2001
From: Zachary Lund <git@lundscape.com>
Date: Mon, 12 Aug 2024 10:33:05 -0500
Subject: [PATCH 1/3] 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 @@
+<?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;
+	}
+
+}
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 @@
+<?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';
+	}
+
+}
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 @@
+<?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.',
+					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 @@
+<?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',
+			],
+			[]
+		);
+	}
+
+}
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 @@
+<?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.',
+					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

From d366e518512e09ecca2c4081fb0eeb34e7aa0800 Mon Sep 17 00:00:00 2001
From: Zachary Lund <git@lundscape.com>
Date: Wed, 21 Aug 2024 09:25:55 -0500
Subject: [PATCH 2/3] Handle Twig namespaces

---
 extension.neon                                 |  2 +-
 src/Rules/Symfony/TwigTemplateExistsRule.php   | 18 +++++++++++++++---
 tests/Rules/Symfony/ExampleTwigController.php  |  4 ++++
 ...TwigTemplateExistsRuleMoreTemplatesTest.php | 13 +++++++++++--
 .../Symfony/TwigTemplateExistsRuleTest.php     | 13 ++++++++++++-
 .../admin/backend.html.twig}                   |  0
 .../Symfony/{ => twig}/templates/foo.html.twig |  0
 tests/Rules/Symfony/twig/user/bar.html.twig    |  0
 8 files changed, 43 insertions(+), 7 deletions(-)
 rename tests/Rules/Symfony/{data/bar.html.twig => twig/admin/backend.html.twig} (100%)
 rename tests/Rules/Symfony/{ => twig}/templates/foo.html.twig (100%)
 create mode 100644 tests/Rules/Symfony/twig/user/bar.html.twig

diff --git a/extension.neon b/extension.neon
index 629bd28a..fd2cddde 100644
--- a/extension.neon
+++ b/extension.neon
@@ -116,7 +116,7 @@ parametersSchema:
 		constantHassers: bool()
 		console_application_loader: schema(string(), nullable())
 		consoleApplicationLoader: schema(string(), nullable())
-		twigTemplateDirectories: listOf(string())
+		twigTemplateDirectories: arrayOf(schema(string(), nullable()))
 	])
 
 services:
diff --git a/src/Rules/Symfony/TwigTemplateExistsRule.php b/src/Rules/Symfony/TwigTemplateExistsRule.php
index 64a1c0d6..489a9a1c 100644
--- a/src/Rules/Symfony/TwigTemplateExistsRule.php
+++ b/src/Rules/Symfony/TwigTemplateExistsRule.php
@@ -16,6 +16,7 @@
 use function file_exists;
 use function in_array;
 use function is_string;
+use function preg_match;
 use function sprintf;
 
 /**
@@ -24,10 +25,10 @@
 final class TwigTemplateExistsRule implements Rule
 {
 
-	/** @var list<string> */
+	/** @var array<string, string|null> */
 	private $twigTemplateDirectories;
 
-	/** @param list<string> $twigTemplateDirectories */
+	/** @param array<string, string|null> $twigTemplateDirectories */
 	public function __construct(array $twigTemplateDirectories)
 	{
 		$this->twigTemplateDirectories = $twigTemplateDirectories;
@@ -108,7 +109,18 @@ private function getTwigTemplateArg(MethodCall $node, Scope $scope): ?Arg
 
 	private function twigTemplateExists(string $templateName): bool
 	{
-		foreach ($this->twigTemplateDirectories as $twigTemplateDirectory) {
+		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)) {
diff --git a/tests/Rules/Symfony/ExampleTwigController.php b/tests/Rules/Symfony/ExampleTwigController.php
index c434d180..cc362f5b 100644
--- a/tests/Rules/Symfony/ExampleTwigController.php
+++ b/tests/Rules/Symfony/ExampleTwigController.php
@@ -61,6 +61,10 @@ public function foo(): void
 		$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
diff --git a/tests/Rules/Symfony/TwigTemplateExistsRuleMoreTemplatesTest.php b/tests/Rules/Symfony/TwigTemplateExistsRuleMoreTemplatesTest.php
index e43e0199..fd5b6112 100644
--- a/tests/Rules/Symfony/TwigTemplateExistsRuleMoreTemplatesTest.php
+++ b/tests/Rules/Symfony/TwigTemplateExistsRuleMoreTemplatesTest.php
@@ -14,8 +14,9 @@ final class TwigTemplateExistsRuleMoreTemplatesTest extends RuleTestCase
 	protected function getRule(): Rule
 	{
 		return new TwigTemplateExistsRule([
-			__DIR__ . '/data',
-			__DIR__ . '/templates',
+			__DIR__ . '/twig/templates' => null,
+			__DIR__ . '/twig/admin' => 'admin',
+			__DIR__ . '/twig/user' => null,
 		]);
 	}
 
@@ -30,6 +31,14 @@ public function testGetArgument(): void
 					'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,
+				],
 			]
 		);
 	}
diff --git a/tests/Rules/Symfony/TwigTemplateExistsRuleTest.php b/tests/Rules/Symfony/TwigTemplateExistsRuleTest.php
index 927026ce..099079d1 100644
--- a/tests/Rules/Symfony/TwigTemplateExistsRuleTest.php
+++ b/tests/Rules/Symfony/TwigTemplateExistsRuleTest.php
@@ -13,7 +13,10 @@ final class TwigTemplateExistsRuleTest extends RuleTestCase
 
 	protected function getRule(): Rule
 	{
-		return new TwigTemplateExistsRule([__DIR__ . '/templates']);
+		return new TwigTemplateExistsRule([
+			__DIR__ . '/twig/templates' => null,
+			__DIR__ . '/twig/admin' => 'admin',
+		]);
 	}
 
 	public function testGetArgument(): void
@@ -83,6 +86,14 @@ public function testGetArgument(): void
 					'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,
+				],
 			]
 		);
 	}
diff --git a/tests/Rules/Symfony/data/bar.html.twig b/tests/Rules/Symfony/twig/admin/backend.html.twig
similarity index 100%
rename from tests/Rules/Symfony/data/bar.html.twig
rename to tests/Rules/Symfony/twig/admin/backend.html.twig
diff --git a/tests/Rules/Symfony/templates/foo.html.twig b/tests/Rules/Symfony/twig/templates/foo.html.twig
similarity index 100%
rename from tests/Rules/Symfony/templates/foo.html.twig
rename to tests/Rules/Symfony/twig/templates/foo.html.twig
diff --git a/tests/Rules/Symfony/twig/user/bar.html.twig b/tests/Rules/Symfony/twig/user/bar.html.twig
new file mode 100644
index 00000000..e69de29b

From 9b97b95daf155526e834dfb225fdeca3a1030f40 Mon Sep 17 00:00:00 2001
From: Zachary Lund <git@lundscape.com>
Date: Sun, 25 Aug 2024 15:05:32 -0500
Subject: [PATCH 3/3] Use Twig environment loader

---
 composer.json                                 |  3 +-
 extension.neon                                | 10 ++--
 rules.neon                                    |  1 +
 src/Rules/Symfony/TwigTemplateExistsRule.php  | 42 ++------------
 src/Symfony/TwigEnvironmentResolver.php       | 55 +++++++++++++++++++
 tests/Rules/Symfony/ExampleTwigController.php |  4 --
 ...wigTemplateExistsRuleMoreTemplatesTest.php | 46 ----------------
 .../TwigTemplateExistsRuleNoTemplatesTest.php |  3 +-
 .../Symfony/TwigTemplateExistsRuleTest.php    | 14 +----
 .../Symfony/twig/admin/backend.html.twig      |  0
 .../Symfony/twig/templates/foo.html.twig      |  0
 tests/Rules/Symfony/twig/user/bar.html.twig   |  0
 .../Rules/Symfony/twig_environment_loader.php | 10 ++++
 13 files changed, 82 insertions(+), 106 deletions(-)
 create mode 100644 src/Symfony/TwigEnvironmentResolver.php
 delete mode 100644 tests/Rules/Symfony/TwigTemplateExistsRuleMoreTemplatesTest.php
 delete mode 100644 tests/Rules/Symfony/twig/admin/backend.html.twig
 delete mode 100644 tests/Rules/Symfony/twig/templates/foo.html.twig
 delete mode 100644 tests/Rules/Symfony/twig/user/bar.html.twig
 create mode 100644 tests/Rules/Symfony/twig_environment_loader.php

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 fd2cddde..567ec50d 100644
--- a/extension.neon
+++ b/extension.neon
@@ -11,7 +11,7 @@ parameters:
 		constantHassers: true
 		console_application_loader: null
 		consoleApplicationLoader: null
-		twigTemplateDirectories: []
+		twigEnvironmentLoader: null
 	featureToggles:
 		skipCheckGenericClasses:
 			- Symfony\Component\Form\AbstractType
@@ -116,7 +116,7 @@ parametersSchema:
 		constantHassers: bool()
 		console_application_loader: schema(string(), nullable())
 		consoleApplicationLoader: schema(string(), nullable())
-		twigTemplateDirectories: arrayOf(schema(string(), nullable()))
+		twigEnvironmentLoader: schema(string(), nullable())
 	])
 
 services:
@@ -369,8 +369,6 @@ services:
 		tags: [phpstan.broker.dynamicMethodReturnTypeExtension]
 
 	-
-		class: PHPStan\Rules\Symfony\TwigTemplateExistsRule
+		class: PHPStan\Symfony\TwigEnvironmentResolver
 		arguments:
-			twigTemplateDirectories: %symfony.twigTemplateDirectories%
-		tags:
-			- phpstan.rules.rule
+			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
index 489a9a1c..e40cc184 100644
--- a/src/Rules/Symfony/TwigTemplateExistsRule.php
+++ b/src/Rules/Symfony/TwigTemplateExistsRule.php
@@ -11,12 +11,11 @@
 use PHPStan\Analyser\Scope;
 use PHPStan\Rules\Rule;
 use PHPStan\Rules\RuleErrorBuilder;
+use PHPStan\Symfony\TwigEnvironmentResolver;
 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;
 
 /**
@@ -25,13 +24,12 @@
 final class TwigTemplateExistsRule implements Rule
 {
 
-	/** @var array<string, string|null> */
-	private $twigTemplateDirectories;
+	/** @var TwigEnvironmentResolver */
+	private $twigEnvironmentResolver;
 
-	/** @param array<string, string|null> $twigTemplateDirectories */
-	public function __construct(array $twigTemplateDirectories)
+	public function __construct(TwigEnvironmentResolver $twigEnvironmentResolver)
 	{
-		$this->twigTemplateDirectories = $twigTemplateDirectories;
+		$this->twigEnvironmentResolver = $twigEnvironmentResolver;
 	}
 
 	public function getNodeType(): string
@@ -41,10 +39,6 @@ public function getNodeType(): string
 
 	public function processNode(Node $node, Scope $scope): array
 	{
-		if (count($this->twigTemplateDirectories) === 0) {
-			return [];
-		}
-
 		$templateArg = $this->getTwigTemplateArg($node, $scope);
 
 		if ($templateArg === null) {
@@ -70,7 +64,7 @@ public function processNode(Node $node, Scope $scope): array
 		$errors = [];
 
 		foreach ($templateNames as $templateName) {
-			if ($this->twigTemplateExists($templateName)) {
+			if ($this->twigEnvironmentResolver->templateExists($templateName)) {
 				continue;
 			}
 
@@ -107,28 +101,4 @@ private function getTwigTemplateArg(MethodCall $node, Scope $scope): ?Arg
 		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;
-	}
-
 }
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 @@
+<?php declare(strict_types = 1);
+
+namespace PHPStan\Symfony;
+
+use PHPStan\ShouldNotHappenException;
+use Twig\Environment;
+use function file_exists;
+use function is_readable;
+use function sprintf;
+
+final class TwigEnvironmentResolver
+{
+
+	/** @var string|null */
+	private $twigEnvironmentLoader;
+
+	/** @var Environment|null */
+	private $twigEnvironment;
+
+	public function __construct(?string $twigEnvironmentLoader)
+	{
+		$this->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
index cc362f5b..c434d180 100644
--- a/tests/Rules/Symfony/ExampleTwigController.php
+++ b/tests/Rules/Symfony/ExampleTwigController.php
@@ -61,10 +61,6 @@ public function foo(): void
 		$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
diff --git a/tests/Rules/Symfony/TwigTemplateExistsRuleMoreTemplatesTest.php b/tests/Rules/Symfony/TwigTemplateExistsRuleMoreTemplatesTest.php
deleted file mode 100644
index fd5b6112..00000000
--- a/tests/Rules/Symfony/TwigTemplateExistsRuleMoreTemplatesTest.php
+++ /dev/null
@@ -1,46 +0,0 @@
-<?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,
-				],
-			]
-		);
-	}
-
-}
diff --git a/tests/Rules/Symfony/TwigTemplateExistsRuleNoTemplatesTest.php b/tests/Rules/Symfony/TwigTemplateExistsRuleNoTemplatesTest.php
index 8d9e685b..7c65fe8b 100644
--- a/tests/Rules/Symfony/TwigTemplateExistsRuleNoTemplatesTest.php
+++ b/tests/Rules/Symfony/TwigTemplateExistsRuleNoTemplatesTest.php
@@ -3,6 +3,7 @@
 namespace PHPStan\Rules\Symfony;
 
 use PHPStan\Rules\Rule;
+use PHPStan\Symfony\TwigEnvironmentResolver;
 use PHPStan\Testing\RuleTestCase;
 
 /**
@@ -13,7 +14,7 @@ final class TwigTemplateExistsRuleNoTemplatesTest extends RuleTestCase
 
 	protected function getRule(): Rule
 	{
-		return new TwigTemplateExistsRule([]);
+		return new TwigTemplateExistsRule(new TwigEnvironmentResolver(null));
 	}
 
 	public function testGetArgument(): void
diff --git a/tests/Rules/Symfony/TwigTemplateExistsRuleTest.php b/tests/Rules/Symfony/TwigTemplateExistsRuleTest.php
index 099079d1..c3f96195 100644
--- a/tests/Rules/Symfony/TwigTemplateExistsRuleTest.php
+++ b/tests/Rules/Symfony/TwigTemplateExistsRuleTest.php
@@ -3,6 +3,7 @@
 namespace PHPStan\Rules\Symfony;
 
 use PHPStan\Rules\Rule;
+use PHPStan\Symfony\TwigEnvironmentResolver;
 use PHPStan\Testing\RuleTestCase;
 
 /**
@@ -13,10 +14,7 @@ final class TwigTemplateExistsRuleTest extends RuleTestCase
 
 	protected function getRule(): Rule
 	{
-		return new TwigTemplateExistsRule([
-			__DIR__ . '/twig/templates' => null,
-			__DIR__ . '/twig/admin' => 'admin',
-		]);
+		return new TwigTemplateExistsRule(new TwigEnvironmentResolver(__DIR__ . '/twig_environment_loader.php'));
 	}
 
 	public function testGetArgument(): void
@@ -86,14 +84,6 @@ public function testGetArgument(): void
 					'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,
-				],
 			]
 		);
 	}
diff --git a/tests/Rules/Symfony/twig/admin/backend.html.twig b/tests/Rules/Symfony/twig/admin/backend.html.twig
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/Rules/Symfony/twig/templates/foo.html.twig b/tests/Rules/Symfony/twig/templates/foo.html.twig
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/Rules/Symfony/twig/user/bar.html.twig b/tests/Rules/Symfony/twig/user/bar.html.twig
deleted file mode 100644
index e69de29b..00000000
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 @@
+<?php declare(strict_types = 1);
+
+use Twig\Environment;
+use Twig\Loader\ArrayLoader;
+
+require_once __DIR__ . '/../../../vendor/autoload.php';
+
+$loader = new ArrayLoader(['foo.html.twig' => 'foo']);
+
+return new Environment($loader);