From 5cb58af0cbd706e07b2916989003c81af647f2fa Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Wed, 3 Dec 2025 16:39:00 +0100 Subject: [PATCH 1/4] Twig: support #[Template] and ->render() --- composer.json | 1 + composer.lock | 343 +++++++++++++++++- rules.neon | 1 + src/Provider/TwigUsageProvider.php | 324 ++++++++++++++++- tests/Rule/DeadCodeRuleTest.php | 3 + .../data/providers/twig-template-outside.php | 10 + tests/Rule/data/providers/twig-template.php | 235 ++++++++++++ 7 files changed, 914 insertions(+), 3 deletions(-) create mode 100644 tests/Rule/data/providers/twig-template-outside.php create mode 100644 tests/Rule/data/providers/twig-template.php diff --git a/composer.json b/composer.json index 72282c69..5842f3d8 100644 --- a/composer.json +++ b/composer.json @@ -40,6 +40,7 @@ "symfony/dependency-injection": "^5.4 || ^6.0 || ^7.0 || ^8.0", "symfony/doctrine-bridge": "^5.4 || ^6.0 || ^7.0 || ^8.0", "symfony/event-dispatcher": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/framework-bundle": "^5.4 || ^6.0 || ^7.0 || ^8.0", "symfony/http-kernel": "^5.4 || ^6.0 || ^7.0 || ^8.0", "symfony/routing": "^5.4 || ^6.0 || ^7.0 || ^8.0", "symfony/validator": "^5.4 || ^6.0 || ^7.0 || ^8.0", diff --git a/composer.lock b/composer.lock index 11dab57b..1f6d1175 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "365ce4a7f734059566fad5f02f7be3b3", + "content-hash": "01ead9e869365b42b74237d60b4f6311", "packages": [ { "name": "phpstan/phpstan", @@ -5004,6 +5004,189 @@ ], "time": "2025-11-10T16:43:36+00:00" }, + { + "name": "symfony/cache", + "version": "v7.4.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache.git", + "reference": "a7a1325a5de2e54ddb45fda002ff528162e48293" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache/zipball/a7a1325a5de2e54ddb45fda002ff528162e48293", + "reference": "a7a1325a5de2e54ddb45fda002ff528162e48293", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/cache": "^2.0|^3.0", + "psr/log": "^1.1|^2|^3", + "symfony/cache-contracts": "^3.6", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/service-contracts": "^2.5|^3", + "symfony/var-exporter": "^6.4|^7.0|^8.0" + }, + "conflict": { + "doctrine/dbal": "<3.6", + "ext-redis": "<6.1", + "ext-relay": "<0.12.1", + "symfony/dependency-injection": "<6.4", + "symfony/http-kernel": "<6.4", + "symfony/var-dumper": "<6.4" + }, + "provide": { + "psr/cache-implementation": "2.0|3.0", + "psr/simple-cache-implementation": "1.0|2.0|3.0", + "symfony/cache-implementation": "1.1|2.0|3.0" + }, + "require-dev": { + "cache/integration-tests": "dev-master", + "doctrine/dbal": "^3.6|^4", + "predis/predis": "^1.1|^2.0", + "psr/simple-cache": "^1.0|^2.0|^3.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/filesystem": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Cache\\": "" + }, + "classmap": [ + "Traits/ValueWrapper.php" + ], + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides extended PSR-6, PSR-16 (and tags) implementations", + "homepage": "https://symfony.com", + "keywords": [ + "caching", + "psr6" + ], + "support": { + "source": "https://github.com/symfony/cache/tree/v7.4.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-11-16T10:14:42+00:00" + }, + { + "name": "symfony/config", + "version": "v7.4.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/config.git", + "reference": "f76c74e93bce2b9285f2dad7fbd06fa8182a7a41" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/config/zipball/f76c74e93bce2b9285f2dad7fbd06fa8182a7a41", + "reference": "f76c74e93bce2b9285f2dad7fbd06fa8182a7a41", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/filesystem": "^7.1|^8.0", + "symfony/polyfill-ctype": "~1.8" + }, + "conflict": { + "symfony/finder": "<6.4", + "symfony/service-contracts": "<2.5" + }, + "require-dev": { + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Config\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/config/tree/v7.4.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-11-27T13:27:24+00:00" + }, { "name": "symfony/console", "version": "v7.3.0", @@ -5677,6 +5860,164 @@ ], "time": "2025-10-15T18:45:57+00:00" }, + { + "name": "symfony/framework-bundle", + "version": "v7.3.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/framework-bundle.git", + "reference": "cabfdfa82bc4f75d693a329fe263d96937636b77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/cabfdfa82bc4f75d693a329fe263d96937636b77", + "reference": "cabfdfa82bc4f75d693a329fe263d96937636b77", + "shasum": "" + }, + "require": { + "composer-runtime-api": ">=2.1", + "ext-xml": "*", + "php": ">=8.2", + "symfony/cache": "^6.4|^7.0", + "symfony/config": "^7.3", + "symfony/dependency-injection": "^7.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^7.3", + "symfony/event-dispatcher": "^6.4|^7.0", + "symfony/filesystem": "^7.1", + "symfony/finder": "^6.4|^7.0", + "symfony/http-foundation": "^7.3", + "symfony/http-kernel": "^7.2", + "symfony/polyfill-mbstring": "~1.0", + "symfony/routing": "^6.4|^7.0" + }, + "conflict": { + "doctrine/persistence": "<1.3", + "phpdocumentor/reflection-docblock": "<3.2.2", + "phpdocumentor/type-resolver": "<1.4.0", + "symfony/asset": "<6.4", + "symfony/asset-mapper": "<6.4", + "symfony/clock": "<6.4", + "symfony/console": "<6.4", + "symfony/dom-crawler": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/form": "<6.4", + "symfony/http-client": "<6.4", + "symfony/json-streamer": ">=7.4", + "symfony/lock": "<6.4", + "symfony/mailer": "<6.4", + "symfony/messenger": "<6.4", + "symfony/mime": "<6.4", + "symfony/object-mapper": ">=7.4", + "symfony/property-access": "<6.4", + "symfony/property-info": "<6.4", + "symfony/runtime": "<6.4.13|>=7.0,<7.1.6", + "symfony/scheduler": "<6.4.4|>=7.0.0,<7.0.4", + "symfony/security-core": "<6.4", + "symfony/security-csrf": "<7.2", + "symfony/serializer": "<7.2.5", + "symfony/stopwatch": "<6.4", + "symfony/translation": "<7.3", + "symfony/twig-bridge": "<6.4", + "symfony/twig-bundle": "<6.4", + "symfony/validator": "<6.4", + "symfony/web-profiler-bundle": "<6.4", + "symfony/webhook": "<7.2", + "symfony/workflow": "<7.3.0-beta2" + }, + "require-dev": { + "doctrine/persistence": "^1.3|^2|^3", + "dragonmantank/cron-expression": "^3.1", + "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", + "seld/jsonlint": "^1.10", + "symfony/asset": "^6.4|^7.0", + "symfony/asset-mapper": "^6.4|^7.0", + "symfony/browser-kit": "^6.4|^7.0", + "symfony/clock": "^6.4|^7.0", + "symfony/console": "^6.4|^7.0", + "symfony/css-selector": "^6.4|^7.0", + "symfony/dom-crawler": "^6.4|^7.0", + "symfony/dotenv": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/form": "^6.4|^7.0", + "symfony/html-sanitizer": "^6.4|^7.0", + "symfony/http-client": "^6.4|^7.0", + "symfony/json-streamer": "7.3.*", + "symfony/lock": "^6.4|^7.0", + "symfony/mailer": "^6.4|^7.0", + "symfony/messenger": "^6.4|^7.0", + "symfony/mime": "^6.4|^7.0", + "symfony/notifier": "^6.4|^7.0", + "symfony/object-mapper": "^v7.3.0-beta2", + "symfony/polyfill-intl-icu": "~1.0", + "symfony/process": "^6.4|^7.0", + "symfony/property-info": "^6.4|^7.0", + "symfony/rate-limiter": "^6.4|^7.0", + "symfony/scheduler": "^6.4.4|^7.0.4", + "symfony/security-bundle": "^6.4|^7.0", + "symfony/semaphore": "^6.4|^7.0", + "symfony/serializer": "^7.2.5", + "symfony/stopwatch": "^6.4|^7.0", + "symfony/string": "^6.4|^7.0", + "symfony/translation": "^7.3", + "symfony/twig-bundle": "^6.4|^7.0", + "symfony/type-info": "^7.1.8", + "symfony/uid": "^6.4|^7.0", + "symfony/validator": "^6.4|^7.0", + "symfony/web-link": "^6.4|^7.0", + "symfony/webhook": "^7.2", + "symfony/workflow": "^7.3", + "symfony/yaml": "^6.4|^7.0", + "twig/twig": "^3.12" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Symfony\\Bundle\\FrameworkBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a tight integration between Symfony components and the Symfony full-stack framework", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/framework-bundle/tree/v7.3.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-10-30T09:42:24+00:00" + }, { "name": "symfony/http-foundation", "version": "v7.3.0", diff --git a/rules.neon b/rules.neon index 77643c8f..285f9c30 100644 --- a/rules.neon +++ b/rules.neon @@ -83,6 +83,7 @@ services: tags: - shipmonk.deadCode.memberUsageProvider arguments: + analysedPaths: %analysedPaths% enabled: %shipmonkDeadCode.usageProviders.twig.enabled% - diff --git a/src/Provider/TwigUsageProvider.php b/src/Provider/TwigUsageProvider.php index bacbca8b..7ae24670 100644 --- a/src/Provider/TwigUsageProvider.php +++ b/src/Provider/TwigUsageProvider.php @@ -3,16 +3,24 @@ namespace ShipMonk\PHPStan\DeadCode\Provider; use Composer\InstalledVersions; +use LogicException; use PhpParser\Node; +use PhpParser\Node\Expr\MethodCall; use PhpParser\Node\Expr\New_; +use PhpParser\Node\Identifier; use PhpParser\Node\Name; +use PhpParser\Node\Stmt\Return_; use PHPStan\Analyser\ArgumentsNormalizer; use PHPStan\Analyser\Scope; use PHPStan\BetterReflection\Reflection\Adapter\ReflectionMethod; use PHPStan\Node\InClassNode; +use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\ExtendedMethodReflection; use PHPStan\Reflection\ParametersAcceptorSelector; +use PHPStan\Reflection\ReflectionProvider; +use PHPStan\Type\Type; use PHPStan\Type\UnionType; +use ReflectionException; use ShipMonk\PHPStan\DeadCode\Graph\ClassMethodRef; use ShipMonk\PHPStan\DeadCode\Graph\ClassMethodUsage; use ShipMonk\PHPStan\DeadCode\Graph\UsageOrigin; @@ -20,20 +28,40 @@ use function count; use function explode; use function in_array; +use function sprintf; +use function strpos; final class TwigUsageProvider implements MemberUsageProvider { private bool $enabled; - public function __construct(?bool $enabled) + /** + * @var list + */ + private array $analysedPaths; + + private ReflectionProvider $reflectionProvider; + + /** + * @param list $analysedPaths + */ + public function __construct( + ReflectionProvider $reflectionProvider, + array $analysedPaths, + ?bool $enabled + ) { + $this->reflectionProvider = $reflectionProvider; + $this->analysedPaths = $analysedPaths; $this->enabled = $enabled ?? $this->isTwigInstalled(); } private function isTwigInstalled(): bool { - return InstalledVersions::isInstalled('twig/twig'); + return InstalledVersions::isInstalled('twig/twig') + || InstalledVersions::isInstalled('symfony/framework-bundle') + || InstalledVersions::isInstalled('symfony/twig-bridge'); } public function getUsages( @@ -61,6 +89,20 @@ public function getUsages( ]; } + if ($node instanceof Return_) { + $usages = [ + ...$usages, + ...$this->getUsagesFromTemplateReturn($node, $scope), + ]; + } + + if ($node instanceof MethodCall) { + $usages = [ + ...$usages, + ...$this->getUsagesFromRenderCall($node, $scope), + ]; + } + return $usages; } @@ -233,4 +275,282 @@ private function createUsage( ); } + /** + * @return list + */ + private function getUsagesFromTemplateReturn( + Return_ $node, + Scope $scope + ): array + { + if (!$this->isInControllerMethodWithTemplate($scope)) { + return []; + } + + if ($node->expr === null) { + return []; + } + + $returnType = $scope->getType($node->expr); + $referencedClassNames = $this->extractObjectTypes($returnType); + + $usages = []; + $visited = []; + + foreach ($referencedClassNames as $className) { + $usages = [ + ...$usages, + ...$this->traverseClassNameRecursively($className, $visited, ''), + ]; + } + + return $usages; + } + + /** + * @return list + */ + private function getUsagesFromRenderCall( + MethodCall $node, + Scope $scope + ): array + { + if (!$scope->isInClass()) { + return []; + } + + if (!$scope->getClassReflection()->is('Symfony\Bundle\FrameworkBundle\Controller\AbstractController')) { + return []; + } + + if (!$node->name instanceof Identifier) { + return []; + } + + $methodName = $node->name->toString(); + + // Check if it's one of the Twig rendering methods + $twigRenderMethods = [ + 'render' => 1, + 'renderView' => 1, + 'renderBlock' => 2, + 'renderBlockView' => 2, + 'stream' => 1, + ]; + + $parametersArgIndex = $twigRenderMethods[$methodName] ?? null; + if ($parametersArgIndex === null) { + return []; + } + + $args = $node->getArgs(); + + if (!isset($args[$parametersArgIndex])) { + return []; + } + + $parametersArg = $args[$parametersArgIndex]; + $parametersType = $scope->getType($parametersArg->value); + + $objectTypes = $this->extractObjectTypes($parametersType); + + $usages = []; + $visited = []; + + foreach ($objectTypes as $className) { + $usages = [ + ...$usages, + ...$this->traverseClassNameRecursively($className, $visited, ''), + ]; + } + + return $usages; + } + + private function isInControllerMethodWithTemplate( + Scope $scope + ): bool + { + if (!$scope->isInClass()) { + return false; + } + $methodName = $scope->getFunctionName(); + if ($methodName === null) { + return false; + } + try { + $attributes = $scope->getClassReflection()->getNativeReflection()->getMethod($methodName)->getAttributes(); + } catch (ReflectionException $e) { + throw new LogicException("Method $methodName must exist as it was returned from Scope. Should never happen.", 0, $e); + } + + foreach ($attributes as $attribute) { + if ( + $attribute->getName() === 'Symfony\Bridge\Twig\Attribute\Template' // Symfony 6.2+ (TwigBridge) + || $attribute->getName() === 'Sensio\Bundle\FrameworkExtraBundle\Configuration\Template' // SensioFrameworkExtraBundle (legacy) + ) { + return true; + } + } + + return false; + } + + /** + * @return list + */ + private function extractObjectTypes(Type $returnType): array + { + return $returnType->getReferencedClasses(); + } + + /** + * @param array $visited + * @return list + */ + private function traverseClassNameRecursively( + string $className, + array &$visited, + string $context + ): array + { + if (isset($visited[$className])) { + return []; // Cycle detection + } + + $visited[$className] = true; + + if (!$this->reflectionProvider->hasClass($className)) { + return []; + } + + $classReflection = $this->reflectionProvider->getClass($className); + + if ($this->shouldSkipClass($classReflection)) { + return []; + } + + return $this->getPublicMembersUsages($classReflection, $visited, $context); + } + + /** + * @param array $visited + * @return list + */ + private function getPublicMembersUsages( + ClassReflection $classReflection, + array &$visited, + string $context + ): array + { + $usages = []; + $className = $classReflection->getName(); + $nativeReflection = $classReflection->getNativeReflection(); + + // Process public methods + foreach ($nativeReflection->getMethods() as $method) { + if (!$method->isPublic() || $method->isStatic()) { + continue; + } + + // Skip magic methods + if ($this->shouldSkipMethod($method->getName())) { + continue; + } + + // Mark method as used + $usages[] = $this->createMethodUsage($className, $method->getName(), $context); + + // Traverse method return type + $extendedMethodReflection = $classReflection->getNativeMethod($method->getName()); + $variants = $extendedMethodReflection->getVariants(); + + foreach ($variants as $variant) { + $returnType = $variant->getReturnType(); + + foreach ($returnType->getObjectClassNames() as $returnClassName) { + $newContext = $context !== '' + ? "{$context} -> {$className}::{$method->getName()}" + : "{$className}::{$method->getName()}"; + + $usages = [ + ...$usages, + ...$this->traverseClassNameRecursively( + $returnClassName, + $visited, + $newContext, + ), + ]; + } + } + } + + // Process public properties + foreach ($nativeReflection->getProperties() as $property) { + if (!$property->isPublic() || $property->isStatic()) { + continue; + } + + $propertyReflection = $classReflection->getNativeProperty($property->getName()); + + foreach ($propertyReflection->getReadableType()->getObjectClassNames() as $propertyClassName) { + $newContext = $context !== '' + ? "{$context} -> {$className}::\${$property->getName()}" + : "{$className}::\${$property->getName()}"; + + $usages = [ + ...$usages, + ...$this->traverseClassNameRecursively( + $propertyClassName, + $visited, + $newContext, + ), + ]; + } + } + + return $usages; + } + + private function createMethodUsage( + string $className, + string $methodName, + string $context + ): ClassMethodUsage + { + $note = $context !== '' + ? sprintf('Accessible in Twig template (from controller return value via %s)', $context) + : 'Accessible in Twig template (from controller return value)'; + + return new ClassMethodUsage( + UsageOrigin::createVirtual($this, VirtualUsageData::withNote($note)), + new ClassMethodRef($className, $methodName, false), + ); + } + + private function shouldSkipMethod(string $methodName): bool + { + return strpos($methodName, '__') === 0; + } + + private function shouldSkipClass(ClassReflection $classReflection): bool + { + if ($classReflection->isInternal()) { + return true; + } + + $fileName = $classReflection->getFileName(); + if ($fileName === null) { + return true; + } + + foreach ($this->analysedPaths as $path) { + if (strpos($fileName, $path) === 0) { + return false; // do not traverse non-analyzed classes (e.g. vendor) + } + } + + return true; + } + } diff --git a/tests/Rule/DeadCodeRuleTest.php b/tests/Rule/DeadCodeRuleTest.php index ccf3dabf..185e472b 100644 --- a/tests/Rule/DeadCodeRuleTest.php +++ b/tests/Rule/DeadCodeRuleTest.php @@ -859,6 +859,7 @@ public static function provideFiles(): Traversable yield 'provider-symfony' => [__DIR__ . '/data/providers/symfony.php', self::requiresPhp(8_00_00)]; yield 'provider-symfony-7.1' => [__DIR__ . '/data/providers/symfony-gte71.php', self::requiresPhp(8_00_00) && self::requiresPackage('symfony/dependency-injection', '>= 7.1')]; yield 'provider-twig' => [__DIR__ . '/data/providers/twig.php', self::requiresPhp(8_00_00)]; + yield 'provider-twig-template' => [__DIR__ . '/data/providers/twig-template.php', self::requiresPhp(8_00_00)]; yield 'provider-phpunit' => [__DIR__ . '/data/providers/phpunit.php', self::requiresPhp(8_00_00)]; yield 'provider-phpbench' => [__DIR__ . '/data/providers/phpbench.php', self::requiresPhp(8_00_00)]; yield 'provider-behat' => [__DIR__ . '/data/providers/behat.php', self::requiresPhp(8_00_00)]; @@ -1026,6 +1027,8 @@ private function getMemberUsageProviders(): array __DIR__ . '/data/providers/symfony/', ), new TwigUsageProvider( + self::createReflectionProvider(), + [__DIR__ . '/data/providers/twig-template.php'], $this->providersEnabled, ), new ApiPhpDocUsageProvider( diff --git a/tests/Rule/data/providers/twig-template-outside.php b/tests/Rule/data/providers/twig-template-outside.php new file mode 100644 index 00000000..e802e234 --- /dev/null +++ b/tests/Rule/data/providers/twig-template-outside.php @@ -0,0 +1,10 @@ +name; } +} + +final class TemplateNestedData +{ + public function getValue(): string { return 'nested'; } + public function getDeep(): TemplateDeepData { return new TemplateDeepData(); } +} + +final class TemplateDeepData +{ + public function getDeepValue(): string { return 'deep'; } +} + +final class TemplateParentModel +{ + public function getNested(): TemplateNestedData { return new TemplateNestedData(); } +} + +final class TemplateCircularA +{ + public function getB(): TemplateCircularB { return new TemplateCircularB(); } +} + +final class TemplateCircularB +{ + public function getA(): TemplateCircularA { return new TemplateCircularA(); } +} + +final class TemplateModelWithProperty +{ + public function __construct( + public string $publicValue, + public TemplateNestedData $nestedData, + ) {} + public function getMethod(): string { return 'value'; } +} +final class TemplateModelReferencedFromOutsideShouldNotBeTraversed +{ + public function getName(): string { return 'outside'; } // error: Unused TwigTemplate\TemplateModelReferencedFromOutsideShouldNotBeTraversed::getName +} + +// Models for $this->render() method tests +final class RenderSimpleModel +{ + public function __construct(private string $name) {} + public function getName(): string { return $this->name; } +} + +final class RenderNestedData +{ + public function getValue(): string { return 'nested'; } + public function getDeep(): RenderDeepData { return new RenderDeepData(); } +} + +final class RenderDeepData +{ + public function getDeepValue(): string { return 'deep'; } +} + +final class RenderParentModel +{ + public function getNested(): RenderNestedData { return new RenderNestedData(); } +} + +final class RenderCircularA +{ + public function getB(): RenderCircularB { return new RenderCircularB(); } +} + +final class RenderCircularB +{ + public function getA(): RenderCircularA { return new RenderCircularA(); } +} + +final class RenderModelWithProperty +{ + public function __construct( + public string $publicValue, + public RenderNestedData $nestedData, + ) {} + public function getMethod(): string { return 'value'; } +} + +final class UnusedModel +{ + public function unusedMethod(): string { return 'unused'; } // error: Unused TwigTemplate\UnusedModel::unusedMethod +} + +final class NonControllerModel +{ + public function nonControllerMethod(): string { return 'should be dead'; } // error: Unused TwigTemplate\NonControllerModel::nonControllerMethod +} + +final class NotAController +{ + + /** + * @param array $parameters + */ + private function render(string $view, array $parameters = []): Response // error: Unused TwigTemplate\NotAController::render + { + return new Response(); + } + + public function someMethod(): Response // error: Unused TwigTemplate\NotAController::someMethod + { + return $this->render('template.twig', [ + 'model' => new NonControllerModel(), + ]); + } + +} + +// Controller extending AbstractController +final class TestController extends AbstractController +{ + + // #[Template] attribute tests + #[Route('/template-simple')] + #[Template('simple.html.twig')] + public function templateSimple(): array + { + return ['model' => new TemplateSimpleModel('test')]; + } + + #[Route('/template-nested')] + #[Template('nested.html.twig')] + public function templateNested(): array + { + return ['parent' => new TemplateParentModel()]; + } + + #[Route('/template-circular')] + #[Template('circular.html.twig')] + public function templateCircular(): array + { + return ['data' => new TemplateCircularA()]; + } + + #[Route('/template-property')] + #[Template('property.html.twig')] + public function templateProperty(): array + { + return ['model' => new TemplateModelWithProperty('test', new TemplateNestedData())]; + } + + // $this->render() method tests + #[Route('/render-simple')] + public function renderSimple(): Response + { + return $this->render('simple.html.twig', [ + 'model' => new RenderSimpleModel('test'), + ]); + } + + #[Route('/render-nested')] + public function renderNested(): Response + { + return $this->render('nested.html.twig', [ + 'parent' => new RenderParentModel(), + ]); + } + + #[Route('/render-circular')] + public function renderCircular(): Response + { + return $this->render('circular.html.twig', [ + 'data' => new RenderCircularA(), + ]); + } + + #[Route('/render-property')] + public function renderProperty(): Response + { + return $this->render('property.html.twig', [ + 'model' => new RenderModelWithProperty('test', new RenderNestedData()), + ]); + } + + // renderView() method test + #[Route('/render-view-test')] + public function renderViewTest(): string + { + return $this->renderView('view.html.twig', [ + 'model' => new RenderSimpleModel('renderView'), + ]); + } + + // renderBlock() method test + #[Route('/render-block-test')] + public function renderBlockTest(): Response + { + return $this->renderBlock('block.html.twig', 'content', [ + 'data' => new RenderNestedData(), + ]); + } + + // stream() method test + #[Route('/stream-test')] + public function streamTest(): Response + { + return $this->stream('stream.html.twig', [ + 'circular' => new RenderCircularA(), + ]); + } + + #[Route('/no-template')] + public function noTemplate(): array + { + return ['unused' => new UnusedModel()]; + } + + #[Route('/response')] + #[Template('response.html.twig')] + public function outsideAnalysedPaths(): array + { + return ['outside' => new TemplateModelOutsideOfAnalysedPaths()]; + } + +} From 5bdbbc400df5386c36f6ea971e4feb0ad9ba0c80 Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Thu, 4 Dec 2025 09:50:30 +0100 Subject: [PATCH 2/4] Fix property hook issue --- src/Provider/TwigUsageProvider.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Provider/TwigUsageProvider.php b/src/Provider/TwigUsageProvider.php index 7ae24670..05e85548 100644 --- a/src/Provider/TwigUsageProvider.php +++ b/src/Provider/TwigUsageProvider.php @@ -374,10 +374,14 @@ private function isInControllerMethodWithTemplate( if (!$scope->isInClass()) { return false; } - $methodName = $scope->getFunctionName(); - if ($methodName === null) { + $function = $scope->getFunction(); + if ($function === null) { return false; } + if ($function->isMethodOrPropertyHook() && $function->isPropertyHook()) { + return false; + } + $methodName = $function->getName(); try { $attributes = $scope->getClassReflection()->getNativeReflection()->getMethod($methodName)->getAttributes(); } catch (ReflectionException $e) { From a11e24eb836556867320f6078a70545f5b10985f Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Thu, 4 Dec 2025 10:52:17 +0100 Subject: [PATCH 3/4] Readme --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 6d6d383d..c1eefb05 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,9 @@ $ vendor/bin/phpstan - `SmartObject` magic calls for `@property` annotations #### Twig: +- Twig parameters (including nested referenced ones) + - Passed to `$controller->render('my.twig', ['param' => $viewModel])`, + - Returned from `#[Template]` controller methods - `#[AsTwigFilter]`, `#[AsTwigFunction]`, `#[AsTwigTest]` - `new TwigFilter(..., callback)`, `new TwigFunction(..., callback)`, `new TwigTest(..., callback)` From 7802f7706dcc78c315c90ff83ef502cfdd16340c Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Thu, 4 Dec 2025 11:01:05 +0100 Subject: [PATCH 4/4] Better virtual usage note --- src/Provider/TwigUsageProvider.php | 44 +++++++++++++++++++----------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/src/Provider/TwigUsageProvider.php b/src/Provider/TwigUsageProvider.php index 05e85548..19c995a1 100644 --- a/src/Provider/TwigUsageProvider.php +++ b/src/Provider/TwigUsageProvider.php @@ -28,7 +28,6 @@ use function count; use function explode; use function in_array; -use function sprintf; use function strpos; final class TwigUsageProvider implements MemberUsageProvider @@ -296,11 +295,12 @@ private function getUsagesFromTemplateReturn( $usages = []; $visited = []; + $rootContext = $this->getRootContext($node, $scope); foreach ($referencedClassNames as $className) { $usages = [ ...$usages, - ...$this->traverseClassNameRecursively($className, $visited, ''), + ...$this->traverseClassNameRecursively($className, $visited, $rootContext), ]; } @@ -356,17 +356,33 @@ private function getUsagesFromRenderCall( $usages = []; $visited = []; + $rootContext = $this->getRootContext($node, $scope); foreach ($objectTypes as $className) { $usages = [ ...$usages, - ...$this->traverseClassNameRecursively($className, $visited, ''), + ...$this->traverseClassNameRecursively($className, $visited, $rootContext), ]; } return $usages; } + /** + * @return non-empty-string + */ + private function getRootContext( + Node $node, + Scope $scope + ): string + { + $functionName = $scope->getFunctionName(); + if (!$scope->isInClass() || $functionName === null) { + return 'unknown'; + } + return "{$scope->getClassReflection()->getName()}::{$functionName}({$node->getStartLine()})"; + } + private function isInControllerMethodWithTemplate( Scope $scope ): bool @@ -409,6 +425,7 @@ private function extractObjectTypes(Type $returnType): array } /** + * @param non-empty-string $context * @param array $visited * @return list */ @@ -439,6 +456,7 @@ private function traverseClassNameRecursively( /** * @param array $visited + * @param non-empty-string $context * @return list */ private function getPublicMembersUsages( @@ -450,6 +468,7 @@ private function getPublicMembersUsages( $usages = []; $className = $classReflection->getName(); $nativeReflection = $classReflection->getNativeReflection(); + $shortClassName = $nativeReflection->getShortName(); // Process public methods foreach ($nativeReflection->getMethods() as $method) { @@ -468,15 +487,12 @@ private function getPublicMembersUsages( // Traverse method return type $extendedMethodReflection = $classReflection->getNativeMethod($method->getName()); $variants = $extendedMethodReflection->getVariants(); + $newContext = "{$context} -> {$shortClassName}::{$method->getName()}"; foreach ($variants as $variant) { $returnType = $variant->getReturnType(); foreach ($returnType->getObjectClassNames() as $returnClassName) { - $newContext = $context !== '' - ? "{$context} -> {$className}::{$method->getName()}" - : "{$className}::{$method->getName()}"; - $usages = [ ...$usages, ...$this->traverseClassNameRecursively( @@ -496,12 +512,9 @@ private function getPublicMembersUsages( } $propertyReflection = $classReflection->getNativeProperty($property->getName()); + $newContext = "{$context} -> {$shortClassName}::\${$property->getName()}"; foreach ($propertyReflection->getReadableType()->getObjectClassNames() as $propertyClassName) { - $newContext = $context !== '' - ? "{$context} -> {$className}::\${$property->getName()}" - : "{$className}::\${$property->getName()}"; - $usages = [ ...$usages, ...$this->traverseClassNameRecursively( @@ -516,18 +529,17 @@ private function getPublicMembersUsages( return $usages; } + /** + * @param non-empty-string $context + */ private function createMethodUsage( string $className, string $methodName, string $context ): ClassMethodUsage { - $note = $context !== '' - ? sprintf('Accessible in Twig template (from controller return value via %s)', $context) - : 'Accessible in Twig template (from controller return value)'; - return new ClassMethodUsage( - UsageOrigin::createVirtual($this, VirtualUsageData::withNote($note)), + UsageOrigin::createVirtual($this, VirtualUsageData::withNote($context)), new ClassMethodRef($className, $methodName, false), ); }