From fa3bc07fe7f6670296be95d8511c1e1d1d9134ef Mon Sep 17 00:00:00 2001 From: Alexander Minkin Date: Tue, 6 Oct 2026 13:58:56 +0300 Subject: [PATCH 01/27] Support Latte 3.1: constraint bump, TemplateGenerator API split, pos->line normalization --- composer.json | 2 +- src/Compiler/Compiler/Latte3Compiler.php | 28 +++++++++++++++++++----- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/composer.json b/composer.json index 0866db90..1b4619fd 100644 --- a/composer.json +++ b/composer.json @@ -10,7 +10,7 @@ "phpstan/phpstan": "^2.1.33", "phpstan/phpdoc-parser": "^2.3.0", "phpstan/phpstan-nette": "^2.0.7", - "latte/latte": "^2.11.6 | ~3.0.25", + "latte/latte": "^2.11.6 | ^3.0.25", "nette/utils": "^3.2|^4.1", "nette/finder": "^2.5|^3.0" }, diff --git a/src/Compiler/Compiler/Latte3Compiler.php b/src/Compiler/Compiler/Latte3Compiler.php index df299994..34a41fa2 100644 --- a/src/Compiler/Compiler/Latte3Compiler.php +++ b/src/Compiler/Compiler/Latte3Compiler.php @@ -19,7 +19,9 @@ use function is_array; use function is_object; use function md5; +use function method_exists; use function preg_replace; +use function str_replace; final class Latte3Compiler extends AbstractCompiler { @@ -88,12 +90,23 @@ public function compile(string $templateContent, ?string $actualClass, string $c $this->engine->applyPasses($templateNode); $className = $this->generateClassName(); $templateGenerator = new TemplateGenerator(); - $phpContent = $templateGenerator->generate( - $templateNode, - $className, - $this->generateClassComment($className, $context), - $this->strictMode - ); + if (method_exists($templateGenerator, 'buildClass')) { + // Latte 3.1+ (TemplateGenerator::generate() was split into buildClass() + generateCode()) + $templateGenerator->buildClass($templateNode); + $phpContent = $templateGenerator->generateCode($className, null, $this->strictMode); + $phpContent = str_replace( + "final class {$className} extends", + '/** source: ' . $this->generateClassComment($className, $context) . " */\nfinal class {$className} extends", + $phpContent + ); + } else { + $phpContent = $templateGenerator->generate( + $templateNode, + $className, + $this->generateClassComment($className, $context), + $this->strictMode + ); + } $phpContent = $this->fixLines($phpContent); $phpContent = $this->addTypes($phpContent, $className, $actualClass); return $phpContent; @@ -111,6 +124,9 @@ private function installExtensions(array $extensions): void private function fixLines(string $phpContent): string { + // Latte 3.1+ emits "/* pos [:] */" instead of "/* line */" + $phpContent = preg_replace('/\/\* pos (\d+)(?::\d+)? \*\//', '/* line $1 */', $phpContent) ?: ''; + // fix lines after $component->render() $pattern = '/\$ʟ_tmp = \$this->global->uiControl->getComponent(.*?)\$ʟ_tmp->render\((.*?)\) (?(.*?)\/\*(.*?)line (?\d+)(.*?)\*\/);/s'; $phpContent = preg_replace($pattern, '${3}' . "\n\t\t" . '$ʟ_tmp = $this->global->uiControl->getComponent${1}$ʟ_tmp->render(${2});', $phpContent) ?: ''; From dcfb94d1de3f27f557a591ef7bf47b287c13dcf6 Mon Sep 17 00:00:00 2001 From: Alexander Minkin Date: Tue, 6 Oct 2026 14:04:08 +0300 Subject: [PATCH 02/27] Declare fork version 0.20.1 for path repositories --- composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/composer.json b/composer.json index 1b4619fd..95db1626 100644 --- a/composer.json +++ b/composer.json @@ -1,5 +1,6 @@ { "name": "efabrica/phpstan-latte", + "version": "0.20.1", "license": ["MIT"], "keywords": [ "static analysis" From 32400d89ff5a698a0a6b05e561c7a1d5c8dd3c76 Mon Sep 17 00:00:00 2001 From: Alexander Minkin Date: Tue, 6 Oct 2026 14:30:04 +0300 Subject: [PATCH 03/27] feat: add latte.templateTypes option for custom template objects --- extension.neon | 4 +++- .../TypeResolver/TemplateTypeResolver.php | 24 ++++++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/extension.neon b/extension.neon index f61977ba..b1af56b2 100644 --- a/extension.neon +++ b/extension.neon @@ -32,6 +32,7 @@ parameters: resolveAllPossiblePaths: false reportUnanalysedTemplates: false collectedPaths: [] + templateTypes: [] features: transformDynamicFormControlNamesToString: false phpstanCommand: null @@ -63,6 +64,7 @@ parametersSchema: resolveAllPossiblePaths: bool() reportUnanalysedTemplates: bool() collectedPaths: arrayOf(string()) + templateTypes: arrayOf(string()) features: structure([ transformDynamicFormControlNamesToString: bool() phpstanCommand: schema(string(), nullable()) @@ -135,7 +137,7 @@ services: # Type resolvers - Efabrica\PHPStanLatte\Resolver\TypeResolver\TypeResolver - - Efabrica\PHPStanLatte\Resolver\TypeResolver\TemplateTypeResolver + - Efabrica\PHPStanLatte\Resolver\TypeResolver\TemplateTypeResolver(%latte.templateTypes%) # Value resolvers - Efabrica\PHPStanLatte\Resolver\ValueResolver\ValueResolver() diff --git a/src/Resolver/TypeResolver/TemplateTypeResolver.php b/src/Resolver/TypeResolver/TemplateTypeResolver.php index 8b3f9b68..327f0f54 100644 --- a/src/Resolver/TypeResolver/TemplateTypeResolver.php +++ b/src/Resolver/TypeResolver/TemplateTypeResolver.php @@ -15,6 +15,17 @@ final class TemplateTypeResolver { + /** @var string[] */ + private array $templateTypes; + + /** + * @param string[] $templateTypes additional classes considered Latte template objects + */ + public function __construct(array $templateTypes = []) + { + $this->templateTypes = $templateTypes; + } + public function resolve(Type $type): bool { if ($type instanceof UnionType) { @@ -24,7 +35,18 @@ public function resolve(Type $type): bool } } } - return (new ObjectType('Nette\Application\UI\Template'))->isSuperTypeOf($type)->yes() || (new ObjectType('Nette\Application\UI\ITemplate'))->isSuperTypeOf($type)->yes(); + if ((new ObjectType('Nette\Application\UI\Template'))->isSuperTypeOf($type)->yes()) { + return true; + } + if ((new ObjectType('Nette\Application\UI\ITemplate'))->isSuperTypeOf($type)->yes()) { + return true; + } + foreach ($this->templateTypes as $templateType) { + if ((new ObjectType($templateType))->isSuperTypeOf($type)->yes()) { + return true; + } + } + return false; } public function resolveByNodeAndScope(Node $node, Scope $scope): bool From 622b893c4988ed6436b5c4b97d912735a122ae99 Mon Sep 17 00:00:00 2001 From: Alexander Minkin Date: Tue, 6 Oct 2026 14:30:04 +0300 Subject: [PATCH 04/27] fix: resolve {extends}/{layout} layouts with quoted paths --- .../LayoutResolver/LayoutPathResolver.php | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/Resolver/LayoutResolver/LayoutPathResolver.php b/src/Resolver/LayoutResolver/LayoutPathResolver.php index e023c5ca..007b3938 100644 --- a/src/Resolver/LayoutResolver/LayoutPathResolver.php +++ b/src/Resolver/LayoutResolver/LayoutPathResolver.php @@ -6,6 +6,7 @@ use function dirname; use function file_get_contents; +use function in_array; use function preg_match; use function realpath; use const DIRECTORY_SEPARATOR; @@ -30,11 +31,18 @@ public function resolve(?string $templatePath): ?string } $templateContent = file_get_contents($templatePath) ?: ''; - preg_match('/{(layout|extend) (?.*?)}/', $templateContent, $match); - - if (isset($match['layout_name'])) { - $layoutFilePath = dirname($templatePath) . DIRECTORY_SEPARATOR . $match['layout_name']; - return $layoutFilePath; + preg_match( + '/\{(?:layout|extend|extends)\s+(?:[\'"](?[^\'"]+)[\'"]|(?[^\s,}]+))/', + $templateContent, + $match + ); + + $layoutName = $match['quoted'] ?? $match['bare'] ?? null; + if ($layoutName !== null && !in_array($layoutName, ['none', 'auto'], true)) { + $layoutFilePath = realpath(dirname($templatePath) . DIRECTORY_SEPARATOR . $layoutName) ?: null; + if ($layoutFilePath !== null) { + return $layoutFilePath; + } } $layoutFilePath = realpath(dirname($templatePath) . DIRECTORY_SEPARATOR . '@layout.latte') ?: null; From 4db6184e26dbad4cf189d8c509cf53988e051a9a Mon Sep 17 00:00:00 2001 From: Alexander Minkin Date: Tue, 6 Oct 2026 15:47:16 +0300 Subject: [PATCH 05/27] fix: require compiled templates only once per process --- src/Compiler/LatteToPhpCompiler.php | 2 +- src/Compiler/Postprocessor.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Compiler/LatteToPhpCompiler.php b/src/Compiler/LatteToPhpCompiler.php index d1c45699..6748270c 100644 --- a/src/Compiler/LatteToPhpCompiler.php +++ b/src/Compiler/LatteToPhpCompiler.php @@ -110,7 +110,7 @@ public function compileFile(Template $template, string $context = ''): string $compileFilePath = $compileDir . DIRECTORY_SEPARATOR . $fileName; if (!$this->debugMode && file_exists($compileFilePath)) { - require($compileFilePath); // load type definitions from compiled template + require_once($compileFilePath); // load type definitions from compiled template return realpath($compileFilePath) ?: ''; } diff --git a/src/Compiler/Postprocessor.php b/src/Compiler/Postprocessor.php index 18d3e9b6..bf6eb206 100644 --- a/src/Compiler/Postprocessor.php +++ b/src/Compiler/Postprocessor.php @@ -102,7 +102,7 @@ public function postProcess(string $phpContent, Template $template, string $comp if ($realPath === '') { return ''; } - require($compileFilePath); // load type definitions from compiled template + require_once($compileFilePath); // load type definitions from compiled template $scope = $this->scopeFactory->create(ScopeContext::create($realPath)); From e0d5bb2a4b5e44a716f0e66436e3645e896d70ac Mon Sep 17 00:00:00 2001 From: Alexander Minkin Date: Tue, 6 Oct 2026 15:47:16 +0300 Subject: [PATCH 06/27] feat: add latte.maxTemplateVariants to bound include-heavy template analysis --- extension.neon | 2 ++ rules.neon | 1 + src/Rule/LatteTemplatesRule.php | 19 ++++++++++++++++++- 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/extension.neon b/extension.neon index b1af56b2..5e57cb76 100644 --- a/extension.neon +++ b/extension.neon @@ -31,6 +31,7 @@ parameters: extensions: [] resolveAllPossiblePaths: false reportUnanalysedTemplates: false + maxTemplateVariants: 0 collectedPaths: [] templateTypes: [] features: @@ -63,6 +64,7 @@ parametersSchema: extensions: arrayOf(object()) resolveAllPossiblePaths: bool() reportUnanalysedTemplates: bool() + maxTemplateVariants: int() collectedPaths: arrayOf(string()) templateTypes: arrayOf(string()) features: structure([ diff --git a/rules.neon b/rules.neon index 731666e3..8fc1512e 100644 --- a/rules.neon +++ b/rules.neon @@ -6,5 +6,6 @@ services: factory: Efabrica\PHPStanLatte\Rule\LatteTemplatesRule() arguments: phpstanCommand: %latte.features.phpstanCommand% + maxTemplateVariants: %latte.maxTemplateVariants% tags: - phpstan.rules.rule diff --git a/src/Rule/LatteTemplatesRule.php b/src/Rule/LatteTemplatesRule.php index 0156e81e..6c61b093 100644 --- a/src/Rule/LatteTemplatesRule.php +++ b/src/Rule/LatteTemplatesRule.php @@ -57,6 +57,9 @@ */ final class LatteTemplatesRule implements Rule { + /** @var array number of analysed variants per template path in the current run */ + private array $templateVariantCounts = []; + /** @var LatteTemplateResolverInterface[] */ private array $latteTemplateResolvers; @@ -82,6 +85,8 @@ final class LatteTemplatesRule implements Rule private ?string $phpstanCommand; + private int $maxTemplateVariants; + /** * @param LatteTemplateResolverInterface[] $latteTemplateResolvers * @param TemplateRenderCollector[] $templateRenderCollectors @@ -98,7 +103,8 @@ public function __construct( array $templateRenderCollectors, TemplateContextHelper $templateContextHelper, TempDirResolver $tempDirResolver, - ?string $phpstanCommand + ?string $phpstanCommand, + int $maxTemplateVariants = 0 ) { $this->latteTemplateResolvers = $latteTemplateResolvers; $this->latteToPhpCompiler = $latteToPhpCompiler; @@ -112,6 +118,7 @@ public function __construct( $this->templateContextHelper = $templateContextHelper; $this->tempDirResolver = $tempDirResolver; $this->phpstanCommand = $phpstanCommand; + $this->maxTemplateVariants = $maxTemplateVariants; } public function getNodeType(): string @@ -124,6 +131,8 @@ public function getNodeType(): string */ public function processNode(Node $collectedDataNode, Scope $scope): array { + $this->templateVariantCounts = []; + $resolvedNodeFinder = new ResolvedNodeFinder($collectedDataNode, $this->latteTemplateResolvers); $analysedFiles = $resolvedNodeFinder->getAnalysedFiles(); @@ -173,6 +182,14 @@ private function compileTemplates(array $templates, array &$errors, ?array &$alr foreach ($templates as $template) { $templatePath = $template->getPath(); + if ($this->maxTemplateVariants > 0) { + $variants = $this->templateVariantCounts[$templatePath] ?? 0; + if ($variants >= $this->maxTemplateVariants) { + continue; + } + $this->templateVariantCounts[$templatePath] = $variants + 1; + } + if ($this->analysedTemplatesRegistry->isExcludedFromAnalysing($templatePath)) { continue; } From 2772df2c2e3e0b3f1b48a30d1eb868119785e51e Mon Sep 17 00:00:00 2001 From: Alexander Minkin Date: Tue, 6 Oct 2026 16:03:45 +0300 Subject: [PATCH 07/27] feat: add latte.breakIncludeCycles to stop cyclic include recursion --- extension.neon | 2 ++ rules.neon | 1 + src/Rule/LatteTemplatesRule.php | 10 +++++++++- 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/extension.neon b/extension.neon index 5e57cb76..ddae7952 100644 --- a/extension.neon +++ b/extension.neon @@ -32,6 +32,7 @@ parameters: resolveAllPossiblePaths: false reportUnanalysedTemplates: false maxTemplateVariants: 0 + breakIncludeCycles: false collectedPaths: [] templateTypes: [] features: @@ -65,6 +66,7 @@ parametersSchema: resolveAllPossiblePaths: bool() reportUnanalysedTemplates: bool() maxTemplateVariants: int() + breakIncludeCycles: bool() collectedPaths: arrayOf(string()) templateTypes: arrayOf(string()) features: structure([ diff --git a/rules.neon b/rules.neon index 8fc1512e..f552477a 100644 --- a/rules.neon +++ b/rules.neon @@ -7,5 +7,6 @@ services: arguments: phpstanCommand: %latte.features.phpstanCommand% maxTemplateVariants: %latte.maxTemplateVariants% + breakIncludeCycles: %latte.breakIncludeCycles% tags: - phpstan.rules.rule diff --git a/src/Rule/LatteTemplatesRule.php b/src/Rule/LatteTemplatesRule.php index 6c61b093..d416adc1 100644 --- a/src/Rule/LatteTemplatesRule.php +++ b/src/Rule/LatteTemplatesRule.php @@ -38,6 +38,7 @@ use function count; use function dirname; use function get_class; +use function in_array; use function is_dir; use function is_file; use function is_string; @@ -87,6 +88,8 @@ final class LatteTemplatesRule implements Rule private int $maxTemplateVariants; + private bool $breakIncludeCycles; + /** * @param LatteTemplateResolverInterface[] $latteTemplateResolvers * @param TemplateRenderCollector[] $templateRenderCollectors @@ -104,7 +107,8 @@ public function __construct( TemplateContextHelper $templateContextHelper, TempDirResolver $tempDirResolver, ?string $phpstanCommand, - int $maxTemplateVariants = 0 + int $maxTemplateVariants = 0, + bool $breakIncludeCycles = false ) { $this->latteTemplateResolvers = $latteTemplateResolvers; $this->latteToPhpCompiler = $latteToPhpCompiler; @@ -119,6 +123,7 @@ public function __construct( $this->tempDirResolver = $tempDirResolver; $this->phpstanCommand = $phpstanCommand; $this->maxTemplateVariants = $maxTemplateVariants; + $this->breakIncludeCycles = $breakIncludeCycles; } public function getNodeType(): string @@ -245,6 +250,9 @@ private function compileTemplates(array $templates, array &$errors, ?array &$alr ); } else { $includedTemplatePath = realpath($includedTemplatePath) ?: $includedTemplatePath; + if ($this->breakIncludeCycles && ($includedTemplatePath === $template->getPath() || in_array($includedTemplatePath, $template->getParentTemplatePaths(), true))) { + continue; // include cycle: contexts degrade and their number explodes + } $includeTemplate = new Template( $includedTemplatePath, $template->getActualClass(), From 8fb4675ccf17fa5d81605e81b9e2ed0a221710e4 Mon Sep 17 00:00:00 2001 From: Alexander Minkin Date: Tue, 6 Oct 2026 16:18:08 +0300 Subject: [PATCH 08/27] feat: add latte.resolveTypes to optionally skip the type resolution pass --- docs/features.md | 21 +++++++++++++++++++++ extension.neon | 3 +++ src/Compiler/Postprocessor.php | 13 ++++++++++++- 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/docs/features.md b/docs/features.md index e96d07df..7489eb09 100644 --- a/docs/features.md +++ b/docs/features.md @@ -55,3 +55,24 @@ parameters: features: analyseLayoutFiles: true ``` + +## resolveTypes + +- type: boolean +- default: true + +Compiled templates are type-resolved before analysis: the compiler runs PHPStan's +node scope resolver over the generated code and the result is used by +`ExprTypeNodeVisitors`. On projects with many template variants this pass is one +of the largest consumers of retained memory (compiled classes and reflection +caches cannot be released in PHP). + +Disabling the pass skips those visitors and saves memory at the cost of less +precise analysis inside templates: + +```neon +parameters: + latte: + resolveTypes: false +``` + diff --git a/extension.neon b/extension.neon index ddae7952..786f4aa3 100644 --- a/extension.neon +++ b/extension.neon @@ -33,6 +33,7 @@ parameters: reportUnanalysedTemplates: false maxTemplateVariants: 0 breakIncludeCycles: false + resolveTypes: true collectedPaths: [] templateTypes: [] features: @@ -67,6 +68,7 @@ parametersSchema: reportUnanalysedTemplates: bool() maxTemplateVariants: int() breakIncludeCycles: bool() + resolveTypes: bool() collectedPaths: arrayOf(string()) templateTypes: arrayOf(string()) features: structure([ @@ -124,6 +126,7 @@ services: factory: Efabrica\PHPStanLatte\Compiler\Postprocessor arguments: parser: @latteCurrentPhpVersionRichParser + resolveTypes: %latte.resolveTypes% - Efabrica\PHPStanLatte\Compiler\TypeToPhpDoc - Efabrica\PHPStanLatte\Compiler\Helper\TemplateContextHelper diff --git a/src/Compiler/Postprocessor.php b/src/Compiler/Postprocessor.php index bf6eb206..27c770c3 100644 --- a/src/Compiler/Postprocessor.php +++ b/src/Compiler/Postprocessor.php @@ -57,6 +57,8 @@ final class Postprocessor private NameResolver $nameResolver; + private bool $resolveTypes; + public function __construct( Parser $parser, NodeVisitorStorage $nodeVisitorStorage, @@ -64,7 +66,8 @@ public function __construct( CompilerInterface $compiler, ScopeFactory $scopeFactory, NodeScopeResolver $nodeScopeResolver, - NameResolver $nameResolver + NameResolver $nameResolver, + bool $resolveTypes = true ) { $this->parser = $parser; $this->nodeVisitorStorage = $nodeVisitorStorage; @@ -73,6 +76,7 @@ public function __construct( $this->scopeFactory = $scopeFactory; $this->nodeScopeResolver = $nodeScopeResolver; $this->nameResolver = $nameResolver; + $this->resolveTypes = $resolveTypes; } public function getCacheKey(): string @@ -104,6 +108,13 @@ public function postProcess(string $phpContent, Template $template, string $comp } require_once($compileFilePath); // load type definitions from compiled template + // Type resolution pass: making it optional trades analysis precision + // inside templates (ExprTypeNodeVisitors) for a significant amount of + // retained memory on projects with many template variants. + if (!$this->resolveTypes) { + return $realPath; + } + $scope = $this->scopeFactory->create(ScopeContext::create($realPath)); $phpStmts = $this->findNodes($phpContent, $compileFilePath); From 6dc37b25f6f02459a799a3e59f513a9982681939 Mon Sep 17 00:00:00 2001 From: Alexander Minkin Date: Fri, 9 Oct 2026 01:32:16 +0300 Subject: [PATCH 09/27] fix(phpstan): version-gate TemplateGenerator split and narrow layout preg_match - add LatteVersion::isLatte31() and use it instead of method_exists() which PHPStan constant-folds to true under Latte 3.1 - ignore the legacy TemplateGenerator::generate()/buildClass()/generateCode() calls for the Latte version that does not define them, following the existing cross-version ignoreErrors in phpstan.neon - narrow preg_match result in LayoutPathResolver with isset() so PHPStan stops reporting offsets on the inferred array shape --- phpstan.neon | 3 +++ src/Compiler/Compiler/Latte3Compiler.php | 4 ++-- src/Compiler/LatteVersion.php | 5 +++++ src/Resolver/LayoutResolver/LayoutPathResolver.php | 14 +++++++++----- 4 files changed, 19 insertions(+), 7 deletions(-) diff --git a/phpstan.neon b/phpstan.neon index 240bac69..9dd6c2c6 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -158,6 +158,9 @@ parameters: - '#^Call to an undefined method Latte\\Engine\:\:applyPasses\(\)\.$#' - '#^Instantiated class Latte\\Compiler\\TemplateGenerator not found\.$#' - '#^Call to method generate\(\) on an unknown class Latte\\Compiler\\TemplateGenerator\.$#' + - '#^Call to an undefined method Latte\\Compiler\\TemplateGenerator\:\:buildClass\(\)\.$#' + - '#^Call to an undefined method Latte\\Compiler\\TemplateGenerator\:\:generateCode\(\)\.$#' + - '#^Call to an undefined method Latte\\Compiler\\TemplateGenerator\:\:generate\(\)\.$#' - '#^Call to an undefined method Latte\\Engine\:\:getExtensions\(\)\.$#' - '#^Call to an undefined method Latte\\Engine\:\:getFunctions\(\)\.$#' - '#^Parameter \$extensions of method Efabrica\\PHPStanLatte\\Compiler\\Compiler\\Latte3Compiler\:\:installExtensions\(\) has invalid type Latte\\Extension\.$#' diff --git a/src/Compiler/Compiler/Latte3Compiler.php b/src/Compiler/Compiler/Latte3Compiler.php index 34a41fa2..6521edda 100644 --- a/src/Compiler/Compiler/Latte3Compiler.php +++ b/src/Compiler/Compiler/Latte3Compiler.php @@ -4,6 +4,7 @@ namespace Efabrica\PHPStanLatte\Compiler\Compiler; +use Efabrica\PHPStanLatte\Compiler\LatteVersion; use Latte\Compiler\TemplateGenerator; use Latte\Engine; use Latte\Essential\RawPhpExtension; @@ -19,7 +20,6 @@ use function is_array; use function is_object; use function md5; -use function method_exists; use function preg_replace; use function str_replace; @@ -90,7 +90,7 @@ public function compile(string $templateContent, ?string $actualClass, string $c $this->engine->applyPasses($templateNode); $className = $this->generateClassName(); $templateGenerator = new TemplateGenerator(); - if (method_exists($templateGenerator, 'buildClass')) { + if (LatteVersion::isLatte31()) { // Latte 3.1+ (TemplateGenerator::generate() was split into buildClass() + generateCode()) $templateGenerator->buildClass($templateNode); $phpContent = $templateGenerator->generateCode($className, null, $this->strictMode); diff --git a/src/Compiler/LatteVersion.php b/src/Compiler/LatteVersion.php index 7e62fd42..dc56fd35 100644 --- a/src/Compiler/LatteVersion.php +++ b/src/Compiler/LatteVersion.php @@ -17,4 +17,9 @@ public static function isLatte3(): bool { return Engine::VERSION_ID >= 30000; } + + public static function isLatte31(): bool + { + return Engine::VERSION_ID >= 30100; + } } diff --git a/src/Resolver/LayoutResolver/LayoutPathResolver.php b/src/Resolver/LayoutResolver/LayoutPathResolver.php index 007b3938..1cdab7d0 100644 --- a/src/Resolver/LayoutResolver/LayoutPathResolver.php +++ b/src/Resolver/LayoutResolver/LayoutPathResolver.php @@ -31,13 +31,17 @@ public function resolve(?string $templatePath): ?string } $templateContent = file_get_contents($templatePath) ?: ''; - preg_match( + $matches = []; + if (preg_match( '/\{(?:layout|extend|extends)\s+(?:[\'"](?[^\'"]+)[\'"]|(?[^\s,}]+))/', $templateContent, - $match - ); - - $layoutName = $match['quoted'] ?? $match['bare'] ?? null; + $matches + ) === 1) { + // "bare" is the only key PHP can leave unset (see preg_match named-group behavior) + $layoutName = isset($matches['bare']) ? $matches['bare'] : $matches['quoted']; + } else { + $layoutName = null; + } if ($layoutName !== null && !in_array($layoutName, ['none', 'auto'], true)) { $layoutFilePath = realpath(dirname($templatePath) . DIRECTORY_SEPARATOR . $layoutName) ?: null; if ($layoutFilePath !== null) { From ffd7d3a287dacaedc46ffe208d962c9ab88dff50 Mon Sep 17 00:00:00 2001 From: Alexander Minkin Date: Fri, 9 Oct 2026 01:42:43 +0300 Subject: [PATCH 10/27] fix(compiler): expand bare iterable types in generated filter/function PHPDoc Latte 3.1 registers built-in filters and functions as first-class callables, so their closure types (resolved by ClosureTypeFactory) now embed bare array/iterable/Traversable/Generator types. Those were emitted verbatim into the @var doc of the generated $__filters__/$__functions__ variables, making every compiled template report missingType errors at level 6+. Add TypeHelper::toPhpDocNode() which expands such bare iterables to explicit mixed type arguments before printing. Fixes 28 failing tests. --- .../NodeVisitor/ChangeFiltersNodeVisitor.php | 2 +- .../ChangeFunctionsNodeVisitor.php | 2 +- src/Type/TypeHelper.php | 48 +++++++++++++++++++ 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/Compiler/NodeVisitor/ChangeFiltersNodeVisitor.php b/src/Compiler/NodeVisitor/ChangeFiltersNodeVisitor.php index 047a8261..81920cd0 100644 --- a/src/Compiler/NodeVisitor/ChangeFiltersNodeVisitor.php +++ b/src/Compiler/NodeVisitor/ChangeFiltersNodeVisitor.php @@ -155,7 +155,7 @@ private function addFilterVariables(ClassMethod $node): void $variableType = $variableType->getStaticObjectType(); } - $arrayShapeItems[] = new ArrayShapeItemNode(new ConstExprStringNode($variable->getName(), ConstExprStringNode::SINGLE_QUOTED), $variable->mightBeUndefined(), $variableType->toPhpDocNode()); + $arrayShapeItems[] = new ArrayShapeItemNode(new ConstExprStringNode($variable->getName(), ConstExprStringNode::SINGLE_QUOTED), $variable->mightBeUndefined(), TypeHelper::toPhpDocNode($variableType)); } if ($arrayShapeItems === []) { diff --git a/src/Compiler/NodeVisitor/ChangeFunctionsNodeVisitor.php b/src/Compiler/NodeVisitor/ChangeFunctionsNodeVisitor.php index 6920eca2..a574fd57 100644 --- a/src/Compiler/NodeVisitor/ChangeFunctionsNodeVisitor.php +++ b/src/Compiler/NodeVisitor/ChangeFunctionsNodeVisitor.php @@ -164,7 +164,7 @@ private function addFunctionVariables(ClassMethod $node): void $variableType = $variableType->getStaticObjectType(); } - $arrayShapeItems[] = new ArrayShapeItemNode(new ConstExprStringNode($variable->getName(), ConstExprStringNode::SINGLE_QUOTED), $variable->mightBeUndefined(), $variableType->toPhpDocNode()); + $arrayShapeItems[] = new ArrayShapeItemNode(new ConstExprStringNode($variable->getName(), ConstExprStringNode::SINGLE_QUOTED), $variable->mightBeUndefined(), TypeHelper::toPhpDocNode($variableType)); } if ($arrayShapeItems === []) { diff --git a/src/Type/TypeHelper.php b/src/Type/TypeHelper.php index a417afc0..ce7e2fba 100644 --- a/src/Type/TypeHelper.php +++ b/src/Type/TypeHelper.php @@ -6,6 +6,12 @@ use InvalidArgumentException; use LogicException; +use PHPStan\PhpDocParser\Ast\AbstractNodeVisitor; +use PHPStan\PhpDocParser\Ast\Node; +use PHPStan\PhpDocParser\Ast\NodeTraverser; +use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode; +use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; +use PHPStan\PhpDocParser\Ast\Type\TypeNode; use PHPStan\PhpDocParser\Printer\Printer; use PHPStan\Reflection\ParametersAcceptor; use PHPStan\Type\ErrorType; @@ -14,6 +20,7 @@ use PHPStan\Type\StaticType; use PHPStan\Type\Type; use PHPStan\Type\TypeTraverser; +use function array_map; use function count; final class TypeHelper @@ -88,6 +95,47 @@ public static function getFirstParamTypeName(array $variants): ?string return $classNames[0] ?? null; } + /** + * PHPDoc node of the type with bare iterables expanded to explicit "mixed" type arguments. + * + * Latte 3.1 registers its built-in filters and functions as first-class callables, so their + * resolved closure types embed bare "array"/"iterable"/"Traversable"/"Generator" types which + * would trigger missingType errors in the generated templates. + */ + public static function toPhpDocNode(Type $type): TypeNode + { + $traverser = new NodeTraverser([ + new class extends AbstractNodeVisitor { + public function enterNode(Node $node): ?Node + { + if (!$node instanceof IdentifierTypeNode || $node->getAttribute('latteTypeExpanded')) { + return null; + } + + $typeNames = match ($node->name) { + 'array', 'iterable', 'Traversable', 'Iterator' => ['mixed', 'mixed'], + 'Generator' => ['mixed', 'mixed', 'mixed', 'mixed'], + default => null, + }; + if ($typeNames === null) { + return null; + } + + $identifier = new IdentifierTypeNode($node->name); + $identifier->setAttribute('latteTypeExpanded', true); + return new GenericTypeNode($identifier, array_map( + static fn (string $typeName): IdentifierTypeNode => new IdentifierTypeNode($typeName), + $typeNames + )); + } + }, + ]); + + /** @var TypeNode[] $nodes */ + $nodes = $traverser->traverse([$type->toPhpDocNode()]); + return $nodes[0]; + } + public static function serializeType(Type $type): string { $type = TypeTraverser::map($type, static function (Type $type, callable $traverse): Type { From c93007155c4c339a48796e6ca7564a1a004debcc Mon Sep 17 00:00:00 2001 From: Alexander Minkin Date: Fri, 9 Oct 2026 01:45:53 +0300 Subject: [PATCH 11/27] fix(stubs): restore HTML escape checks for Latte 3.1 HtmlHelpers Latte 3.1 compiles escaping to Latte\Runtime\HtmlHelpers::escapeText() whose $s parameter is mixed, so the FilterString checks from the old Latte\Runtime\Filters::escapeHtmlText() stub no longer fired. Stub HtmlHelpers::escapeText with the same FilterString type and teach EscapeErrorTransformer to translate its errors to the "Cannot convert X to HTML string." message. --- extension.neon | 1 + .../Transformer/EscapeErrorTransformer.php | 4 ++-- stubs/Latte/Runtime/HtmlHelpers.stub | 21 +++++++++++++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 stubs/Latte/Runtime/HtmlHelpers.stub diff --git a/extension.neon b/extension.neon index 786f4aa3..664e1fa7 100644 --- a/extension.neon +++ b/extension.neon @@ -5,6 +5,7 @@ parameters: - stubs/Latte/Essential/Filters.stub - stubs/Latte/Runtime/FilterInfo.stub - stubs/Latte/Runtime/Filters.stub + - stubs/Latte/Runtime/HtmlHelpers.stub - stubs/Latte/Runtime/Helpers.stub - stubs/Latte/Runtime/HtmlStringable.stub - stubs/Latte/Runtime/Html.stub diff --git a/src/Error/Transformer/EscapeErrorTransformer.php b/src/Error/Transformer/EscapeErrorTransformer.php index 9357cdbb..3953149b 100644 --- a/src/Error/Transformer/EscapeErrorTransformer.php +++ b/src/Error/Transformer/EscapeErrorTransformer.php @@ -10,13 +10,13 @@ final class EscapeErrorTransformer implements ErrorTransformerInterface { - private const HTML_OUTPUT_REGEX = '/Parameter #1 \$s of static method Latte\\\\(Runtime|Essentials)\\\\Filters::escape(?.*)\(\) expects [^ ]+, (?.*) given\./'; + private const HTML_OUTPUT_REGEX = '/Parameter #1 \$s of static method Latte\\\\(Runtime|Essentials)\\\\(?:Filters|HtmlHelpers)::escape(?.*)\(\) expects [^ ]+, (?.*) given\./'; public function transform(Error $error): Error { if (preg_match(self::HTML_OUTPUT_REGEX, $error->getMessage(), $match) === 1) { $escape = Strings::upper($match['escape']); - if ($escape === 'HTMLTEXT') { + if ($escape === 'HTMLTEXT' || $escape === 'TEXT') { $escape = 'HTML'; } $message = "Cannot convert {$match['type']} to {$escape} string."; diff --git a/stubs/Latte/Runtime/HtmlHelpers.stub b/stubs/Latte/Runtime/HtmlHelpers.stub new file mode 100644 index 00000000..0bfefc13 --- /dev/null +++ b/stubs/Latte/Runtime/HtmlHelpers.stub @@ -0,0 +1,21 @@ + Date: Fri, 9 Oct 2026 01:49:48 +0300 Subject: [PATCH 12/27] fix(phpstan): ignore unknown TemplateGenerator methods on Latte 2 runs --- phpstan.neon | 2 ++ 1 file changed, 2 insertions(+) diff --git a/phpstan.neon b/phpstan.neon index 9dd6c2c6..2a43c3b0 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -158,6 +158,8 @@ parameters: - '#^Call to an undefined method Latte\\Engine\:\:applyPasses\(\)\.$#' - '#^Instantiated class Latte\\Compiler\\TemplateGenerator not found\.$#' - '#^Call to method generate\(\) on an unknown class Latte\\Compiler\\TemplateGenerator\.$#' + - '#^Call to method buildClass\(\) on an unknown class Latte\\Compiler\\TemplateGenerator\.$#' + - '#^Call to method generateCode\(\) on an unknown class Latte\\Compiler\\TemplateGenerator\.$#' - '#^Call to an undefined method Latte\\Compiler\\TemplateGenerator\:\:buildClass\(\)\.$#' - '#^Call to an undefined method Latte\\Compiler\\TemplateGenerator\:\:generateCode\(\)\.$#' - '#^Call to an undefined method Latte\\Compiler\\TemplateGenerator\:\:generate\(\)\.$#' From 1b32cb99f2ac08659ecf5919ecebbd0c28aca4df Mon Sep 17 00:00:00 2001 From: Alexander Minkin Date: Fri, 9 Oct 2026 01:58:40 +0300 Subject: [PATCH 13/27] fix(compat): replace match expression with switch for PHP 7.4 support --- src/Type/TypeHelper.php | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/Type/TypeHelper.php b/src/Type/TypeHelper.php index ce7e2fba..f77d333c 100644 --- a/src/Type/TypeHelper.php +++ b/src/Type/TypeHelper.php @@ -112,11 +112,18 @@ public function enterNode(Node $node): ?Node return null; } - $typeNames = match ($node->name) { - 'array', 'iterable', 'Traversable', 'Iterator' => ['mixed', 'mixed'], - 'Generator' => ['mixed', 'mixed', 'mixed', 'mixed'], - default => null, - }; + $typeNames = null; + switch ($node->name) { + case 'array': + case 'iterable': + case 'Traversable': + case 'Iterator': + $typeNames = ['mixed', 'mixed']; + break; + case 'Generator': + $typeNames = ['mixed', 'mixed', 'mixed', 'mixed']; + break; + } if ($typeNames === null) { return null; } From 8f0ffc239e2b884a4cb4d3674f70e3e60362cd80 Mon Sep 17 00:00:00 2001 From: Alexander Minkin Date: Fri, 9 Oct 2026 02:17:47 +0300 Subject: [PATCH 14/27] fix(compiler): route Latte 3.1 first-class callable filters/functions through array callables Latte 3.1 registers built-in filters and functions as first-class callables (Closure objects) instead of [class, method] arrays. The visitors treated them as plain closures, so calls were generated on closure variables and the Latte stubs (e.g. stubs/Latte/Essential/Filters.stub) no longer applied. Detect closures that wrap a method via ReflectionFunction scope class and convert them back to [class, method], so static methods are called directly and stubs apply again. Latte 3.1 also adds native types to the built-in filters; PHPStan stubs can only narrow native types, so the FilterString stub union no longer applies to |lower/|upper/|capitalize and the native |slice return type (Generator) leaks into escape checks. Expectations for Latte 3.1 are adjusted accordingly, Latte 2 and 3.0 expectations are unchanged. --- .../NodeVisitor/ChangeFiltersNodeVisitor.php | 34 ++++++++++++++++- .../ChangeFunctionsNodeVisitor.php | 34 ++++++++++++++++- .../LatteTemplatesRuleForPresenterTest.php | 37 +++++++++++++++---- 3 files changed, 95 insertions(+), 10 deletions(-) diff --git a/src/Compiler/NodeVisitor/ChangeFiltersNodeVisitor.php b/src/Compiler/NodeVisitor/ChangeFiltersNodeVisitor.php index 81920cd0..a826d83d 100644 --- a/src/Compiler/NodeVisitor/ChangeFiltersNodeVisitor.php +++ b/src/Compiler/NodeVisitor/ChangeFiltersNodeVisitor.php @@ -46,6 +46,7 @@ use PHPStan\Type\ClosureTypeFactory; use PHPStan\Type\ObjectType; use PHPStan\Type\ThisType; +use ReflectionFunction; use function array_merge; use function explode; use function get_class; @@ -134,6 +135,29 @@ private function isCallableString($filter): bool return is_string($filter) && (str_starts_with($filter, 'Closure(') || str_starts_with($filter, '\Closure(') || str_starts_with($filter, 'callable(')); } + /** + * Latte 3.1+ registers filters as first-class callables (Closure objects) instead of + * array callables. Detect them and convert back to [class, method] so the underlying + * method is used (and stubs for Latte built-in filters apply again). + * + * @return array{string, string}|null + */ + private function resolveFirstClassCallable(Closure $filter): ?array + { + $reflection = new ReflectionFunction($filter); + $scopeClass = $reflection->getClosureScopeClass(); + if ($scopeClass === null) { + return null; + } + + $methodName = $reflection->getName(); + if ($methodName === '' || $methodName[0] === '{' || !$scopeClass->hasMethod($methodName)) { + return null; + } + + return [$scopeClass->getName(), $methodName]; + } + private function addFilterVariables(ClassMethod $node): void { $class = $node->getAttribute('parent'); @@ -182,6 +206,10 @@ private function getFilterVariables(): array { $variables = []; foreach ($this->filters as $filterName => $filter) { + if ($filter instanceof Closure) { + $filter = $this->resolveFirstClassCallable($filter) ?? $filter; + } + if ($this->isCallableString($filter)) { $variableName = $this->createFilterVariableName($filterName); /** @var string $filter */ @@ -203,7 +231,7 @@ private function getFilterVariables(): array $className = is_string($filter[0]) ? $filter[0] : get_class($filter[0]); $methodName = $filter[1]; - if (!is_string($methodName) || $methodName === '') { + if ($methodName === '') { continue; } @@ -239,6 +267,10 @@ private function createFilterCallNode(string $filterName, array $args): ?Node return null; } + if ($filter instanceof Closure) { + $filter = $this->resolveFirstClassCallable($filter) ?? $filter; + } + if ($filter instanceof Closure || $this->isCallableString($filter)) { if ($filter instanceof Closure) { $args = $this->updateArgs($this->closureTypeFactory->fromClosureObject($filter)->getCallableParametersAcceptors($this->getScope()), $args); diff --git a/src/Compiler/NodeVisitor/ChangeFunctionsNodeVisitor.php b/src/Compiler/NodeVisitor/ChangeFunctionsNodeVisitor.php index a574fd57..3901db78 100644 --- a/src/Compiler/NodeVisitor/ChangeFunctionsNodeVisitor.php +++ b/src/Compiler/NodeVisitor/ChangeFunctionsNodeVisitor.php @@ -47,6 +47,7 @@ use PHPStan\Type\ClosureTypeFactory; use PHPStan\Type\ObjectType; use PHPStan\Type\ThisType; +use ReflectionFunction; use function array_merge; use function array_slice; use function explode; @@ -143,6 +144,29 @@ private function isCallableString($function): bool return is_string($function) && (str_starts_with($function, 'Closure(') || str_starts_with($function, '\Closure(') || str_starts_with($function, 'callable(')); } + /** + * Latte 3.1+ registers functions as first-class callables (Closure objects) instead of + * array callables. Detect them and convert back to [class, method] so the underlying + * method is used (and stubs for Latte built-in functions apply again). + * + * @return array{string, string}|null + */ + private function resolveFirstClassCallable(Closure $function): ?array + { + $reflection = new ReflectionFunction($function); + $scopeClass = $reflection->getClosureScopeClass(); + if ($scopeClass === null) { + return null; + } + + $methodName = $reflection->getName(); + if ($methodName === '' || $methodName[0] === '{' || !$scopeClass->hasMethod($methodName)) { + return null; + } + + return [$scopeClass->getName(), $methodName]; + } + private function addFunctionVariables(ClassMethod $node): void { $class = $node->getAttribute('parent'); @@ -191,6 +215,10 @@ private function getFunctionVariables(): array { $variables = []; foreach ($this->functions as $functionName => $function) { + if ($function instanceof Closure) { + $function = $this->resolveFirstClassCallable($function) ?? $function; + } + if ($this->isCallableString($function)) { $variableName = $this->createFunctionVariableName($functionName); /** @var string $function */ @@ -212,7 +240,7 @@ private function getFunctionVariables(): array $className = is_string($function[0]) ? $function[0] : get_class($function[0]); $methodName = $function[1]; - if (!is_string($methodName) || $methodName === '') { + if ($methodName === '') { continue; } @@ -248,6 +276,10 @@ private function createFunctionCallNode(string $functionName, array $args): ?Nod return null; } + if ($function instanceof Closure) { + $function = $this->resolveFirstClassCallable($function) ?? $function; + } + if ($function instanceof Closure || $this->isCallableString($function)) { if ($function instanceof Closure) { $args = $this->updateArgs($this->closureTypeFactory->fromClosureObject($function)->getCallableParametersAcceptors($this->getScope()), $args); diff --git a/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterTest.php b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterTest.php index b221fd4b..dac1328e 100644 --- a/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterTest.php +++ b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterTest.php @@ -911,11 +911,6 @@ public function testFilters(): void 10, '@layout.latte', ], - [ - 'Cannot convert array|string to HTML string.', - 22, - 'default.latte', - ], ]; if (LatteVersion::isLatte3()) { @@ -924,21 +919,42 @@ public function testFilters(): void 2, 'translate_new.latte', ]; + if (LatteVersion::isLatte31()) { + // Latte 3.1 declares native types on built-in filters. PHPStan stub PHPDoc + // can only narrow native types, so the permissive FilterString stub no longer + // widens |lower/|upper/|capitalize, and the native |slice return type + // (including Generator) leaks into the escape checks. + $filterStringType = 'string|Stringable|null'; + $sliceReturnType = 'array|Generator|string'; + $expectedErrors[] = [ + 'Parameter #3 $s of static method Latte\Runtime\Helpers::convertTo() expects string, mixed given.', + 22, + 'default.latte', + ]; + } else { + $filterStringType = 'bool|float|int|string|Stringable|null'; + $sliceReturnType = 'array|string'; + } $expectedErrors[] = [ - 'Parameter #1 $s of static method Latte\Essential\Filters::lower() expects bool|float|int|string|Stringable|null, stdClass given.', + 'Parameter #1 $s of static method Latte\Essential\Filters::lower() expects ' . $filterStringType . ', stdClass given.', 19, 'default.latte', ]; $expectedErrors[] = [ - 'Parameter #1 $s of static method Latte\Essential\Filters::upper() expects bool|float|int|string|Stringable|null, stdClass given.', + 'Parameter #1 $s of static method Latte\Essential\Filters::upper() expects ' . $filterStringType . ', stdClass given.', 20, 'default.latte', ]; $expectedErrors[] = [ - 'Parameter #1 $s of static method Latte\Essential\Filters::capitalize() expects bool|float|int|string|Stringable|null, stdClass given.', + 'Parameter #1 $s of static method Latte\Essential\Filters::capitalize() expects ' . $filterStringType . ', stdClass given.', 21, 'default.latte', ]; + $expectedErrors[] = [ + 'Cannot convert ' . $sliceReturnType . ' to HTML string.', + 22, + 'default.latte', + ]; $expectedErrors[] = [ 'Parameter #1 $value of static method Latte\Essential\Filters::slice() expects array|string, string|null given.', 22, @@ -978,6 +994,11 @@ public function testFilters(): void 21, 'default.latte', ]; + $expectedErrors[] = [ + 'Cannot convert array|string to HTML string.', + 22, + 'default.latte', + ]; $expectedErrors[] = [ 'Parameter #1 $value of static method Latte\Runtime\Filters::slice() expects array|string, string|null given.', 22, From 6c09885b1df7be443e0a2e2cea669a60472aa417 Mon Sep 17 00:00:00 2001 From: Alexander Minkin Date: Fri, 9 Oct 2026 02:21:03 +0300 Subject: [PATCH 15/27] fix(phpstan): resolve level=max errors against Latte 3.1 and PHPStan 2.3 - use the correct Nette\Utils\FileSystem class name (Filesystem references) - cover list in the getExtensions() is_array ignore - drop now-redundant is_string()/is_array()/count() checks - align FakePresenterFactory::setFakeMapping() mapping shape with PresenterFactory::setMapping(); parent already validates the mapping, so the duplicated 2-element array branch was unreachable - drop ignores that no longer match and mark the get_class one reportUnmatched --- phpstan.neon | 7 ++----- src/Analyser/LatteContextAnalyser.php | 14 +++++++------- .../NodeVisitor/ChangeFiltersNodeVisitor.php | 2 +- .../NodeVisitor/ChangeFunctionsNodeVisitor.php | 2 +- src/LinkProcessor/FakePresenterFactory.php | 8 ++------ src/Temp/TempDirResolver.php | 4 ++-- 6 files changed, 15 insertions(+), 22 deletions(-) diff --git a/phpstan.neon b/phpstan.neon index 2a43c3b0..0fa98ba0 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -73,10 +73,6 @@ parameters: message: '#^Creating new PHPStan\\Reflection\\Native\\NativeParameterReflection is not covered by backward compatibility promise\. The class might change in a minor PHPStan version\.$#' path: src/Compiler/Compiler/AbstractCompiler.php - - - message: '#^Call to function is_array\(\) with array{0: string, 1: string, 2\?: string} will always evaluate to true\.$#' - path: src/LinkProcessor/FakePresenterFactory.php - - messages: - '#^Call to internal method Nette\\Application\\PresenterFactory::formatPresenterClass\(\) from outside its root namespace Nette\.$#' @@ -170,7 +166,7 @@ parameters: - '#^Parameter \#1 \$phpContent of method Efabrica\\PHPStanLatte\\Compiler\\Compiler\\Latte3Compiler::fixLines\(\) expects string, mixed given\.#' - '#^PHPDoc tag @var with type array is not subtype of type array\.#' - '#^Parameter \#1 \$object of function get_class expects object, mixed given\.#' - - '#^Call to function is_array\(\) with array will always evaluate to true\.#' + - '#^Call to function is_array\(\) with (array|list) will always evaluate to true\.#' - '#^Call to function is_object\(\) with Latte\\Extension will always evaluate to true\.#' path: src/Compiler/Compiler/Latte3Compiler.php reportUnmatched: false @@ -204,6 +200,7 @@ parameters: paths: - src/Compiler/NodeVisitor/ChangeFiltersNodeVisitor.php - src/Compiler/NodeVisitor/ChangeFunctionsNodeVisitor.php + reportUnmatched: false - # present only in nette/utils 4 message: '#^PHPDoc tag @var with type SplFileInfo is not subtype of native type Nette\\Utils\\FileInfo\.$#' diff --git a/src/Analyser/LatteContextAnalyser.php b/src/Analyser/LatteContextAnalyser.php index dd0b2e3b..59e03387 100644 --- a/src/Analyser/LatteContextAnalyser.php +++ b/src/Analyser/LatteContextAnalyser.php @@ -226,7 +226,7 @@ private function cacheFilename(string $file): string private function saveLatteContextDataToCache(string $file, LatteContextData $fileResult): void { if (!is_dir($this->tmpDir)) { - Filesystem::createDir($this->tmpDir, 0777); + FileSystem::createDir($this->tmpDir, 0777); } $cacheFile = $this->cacheFilename($file); @@ -243,16 +243,16 @@ private function saveLatteContextDataToCache(string $file, LatteContextData $fil $cacheData = [ 'file' => $file, - 'fileHash' => sha1(Filesystem::read($file)), + 'fileHash' => sha1(FileSystem::read($file)), 'data' => $data, ]; foreach ($fileResult->getRelatedFiles() as $relatedFile) { $cacheData['dependencies'][] = [ 'file' => $relatedFile, - 'fileHash' => sha1(Filesystem::read($relatedFile)), + 'fileHash' => sha1(FileSystem::read($relatedFile)), ]; } - Filesystem::write( + FileSystem::write( $cacheFile, Json::encode($cacheData, JSON_PRETTY_PRINT) ); @@ -266,7 +266,7 @@ private function loadLatteContextDataFromCache(string $file): ?LatteContextData } try { - $cacheData = Json::decode(Filesystem::read($cacheFile), JSON_OBJECT_AS_ARRAY); + $cacheData = Json::decode(FileSystem::read($cacheFile), JSON_OBJECT_AS_ARRAY); } catch (Exception $e) { FileSystem::delete($cacheFile); return null; @@ -286,7 +286,7 @@ private function loadLatteContextDataFromCache(string $file): ?LatteContextData } // Check if the file has changed since the cache was created - if (sha1(Filesystem::read($file)) !== $fileHash) { + if (sha1(FileSystem::read($file)) !== $fileHash) { return null; } @@ -304,7 +304,7 @@ private function loadLatteContextDataFromCache(string $file): ?LatteContextData return null; } // Check if the dependency file has changed since the cache was created - if (sha1(Filesystem::read($dependencyFile)) !== $dependencyFileHash) { + if (sha1(FileSystem::read($dependencyFile)) !== $dependencyFileHash) { return null; } } diff --git a/src/Compiler/NodeVisitor/ChangeFiltersNodeVisitor.php b/src/Compiler/NodeVisitor/ChangeFiltersNodeVisitor.php index a826d83d..e5a3a0ac 100644 --- a/src/Compiler/NodeVisitor/ChangeFiltersNodeVisitor.php +++ b/src/Compiler/NodeVisitor/ChangeFiltersNodeVisitor.php @@ -151,7 +151,7 @@ private function resolveFirstClassCallable(Closure $filter): ?array } $methodName = $reflection->getName(); - if ($methodName === '' || $methodName[0] === '{' || !$scopeClass->hasMethod($methodName)) { + if ($methodName[0] === '{' || !$scopeClass->hasMethod($methodName)) { return null; } diff --git a/src/Compiler/NodeVisitor/ChangeFunctionsNodeVisitor.php b/src/Compiler/NodeVisitor/ChangeFunctionsNodeVisitor.php index 3901db78..e52ee373 100644 --- a/src/Compiler/NodeVisitor/ChangeFunctionsNodeVisitor.php +++ b/src/Compiler/NodeVisitor/ChangeFunctionsNodeVisitor.php @@ -160,7 +160,7 @@ private function resolveFirstClassCallable(Closure $function): ?array } $methodName = $reflection->getName(); - if ($methodName === '' || $methodName[0] === '{' || !$scopeClass->hasMethod($methodName)) { + if ($methodName[0] === '{' || !$scopeClass->hasMethod($methodName)) { return null; } diff --git a/src/LinkProcessor/FakePresenterFactory.php b/src/LinkProcessor/FakePresenterFactory.php index 49f4f524..0426fef3 100644 --- a/src/LinkProcessor/FakePresenterFactory.php +++ b/src/LinkProcessor/FakePresenterFactory.php @@ -6,8 +6,6 @@ use Nette\Application\PresenterFactory; use Nette\InvalidStateException; -use function count; -use function is_array; use function is_string; use function preg_match; use function preg_replace; @@ -19,7 +17,7 @@ final class FakePresenterFactory extends PresenterFactory private array $mapping = []; /** - * @param array $mapping + * @param array $mapping */ public function setFakeMapping(array $mapping): FakePresenterFactory { @@ -31,10 +29,8 @@ public function setFakeMapping(array $mapping): FakePresenterFactory } $this->mapping[$module] = [$m[1], $m[2] ?: '*Module\\', $m[3]]; - } elseif (is_array($mask) && count($mask) === 3) { - $this->mapping[$module] = [$mask[0] ? $mask[0] . '\\' : '', $mask[1] . '\\', $mask[2]]; } else { - throw new InvalidStateException("Invalid mapping mask for module $module."); + $this->mapping[$module] = [$mask[0] ? $mask[0] . '\\' : '', $mask[1] . '\\', $mask[2]]; } } diff --git a/src/Temp/TempDirResolver.php b/src/Temp/TempDirResolver.php index a7d24999..adc43c33 100644 --- a/src/Temp/TempDirResolver.php +++ b/src/Temp/TempDirResolver.php @@ -39,7 +39,7 @@ public function __construct(?string $tmpDir) } if (!is_dir($tmpDir)) { - Filesystem::createDir($tmpDir); + FileSystem::createDir($tmpDir); } $tmpDir = realpath($tmpDir) ?: $tmpDir; if (!is_writable($tmpDir)) { @@ -71,7 +71,7 @@ private function pruneDir(string $tmpDir): void ); /** @var SplFileInfo $file */ foreach ($ri as $file) { - Filesystem::delete($file->getPathname()); + FileSystem::delete($file->getPathname()); } } From 2a32ed00a86aaecb51168748b6af5c0440b12d4c Mon Sep 17 00:00:00 2001 From: Alexander Minkin Date: Fri, 9 Oct 2026 02:41:41 +0300 Subject: [PATCH 16/27] test: align expectations with PHPStan 2.3 and nette/forms 3.3 PHPStan 2.3 reports new dead-code, repeated-assignment, implicit-array and coalesce errors on generated template code, and nette/forms 3.3 changed FormsLatte\Runtime::get() to return Control|Container, which made generated control lookups lose their BaseControl type. - add a Runtime::get() stub with a conditional return type so control lookups keep resolving to BaseControl (Container lookups are unaffected) - update Variables/Forms/Links/NoMapping fixture expectations for the new PHPStan 2.3 errors; the separated-phpstan-command variant does not report them, so they are gated with usesSeparatedPhpStanCommand() - gate the Latte 3.1-only slice() Generator expectations with isLatte31() Latte 2 and 3.0 expectations are preserved. --- stubs/Nette/Bridges/FormsLatte/Runtime.stub | 9 + .../LatteTemplatesRuleForPresenterTest.php | 619 +++++++++++++++++- ...latesRuleForPresenterWithNoMappingTest.php | 30 +- ...MappingWithSeparatedPHPStanCommandTest.php | 5 + 4 files changed, 659 insertions(+), 4 deletions(-) diff --git a/stubs/Nette/Bridges/FormsLatte/Runtime.stub b/stubs/Nette/Bridges/FormsLatte/Runtime.stub index 819dec17..b6e95547 100644 --- a/stubs/Nette/Bridges/FormsLatte/Runtime.stub +++ b/stubs/Nette/Bridges/FormsLatte/Runtime.stub @@ -13,4 +13,13 @@ class Runtime public static function item($item, $global): object { } + + /** + * @param \Nette\Forms\Control|\Nette\Forms\Container|string|int $item + * @param string $type + * @return ($type is 'Nette\Forms\Container' ? \Nette\Forms\Container : \Nette\Forms\Controls\BaseControl) + */ + public function get($item, $type = \Nette\Forms\Control::class) + { + } } diff --git a/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterTest.php b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterTest.php index dac1328e..82dc12a6 100644 --- a/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterTest.php +++ b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterTest.php @@ -26,6 +26,12 @@ protected static function additionalConfigFiles(): array public function testVariables(): void { + // Latte 3.1 declares a native return type on Filters::slice, which PHPStan cannot + // narrow with the stub's conditional return type anymore. + $sliceArrayType = LatteVersion::isLatte31() + ? "Dumped type: array<'bar'|'baz'|'foo'>|Generator" + : "Dumped type: array<'bar'|'baz'|'foo'>"; + $sliceStringType = LatteVersion::isLatte31() ? 'Dumped type: Generator|string' : 'Dumped type: string'; $this->analyse([__DIR__ . '/Fixtures/VariablesPresenter.php'], [ [ 'Undefined variable: $items', @@ -163,12 +169,12 @@ public function testVariables(): void 'default.latte', ], [ - "Dumped type: array<'bar'|'baz'|'foo'>", // False positive - TODO create DynamicMethodReturnTypeExtension for Latte\Runtime\Filters::slice - inspire in ArraySliceFunctionReturnTypeExtension and SubstrDynamicReturnTypeExtension + $sliceArrayType, // False positive - TODO create DynamicMethodReturnTypeExtension for Latte\Runtime\Filters::slice - inspire in ArraySliceFunctionReturnTypeExtension and SubstrDynamicReturnTypeExtension 92, 'default.latte', ], [ - 'Dumped type: string', + $sliceStringType, 94, 'default.latte', ], @@ -532,6 +538,72 @@ public function testVariables(): void 7, '@layoutOther.latte', ], + // PHPStan 2.3 dead-code and implicit-array checks on generated template code + [ + 'Value assigned to variable $someOtherVariableWithDefault is never read.', + 2, + 'default.latte', + ], + [ + 'Value assigned to variable $iterator is never read before being overwritten.', + 5, + 'default.latte', + ], + [ + 'Implicit array creation is not allowed - variable $ʟ_tags might not exist.', + 6, + 'default.latte', + ], + [ + 'Value assigned to variable $iterator is never read before being overwritten.', + 7, + 'default.latte', + ], + [ + 'Coalesce operator ?? is unnecessary because the left side is always set and the right side is null.', + 26, + 'default.latte', + ], + [ + 'Coalesce operator ?? is unnecessary because the left side is always set and the right side is null.', + 32, + 'default.latte', + ], + [ + 'Value assigned to variable $iterator is never read before being overwritten.', + 42, + 'default.latte', + ], + [ + 'Coalesce operator ?? is unnecessary because the left side is always set and the right side is null.', + 50, + 'default.latte', + ], + [ + 'Value assigned to variable $iterator is never read.', + 58, + 'default.latte', + ], + [ + 'Value assigned to variable $ʟ_it is never read.', + 58, + 'default.latte', + ], + [ + 'Implicit array creation is not allowed - variable $ʟ_tags might not exist.', + 100, + 'default.latte', + ], + [ + 'Value assigned to variable $headerVar is never read.', + 3, + 'other.latte', + ], + [ + 'Value assigned to variable $ʟ_switch is never read before being overwritten.', + 20, + 'specialConstructs.latte', + ], ]); } @@ -669,6 +741,533 @@ public function testComponents(): void public function testForms(): void { + if (LatteVersion::isLatte31()) { + // Latte 3.1 + nette/forms 3.3 + PHPStan 2.3 output. The expectations below + // are kept for Latte 2/3.0 runs. + $this->analyse([__DIR__ . '/Fixtures/FormsPresenter.php'], [ + [ + 'Undefined variable: $title', + 7, + '@layout.latte', + ], + [ + 'Undefined variable: $title', + 7, + '@layout.latte', + ], + [ + 'Undefined variable: $neverDefined', + 10, + '@layout.latte', + ], + [ + 'Undefined variable: $neverDefined', + 10, + '@layout.latte', + ], + [ + 'Value assigned to variable $form is never read before being overwritten.', + 1, + 'default.latte', + ], + [ + 'Value assigned to variable $ʟ_elem is never read before being overwritten.', + 2, + 'default.latte', + ], + [ + 'Value assigned to variable $form is never read before being overwritten.', + 8, + 'default.latte', + ], + [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 101, + 'default.latte', + ], + [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 102, + 'default.latte', + ], + [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 105, + 'default.latte', + ], + [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 106, + 'default.latte', + ], + [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 109, + 'default.latte', + ], + [ + 'Value assigned to variable $ʟ_label is never read before being overwritten.', + 10, + 'default.latte', + ], + [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 110, + 'default.latte', + ], + [ + 'Value assigned to variable $form is never read before being overwritten.', + 114, + 'default.latte', + ], + [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 116, + 'default.latte', + ], + [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 117, + 'default.latte', + ], + [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 119, + 'default.latte', + ], + [ + 'Method Nette\\Forms\\Controls\\BaseControl::getControlPart() invoked with 1 parameter, 0 required.', + 11, + 'default.latte', + ], + [ + 'Method Nette\\Forms\\Controls\\BaseControl::getLabelPart() invoked with 1 parameter, 0 required.', + 11, + 'default.latte', + ], + [ + 'Value assigned to variable $ʟ_label is never read before being overwritten.', + 11, + 'default.latte', + ], + [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 120, + 'default.latte', + ], + [ + 'Parameter #1 $control of method Nette\\Forms\\Rendering\\DefaultFormRenderer::renderPair() expects Nette\\Forms\\Control, Nette\\ComponentModel\\IComponent given.', + 127, + 'default.latte', + ], + [ + 'Parameter #1 $control of method Nette\\Forms\\Rendering\\DefaultFormRenderer::renderPair() expects Nette\\Forms\\Control, Nette\\ComponentModel\\IComponent given.', + 128, + 'default.latte', + ], + [ + 'Parameter #1 $control of method Efabrica\\PHPStanLatte\\Tests\\Rule\\LatteTemplatesRule\\PresenterWithoutModule\\Source\\CustomFormRenderer::someCustomMethod() expects Nette\\Forms\\Control, Nette\\ComponentModel\\IComponent given.', + 129, + 'default.latte', + ], + [ + 'Value assigned to variable $ʟ_elem is never read before being overwritten.', + 12, + 'default.latte', + ], + [ + 'Value assigned to variable $ʟ_elem is never read.', + 12, + 'default.latte', + ], + [ + 'Value assigned to variable $ʟ_label is never read before being overwritten.', + 12, + 'default.latte', + ], + [ + 'Call to an undefined method Efabrica\\PHPStanLatte\\Tests\\Rule\\LatteTemplatesRule\\PresenterWithoutModule\\Source\\CustomFormRenderer::someNonExistingCustomMethod().', + 130, + 'default.latte', + ], + [ + 'Method Nette\\Forms\\Controls\\BaseControl::getControlPart() invoked with 1 parameter, 0 required.', + 134, + 'default.latte', + ], + [ + 'Method Nette\\Forms\\Controls\\BaseControl::getLabelPart() invoked with 1 parameter, 0 required.', + 134, + 'default.latte', + ], + [ + 'Value assigned to variable $ʟ_label is never read before being overwritten.', + 134, + 'default.latte', + ], + [ + 'Method Nette\\Forms\\Controls\\BaseControl::getControlPart() invoked with 1 parameter, 0 required.', + 135, + 'default.latte', + ], + [ + 'Method Nette\\Forms\\Controls\\BaseControl::getLabelPart() invoked with 1 parameter, 0 required.', + 135, + 'default.latte', + ], + [ + 'Value assigned to variable $ʟ_label is never read before being overwritten.', + 135, + 'default.latte', + ], + [ + 'Method Nette\\Forms\\Controls\\BaseControl::getControlPart() invoked with 1 parameter, 0 required.', + 136, + 'default.latte', + ], + [ + 'Method Nette\\Forms\\Controls\\BaseControl::getLabelPart() invoked with 1 parameter, 0 required.', + 136, + 'default.latte', + ], + [ + 'Value assigned to variable $ʟ_label is never read before being overwritten.', + 136, + 'default.latte', + ], + [ + 'Method Nette\\Forms\\Controls\\BaseControl::getControlPart() invoked with 1 parameter, 0 required.', + 137, + 'default.latte', + ], + [ + 'Method Nette\\Forms\\Controls\\BaseControl::getLabelPart() invoked with 1 parameter, 0 required.', + 137, + 'default.latte', + ], + [ + 'Value assigned to variable $ʟ_label is never read before being overwritten.', + 137, + 'default.latte', + ], + [ + 'Method Nette\\Forms\\Controls\\BaseControl::getControlPart() invoked with 1 parameter, 0 required.', + 138, + 'default.latte', + ], + [ + 'Method Nette\\Forms\\Controls\\BaseControl::getLabelPart() invoked with 1 parameter, 0 required.', + 138, + 'default.latte', + ], + [ + 'Value assigned to variable $ʟ_label is never read before being overwritten.', + 138, + 'default.latte', + ], + [ + 'Method Nette\\Forms\\Controls\\BaseControl::getControlPart() invoked with 1 parameter, 0 required.', + 139, + 'default.latte', + ], + [ + 'Method Nette\\Forms\\Controls\\BaseControl::getLabelPart() invoked with 1 parameter, 0 required.', + 139, + 'default.latte', + ], + [ + 'Value assigned to variable $ʟ_label is never read before being overwritten.', + 139, + 'default.latte', + ], + [ + 'Value assigned to variable $ʟ_label is never read before being overwritten.', + 13, + 'default.latte', + ], + [ + 'Method Nette\\Forms\\Controls\\BaseControl::getControlPart() invoked with 1 parameter, 0 required.', + 140, + 'default.latte', + ], + [ + 'Method Nette\\Forms\\Controls\\BaseControl::getLabelPart() invoked with 1 parameter, 0 required.', + 140, + 'default.latte', + ], + [ + 'Value assigned to variable $ʟ_label is never read before being overwritten.', + 140, + 'default.latte', + ], + [ + 'Method Nette\\Forms\\Controls\\BaseControl::getControlPart() invoked with 1 parameter, 0 required.', + 141, + 'default.latte', + ], + [ + 'Method Nette\\Forms\\Controls\\BaseControl::getLabelPart() invoked with 1 parameter, 0 required.', + 141, + 'default.latte', + ], + [ + 'Value assigned to variable $ʟ_label is never read.', + 141, + 'default.latte', + ], + [ + 'Dumped type: Nette\\ComponentModel\\IComponent', + 142, + 'default.latte', + ], + [ + 'Parameter #1 $item of method Nette\\Bridges\\FormsLatte\\Runtime::get() expects int|Nette\\Forms\\Container|Nette\\Forms\\Control|string, mixed given.', + 143, + 'default.latte', + ], + [ + 'Dumped type: Nette\\ComponentModel\\IComponent', + 144, + 'default.latte', + ], + [ + 'Parameter #1 $item of method Nette\\Bridges\\FormsLatte\\Runtime::get() expects int|Nette\\Forms\\Container|Nette\\Forms\\Control|string, mixed given.', + 145, + 'default.latte', + ], + [ + 'Parameter #1 $item of method Nette\\Bridges\\FormsLatte\\Runtime::get() expects int|Nette\\Forms\\Container|Nette\\Forms\\Control|string, mixed given.', + 146, + 'default.latte', + ], + [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 146, + 'default.latte', + ], + [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 148, + 'default.latte', + ], + [ + 'Value assigned to variable $form is never read.', + 153, + 'default.latte', + ], + [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 154, + 'default.latte', + ], + [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 156, + 'default.latte', + ], + [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 159, + 'default.latte', + ], + [ + 'Value assigned to variable $ʟ_label is never read before being overwritten.', + 15, + 'default.latte', + ], + [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 161, + 'default.latte', + ], + [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 164, + 'default.latte', + ], + [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 166, + 'default.latte', + ], + [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 169, + 'default.latte', + ], + [ + 'Value assigned to variable $formContainer is never read.', + 171, + 'default.latte', + ], + [ + 'Method Nette\\Forms\\Rendering\\DefaultFormRenderer::renderControls() invoked with 2 parameters, 1 required.', + 19, + 'default.latte', + ], + [ + 'Parameter #1 $parent of method Nette\\Forms\\Rendering\\DefaultFormRenderer::renderControls() expects Nette\\Forms\\Container|Nette\\Forms\\ControlGroup, Nette\\Forms\\ControlGroup|null given.', + 19, + 'default.latte', + ], + [ + 'Parameter #1 $parent of method Nette\\Forms\\Rendering\\DefaultFormRenderer::renderControls() expects Nette\\Forms\\Container|Nette\\Forms\\ControlGroup, Nette\\Forms\\ControlGroup|null given.', + 20, + 'default.latte', + ], + [ + 'Parameter #1 $control of method Nette\\Forms\\Rendering\\DefaultFormRenderer::renderPair() expects Nette\\Forms\\Control, Nette\\ComponentModel\\IComponent given.', + 21, + 'default.latte', + ], + [ + 'Parameter #1 $control of method Nette\\Forms\\Rendering\\DefaultFormRenderer::renderPair() expects Nette\\Forms\\Control, Nette\\ComponentModel\\IComponent given.', + 22, + 'default.latte', + ], + [ + 'Parameter #1 $control of method Efabrica\\PHPStanLatte\\Tests\\Rule\\LatteTemplatesRule\\PresenterWithoutModule\\Source\\CustomFormRenderer::someCustomMethod() expects Nette\\Forms\\Control, Nette\\ComponentModel\\IComponent given.', + 23, + 'default.latte', + ], + [ + 'Call to an undefined method Efabrica\\PHPStanLatte\\Tests\\Rule\\LatteTemplatesRule\\PresenterWithoutModule\\Source\\CustomFormRenderer::someNonExistingCustomMethod().', + 24, + 'default.latte', + ], + [ + 'Value assigned to variable $form is never read before being overwritten.', + 27, + 'default.latte', + ], + [ + 'Value assigned to variable $form is never read before being overwritten.', + 34, + 'default.latte', + ], + [ + 'Value assigned to variable $form is never read before being overwritten.', + 41, + 'default.latte', + ], + [ + 'Parameter #1 $form of method Nette\\Bridges\\FormsLatte\\Runtime::begin() expects Nette\\Forms\\Container, mixed given.', + 48, + 'default.latte', + ], + [ + 'Value assigned to variable $form is never read before being overwritten.', + 48, + 'default.latte', + ], + [ + 'Value assigned to variable $ʟ_label is never read before being overwritten.', + 49, + 'default.latte', + ], + [ + 'Value assigned to variable $ʟ_label is never read before being overwritten.', + 54, + 'default.latte', + ], + [ + 'Parameter #1 $item of method Nette\\Bridges\\FormsLatte\\Runtime::get() expects int|Nette\\Forms\\Container|Nette\\Forms\\Control|string, Nette\\ComponentModel\\IComponent given.', + 56, + 'default.latte', + ], + [ + 'Cannot access offset \'text1\' on Nette\\ComponentModel\\IComponent.', + 60, + 'default.latte', + ], + [ + 'Cannot call method getHtmlId() on mixed.', + 60, + 'default.latte', + ], + [ + 'Value assigned to variable $form is never read before being overwritten.', + 70, + 'default.latte', + ], + [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 71, + 'default.latte', + ], + [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 72, + 'default.latte', + ], + [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 75, + 'default.latte', + ], + [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 76, + 'default.latte', + ], + [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 79, + 'default.latte', + ], + [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 80, + 'default.latte', + ], + [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 83, + 'default.latte', + ], + [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 84, + 'default.latte', + ], + [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 87, + 'default.latte', + ], + [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 88, + 'default.latte', + ], + [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 91, + 'default.latte', + ], + [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 92, + 'default.latte', + ], + [ + 'Value assigned to variable $form is never read before being overwritten.', + 96, + 'default.latte', + ], + [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 97, + 'default.latte', + ], + [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 98, + 'default.latte', + ], + ]); + return; + } $expectedErrors = [ [ 'Form control with name "password" probably does not exist.', @@ -1023,6 +1622,22 @@ public function testLinks(): void 7, 'default.latte', ], + // PHPStan 2.3 dead-code and repeated-assignment checks on generated template code + [ + 'Variable $ʟ_tmp is assigned value array{aa: 1, bb: 1} but it already has that value.', + 7, + 'default.latte', + ], + [ + 'Variable $ʟ_tmp is assigned value array{aa: 1, bb: 1} but it already has that value.', + 101, + 'default.latte', + ], + [ + 'Value assigned to variable $ʟ_fi is never read.', + 102, + 'default.latte', + ], [ 'Method ' . LinksPresenter::class . '::actionCreate() invoked with 1 parameter, 0 required.', 8, diff --git a/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterWithNoMappingTest.php b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterWithNoMappingTest.php index 243b4320..9ef7b0f6 100644 --- a/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterWithNoMappingTest.php +++ b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterWithNoMappingTest.php @@ -22,7 +22,7 @@ protected static function additionalConfigFiles(): array public function testLinks(): void { // Without mapping only handle links are created, no other errors should be found - $this->analyse([__DIR__ . '/Fixtures/LinksPresenter.php'], [ + $expectedErrors = [ [ 'Parameter #1 $id of method ' . LinksPresenter::class . '::handleDelete() expects string, null given.', 97, @@ -108,6 +108,32 @@ public function testLinks(): void 10, '@layout.latte', ], - ]); + ]; + if (!$this->usesSeparatedPhpStanCommand()) { + // PHPStan 2.3 dead-code and repeated-assignment checks on generated template code; + // the separated phpstan command does not report them. + $expectedErrors[] = [ + 'Variable $ʟ_tmp is assigned value array{aa: 1, bb: 1} but it already has that value.', + 7, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Variable $ʟ_tmp is assigned value array{aa: 1, bb: 1} but it already has that value.', + 101, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value assigned to variable $ʟ_fi is never read.', + 102, + 'default.latte', + ]; + } + + $this->analyse([__DIR__ . '/Fixtures/LinksPresenter.php'], $expectedErrors); + } + + protected function usesSeparatedPhpStanCommand(): bool + { + return false; } } diff --git a/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterWithNoMappingWithSeparatedPHPStanCommandTest.php b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterWithNoMappingWithSeparatedPHPStanCommandTest.php index deebd93d..b2ac2a5f 100644 --- a/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterWithNoMappingWithSeparatedPHPStanCommandTest.php +++ b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterWithNoMappingWithSeparatedPHPStanCommandTest.php @@ -14,4 +14,9 @@ protected static function additionalConfigFiles(): array __DIR__ . '/phpstanCommand.neon', ]); } + + protected function usesSeparatedPhpStanCommand(): bool + { + return true; + } } From 0d83dfdfecd16f98c3a777fa1bab2c60e1a407c8 Mon Sep 17 00:00:00 2001 From: Alexander Minkin Date: Fri, 9 Oct 2026 03:00:29 +0300 Subject: [PATCH 17/27] fix(phpstan): polyfill str_contains/str_starts_with for PHP 7.4 runs PHP 7.4 CI installs Latte 2.11.x and analyses with phpVersion 70400, where the PHP 8 string helpers used across the collectors and node visitors are unknown symbols. Require symfony/polyfill-php80 so the functions are discovered on PHP 7.4 too. Fixes 26 'Function str_contains/str_starts_with not found.' errors in the PHPStan (normal) and PHPStan (lowest) matrix jobs. --- composer.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 95db1626..d492e044 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,8 @@ "phpstan/phpstan-nette": "^2.0.7", "latte/latte": "^2.11.6 | ^3.0.25", "nette/utils": "^3.2|^4.1", - "nette/finder": "^2.5|^3.0" + "nette/finder": "^2.5|^3.0", + "symfony/polyfill-php80": "^1.16" }, "require-dev": { "phpunit/phpunit": "^9.6.31", From 0cca6518fd9391b75febdcd988d445fbc7b7ec04 Mon Sep 17 00:00:00 2001 From: Alexander Minkin Date: Fri, 9 Oct 2026 03:00:29 +0300 Subject: [PATCH 18/27] fix(phpstan): narrow mixed values under Latte <3.1 and PHPStan 2.1 On Latte 3.0/2.11 TemplateGenerator::generateCode() is unknown, so its return value is mixed; assert string before str_replace(). On PHPStan 2.1 ClassReflection::getMethod() received mixed from filter/function array callables; add an is_string() guard (ignored as always-true on PHPStan 2.3 where the phpdoc already narrows). Fixes Latte3Compiler.php:100 and ChangeFilters/ChangeFunctionsNodeVisitor getMethod() errors in the PHPStan (normal) PHP 8.0/8.1 and PHPStan (lowest) jobs. --- phpstan.neon | 7 +++++++ src/Compiler/Compiler/Latte3Compiler.php | 1 + src/Compiler/NodeVisitor/ChangeFiltersNodeVisitor.php | 2 +- src/Compiler/NodeVisitor/ChangeFunctionsNodeVisitor.php | 2 +- 4 files changed, 10 insertions(+), 2 deletions(-) diff --git a/phpstan.neon b/phpstan.neon index 0fa98ba0..71dc72c0 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -201,6 +201,13 @@ parameters: - src/Compiler/NodeVisitor/ChangeFiltersNodeVisitor.php - src/Compiler/NodeVisitor/ChangeFunctionsNodeVisitor.php reportUnmatched: false + # The guard is needed on PHPStan 2.1 / Latte < 3.1 where $filter[1] infers as mixed + - + message: '#^Call to function is_string\(\) with string will always evaluate to true\.$#' + paths: + - src/Compiler/NodeVisitor/ChangeFiltersNodeVisitor.php + - src/Compiler/NodeVisitor/ChangeFunctionsNodeVisitor.php + reportUnmatched: false - # present only in nette/utils 4 message: '#^PHPDoc tag @var with type SplFileInfo is not subtype of native type Nette\\Utils\\FileInfo\.$#' diff --git a/src/Compiler/Compiler/Latte3Compiler.php b/src/Compiler/Compiler/Latte3Compiler.php index 6521edda..19f3bad8 100644 --- a/src/Compiler/Compiler/Latte3Compiler.php +++ b/src/Compiler/Compiler/Latte3Compiler.php @@ -93,6 +93,7 @@ public function compile(string $templateContent, ?string $actualClass, string $c if (LatteVersion::isLatte31()) { // Latte 3.1+ (TemplateGenerator::generate() was split into buildClass() + generateCode()) $templateGenerator->buildClass($templateNode); + /** @var string $phpContent */ $phpContent = $templateGenerator->generateCode($className, null, $this->strictMode); $phpContent = str_replace( "final class {$className} extends", diff --git a/src/Compiler/NodeVisitor/ChangeFiltersNodeVisitor.php b/src/Compiler/NodeVisitor/ChangeFiltersNodeVisitor.php index e5a3a0ac..9f3e289e 100644 --- a/src/Compiler/NodeVisitor/ChangeFiltersNodeVisitor.php +++ b/src/Compiler/NodeVisitor/ChangeFiltersNodeVisitor.php @@ -231,7 +231,7 @@ private function getFilterVariables(): array $className = is_string($filter[0]) ? $filter[0] : get_class($filter[0]); $methodName = $filter[1]; - if ($methodName === '') { + if (!is_string($methodName) || $methodName === '') { continue; } diff --git a/src/Compiler/NodeVisitor/ChangeFunctionsNodeVisitor.php b/src/Compiler/NodeVisitor/ChangeFunctionsNodeVisitor.php index e52ee373..5842041a 100644 --- a/src/Compiler/NodeVisitor/ChangeFunctionsNodeVisitor.php +++ b/src/Compiler/NodeVisitor/ChangeFunctionsNodeVisitor.php @@ -240,7 +240,7 @@ private function getFunctionVariables(): array $className = is_string($function[0]) ? $function[0] : get_class($function[0]); $methodName = $function[1]; - if ($methodName === '') { + if (!is_string($methodName) || $methodName === '') { continue; } From 97c330453b7ce9ef755e0f6c952e820e5fe59fea Mon Sep 17 00:00:00 2001 From: Alexander Minkin Date: Fri, 9 Oct 2026 03:38:42 +0300 Subject: [PATCH 19/27] test: version-gate expectations for the dependency matrix The PHPunit matrix resolves different dependencies per PHP version: PHPStan 2.1.33 (lowest), Latte 2.11.x (PHP 7.4, lowest <= 8.3), Latte 3.0.x (PHP 8.0/8.1, lowest 8.4/8.5), nette/forms 3.1.x (PHP <= 8.1), 3.2.x (PHP 8.2) and 3.3.x (PHP 8.3+). - DependencyVersion detects PHPStan >= 2.3 via UnusedVariableRule (inside RuleTestCase the PHPStan phar ships its own Composer\InstalledVersions without package versions) and nette/forms >= 3.3 via the removal of FormsLatte\Runtime::item() - testVariables/testComponents/testSnippets/testLinks: gate PHPStan 2.3-only dead-code errors and add the Latte 2-only $iterations errors - testForms: add a Latte 3.1 + nette/forms 3.2 branch (item() still used there) and the nette/forms 3.1 extras for Latte 2/3.0 Verified locally on all four dependency shapes (Latte 2.11.7/nf 3.1.15/ PHPStan 2.3.1, Latte 3.0.26/nf 3.1.15/PHPStan 2.3.1, Latte 3.1.6/nf 3.2.9/ PHPStan 2.3.1, Latte 3.1.6/nf 3.3.0/PHPStan 2.3.1) plus Latte 2.11.7/ PHPStan 2.1.33: 82/82 tests pass. --- tests/DependencyVersion.php | 32 + .../LatteTemplatesRuleForPresenterTest.php | 929 ++++++++++++++++-- ...latesRuleForPresenterWithNoMappingTest.php | 3 +- 3 files changed, 854 insertions(+), 110 deletions(-) create mode 100644 tests/DependencyVersion.php diff --git a/tests/DependencyVersion.php b/tests/DependencyVersion.php new file mode 100644 index 00000000..49576e7a --- /dev/null +++ b/tests/DependencyVersion.php @@ -0,0 +1,32 @@ +|Generator" : "Dumped type: array<'bar'|'baz'|'foo'>"; $sliceStringType = LatteVersion::isLatte31() ? 'Dumped type: Generator|string' : 'Dumped type: string'; - $this->analyse([__DIR__ . '/Fixtures/VariablesPresenter.php'], [ + $expectedErrors = [ [ 'Undefined variable: $items', 5, @@ -538,78 +539,167 @@ public function testVariables(): void 7, '@layoutOther.latte', ], + ]; + if (DependencyVersion::phpstanAtLeast23()) { // PHPStan 2.3 dead-code and implicit-array checks on generated template code - [ - 'Value assigned to variable $someOtherVariableWithDefault is never read.', - 2, - 'default.latte', - ], - [ + if (!LatteVersion::isLatte2()) { + $expectedErrors[] = [ + 'Value assigned to variable $someOtherVariableWithDefault is never read.', + 2, + 'default.latte', + ]; + } + $expectedErrors[] = [ 'Value assigned to variable $iterator is never read before being overwritten.', 5, 'default.latte', - ], - [ - 'Implicit array creation is not allowed - variable $ʟ_tags might not exist.', - 6, - 'default.latte', - ], - [ + ]; + if (LatteVersion::isLatte31()) { + $expectedErrors[] = [ + 'Implicit array creation is not allowed - variable $ʟ_tags might not exist.', + 6, + 'default.latte', + ]; + } + $expectedErrors[] = [ 'Value assigned to variable $iterator is never read before being overwritten.', 7, 'default.latte', - ], - [ + ]; + $expectedErrors[] = [ 'Coalesce operator ?? is unnecessary because the left side is always set and the right side is null.', 26, 'default.latte', - ], - [ + ]; + $expectedErrors[] = [ 'Coalesce operator ?? is unnecessary because the left side is always set and the right side is null.', 32, 'default.latte', - ], - [ + ]; + $expectedErrors[] = [ 'Value assigned to variable $iterator is never read before being overwritten.', 42, 'default.latte', - ], - [ + ]; + $expectedErrors[] = [ 'Coalesce operator ?? is unnecessary because the left side is always set and the right side is null.', 50, 'default.latte', - ], - [ + ]; + $expectedErrors[] = [ 'Value assigned to variable $iterator is never read.', 58, 'default.latte', - ], - [ + ]; + $expectedErrors[] = [ 'Value assigned to variable $ʟ_it is never read.', 58, 'default.latte', - ], - [ - 'Implicit array creation is not allowed - variable $ʟ_tags might not exist.', - 100, - 'default.latte', - ], - [ - 'Value assigned to variable $headerVar is never read.', - 3, - 'other.latte', - ], - [ + ]; + if (LatteVersion::isLatte31()) { + $expectedErrors[] = [ + 'Implicit array creation is not allowed - variable $ʟ_tags might not exist.', + 100, + 'default.latte', + ]; + } + if (!LatteVersion::isLatte2()) { + $expectedErrors[] = [ + 'Value assigned to variable $headerVar is never read.', + 3, + 'other.latte', + ]; + } + $expectedErrors[] = [ 'Value assigned to variable $ʟ_switch is never read before being overwritten.', 20, 'specialConstructs.latte', - ], - ]); + ]; + if (LatteVersion::isLatte2()) { + // Latte 2 compiled foreach code triggers additional PHPStan 2.3 dead-code checks + $expectedErrors[] = [ + 'Value assigned to variable $iterations only flows into values that are never used.', + 4, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value assigned to variable $iterator is never read before being overwritten.', + 6, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value of variable $iterations after ++ only flows into values that are never used.', + 6, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value assigned to variable $iterations only flows into values that are never used.', + 21, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value assigned to variable $iterations is never read before being overwritten.', + 23, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value of variable $iterations after ++ only flows into values that are never used.', + 23, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value of variable $iterations after ++ is never read before being overwritten.', + 42, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value assigned to variable $iterations only flows into values that are never used.', + 45, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value assigned to variable $iterations only flows into values that are never used.', + 47, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value of variable $iterations after ++ only flows into values that are never used.', + 47, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value of variable $iterations after ++ only flows into values that are never used.', + 58, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value assigned to variable $iterations only flows into values that are never used.', + 1, + 'specialConstructs.latte', + ]; + $expectedErrors[] = [ + 'Value assigned to variable $iterations only flows into values that are never used.', + 20, + 'specialConstructs.latte', + ]; + $expectedErrors[] = [ + 'Value of variable $iterations after ++ only flows into values that are never used.', + 20, + 'specialConstructs.latte', + ]; + $expectedErrors[] = [ + 'Value of variable $iterations after ++ only flows into values that are never used.', + 27, + 'specialConstructs.latte', + ]; + } + } + $this->analyse([__DIR__ . '/Fixtures/VariablesPresenter.php'], $expectedErrors); } public function testComponents(): void { - $this->analyse([__DIR__ . '/Fixtures/ComponentsPresenter.php'], [ + $expectedErrors = [ [ 'Component with name "onlyCreateForm" probably doesn\'t exist.', 9, @@ -736,12 +826,36 @@ public function testComponents(): void 10, '@layout.latte', ], - ]); + ]; + if (DependencyVersion::phpstanAtLeast23() && LatteVersion::isLatte2()) { + // Latte 2 compiled foreach code triggers additional PHPStan 2.3 dead-code checks + $expectedErrors[] = [ + 'Value assigned to variable $iterations only flows into values that are never used.', + 29, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value of variable $iterations after ++ only flows into values that are never used.', + 31, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value assigned to variable $iterations only flows into values that are never used.', + 36, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value of variable $iterations after ++ only flows into values that are never used.', + 38, + 'default.latte', + ]; + } + $this->analyse([__DIR__ . '/Fixtures/ComponentsPresenter.php'], $expectedErrors); } public function testForms(): void { - if (LatteVersion::isLatte31()) { + if (LatteVersion::isLatte31() && DependencyVersion::netteFormsAtLeast33()) { // Latte 3.1 + nette/forms 3.3 + PHPStan 2.3 output. The expectations below // are kept for Latte 2/3.0 runs. $this->analyse([__DIR__ . '/Fixtures/FormsPresenter.php'], [ @@ -1268,126 +1382,488 @@ public function testForms(): void ]); return; } - $expectedErrors = [ + if (LatteVersion::isLatte31()) { + // Latte 3.1 + nette/forms 3.2 output; nette/forms 3.2 still calls FormsLatte\Runtime::item() + $this->analyse([__DIR__ . '/Fixtures/FormsPresenter.php'], [ + [ + 'Undefined variable: $title', + 7, + '@layout.latte', + ], + [ + 'Undefined variable: $title', + 7, + '@layout.latte', + ], + [ + 'Undefined variable: $neverDefined', + 10, + '@layout.latte', + ], + [ + 'Undefined variable: $neverDefined', + 10, + '@layout.latte', + ], + [ + 'Value assigned to variable $ʟ_elem is never read before being overwritten.', + 2, + 'default.latte', + ], [ 'Form control with name "password" probably does not exist.', 4, 'default.latte', ], [ - 'Method Nette\Forms\Controls\BaseControl::getControlPart() invoked with 1 parameter, 0 required.', - 11, + 'Value assigned to variable $formContainer is never read before being overwritten.', + 101, 'default.latte', ], [ - 'Method Nette\Forms\Controls\BaseControl::getLabelPart() invoked with 1 parameter, 0 required.', - 11, + 'Value assigned to variable $formContainer is never read before being overwritten.', + 102, 'default.latte', ], [ - 'Form control with name "second_submit" probably does not exist.', - 15, + 'Form control with name "1" probably does not exist.', + 105, 'default.latte', ], [ - 'Form control with name "second_submit_label" probably does not exist.', - 15, + 'Value assigned to variable $formContainer is never read before being overwritten.', + 106, 'default.latte', ], [ - 'Method Nette\Forms\Rendering\DefaultFormRenderer::renderControls() invoked with 2 parameters, 1 required.', - 19, + 'Form control with name "1" probably does not exist.', + 109, 'default.latte', ], [ - 'Parameter #1 $parent of method Nette\Forms\Rendering\DefaultFormRenderer::renderControls() expects Nette\Forms\Container|Nette\Forms\ControlGroup, null given.', - 20, + 'Value assigned to variable $ʟ_label is never read before being overwritten.', + 10, 'default.latte', ], [ - 'Call to an undefined method ' . CustomFormRenderer::class . '::someNonExistingCustomMethod().', - 24, + 'Value assigned to variable $formContainer is never read before being overwritten.', + 110, 'default.latte', ], [ - 'Form with name "notExisting" probably does not exist.', - LatteVersion::isLatte3() ? 48 : 45, + 'Value assigned to variable $formContainer is never read before being overwritten.', + 116, 'default.latte', ], [ - 'Form control with name "username" probably does not exist.', - 49, + 'Value assigned to variable $formContainer is never read before being overwritten.', + 117, 'default.latte', ], [ - 'Form control with name "5" probably does not exist.', - 87, + 'Value assigned to variable $formContainer is never read before being overwritten.', + 119, 'default.latte', ], [ - 'Form control with name "5" probably does not exist.', - 91, + 'Method Nette\\Forms\\Controls\\BaseControl::getControlPart() invoked with 1 parameter, 0 required.', + 11, 'default.latte', ], [ - 'Form control with name "1" probably does not exist.', - 105, + 'Method Nette\\Forms\\Controls\\BaseControl::getLabelPart() invoked with 1 parameter, 0 required.', + 11, 'default.latte', ], [ - 'Form control with name "1" probably does not exist.', - 109, + 'Value assigned to variable $ʟ_label is never read before being overwritten.', + 11, 'default.latte', ], [ - 'Call to an undefined method ' . CustomFormRenderer::class . '::someNonExistingCustomMethod().', - 130, + 'Value assigned to variable $formContainer is never read before being overwritten.', + 120, 'default.latte', ], [ - 'Option "item4" for control "checkbox_list" probably doesn\'t exist.', - 137, + 'Value assigned to variable $ʟ_elem is never read before being overwritten.', + 12, 'default.latte', ], [ - 'Option "4" for control "radio_list" probably doesn\'t exist.', - 141, + 'Value assigned to variable $ʟ_elem is never read.', + 12, 'default.latte', ], [ - 'Dumped type: Nette\Forms\Controls\TextInput', - 142, + 'Value assigned to variable $ʟ_label is never read before being overwritten.', + 12, 'default.latte', ], [ - 'Dumped type: Nette\Forms\Controls\TextInput', - 144, + 'Call to an undefined method Efabrica\\PHPStanLatte\\Tests\\Rule\\LatteTemplatesRule\\PresenterWithoutModule\\Source\\CustomFormRenderer::someNonExistingCustomMethod().', + 130, 'default.latte', ], [ - 'Form control with name "2" probably does not exist.', - 164, + 'Value assigned to variable $ʟ_label is never read before being overwritten.', + 134, 'default.latte', ], [ - 'Form control with name "10" probably does not exist.', - 169, + 'Value assigned to variable $ʟ_label is never read before being overwritten.', + 135, 'default.latte', ], [ - 'Undefined variable: $title', - 7, - '@layout.latte', + 'Value assigned to variable $ʟ_label is never read before being overwritten.', + 136, + 'default.latte', ], [ - 'Undefined variable: $title', - 7, - '@layout.latte', + 'Option "item4" for control "checkbox_list" probably doesn\'t exist.', + 137, + 'default.latte', ], [ - 'Undefined variable: $neverDefined', - 10, - '@layout.latte', + 'Value assigned to variable $ʟ_label is never read before being overwritten.', + 137, + 'default.latte', + ], + [ + 'Value assigned to variable $ʟ_label is never read before being overwritten.', + 138, + 'default.latte', + ], + [ + 'Value assigned to variable $ʟ_label is never read before being overwritten.', + 139, + 'default.latte', + ], + [ + 'Value assigned to variable $ʟ_label is never read before being overwritten.', + 13, + 'default.latte', + ], + [ + 'Value assigned to variable $ʟ_label is never read before being overwritten.', + 140, + 'default.latte', + ], + [ + 'Option "4" for control "radio_list" probably doesn\'t exist.', + 141, + 'default.latte', + ], + [ + 'Value assigned to variable $ʟ_label is never read.', + 141, + 'default.latte', + ], + [ + 'Dumped type: Nette\\Forms\\Controls\\TextInput', + 142, + 'default.latte', + ], + [ + 'Parameter #1 $item of static method Nette\\Bridges\\FormsLatte\\Runtime::item() expects int|object|string, mixed given.', + 142, + 'default.latte', + ], + [ + 'Unable to resolve the template type T in call to static method Nette\\Bridges\\FormsLatte\\Runtime::item()', + 142, + 'default.latte', + 'See: https://phpstan.org/blog/solving-phpstan-error-unable-to-resolve-template-type', + ], + [ + 'Dumped type: Nette\\Forms\\Controls\\TextInput', + 144, + 'default.latte', + ], + [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 146, + 'default.latte', + ], + [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 148, + 'default.latte', + ], + [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 154, + 'default.latte', + ], + [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 156, + 'default.latte', + ], + [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 159, + 'default.latte', + ], + [ + 'Form control with name "second_submit" probably does not exist.', + 15, + 'default.latte', + ], + [ + 'Form control with name "second_submit_label" probably does not exist.', + 15, + 'default.latte', + ], + [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 161, + 'default.latte', + ], + [ + 'Form control with name "2" probably does not exist.', + 164, + 'default.latte', + ], + [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 166, + 'default.latte', + ], + [ + 'Form control with name "10" probably does not exist.', + 169, + 'default.latte', + ], + [ + 'Value assigned to variable $formContainer is never read.', + 171, + 'default.latte', + ], + [ + 'Method Nette\\Forms\\Rendering\\DefaultFormRenderer::renderControls() invoked with 2 parameters, 1 required.', + 19, + 'default.latte', + ], + [ + 'Parameter #1 $parent of method Nette\\Forms\\Rendering\\DefaultFormRenderer::renderControls() expects Nette\\Forms\\Container|Nette\\Forms\\ControlGroup, null given.', + 20, + 'default.latte', + ], + [ + 'Call to an undefined method Efabrica\\PHPStanLatte\\Tests\\Rule\\LatteTemplatesRule\\PresenterWithoutModule\\Source\\CustomFormRenderer::someNonExistingCustomMethod().', + 24, + 'default.latte', + ], + [ + 'Form with name "notExisting" probably does not exist.', + 48, + 'default.latte', + ], + [ + 'Form control with name "username" probably does not exist.', + 49, + 'default.latte', + ], + [ + 'PHPDoc tag @var with type Nette\\Forms\\Controls\\BaseControl is not subtype of type Nette\\Forms\\Controls\\TextArea.', + 55, + 'default.latte', + ], + [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 71, + 'default.latte', + ], + [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 72, + 'default.latte', + ], + [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 75, + 'default.latte', + ], + [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 76, + 'default.latte', + ], + [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 79, + 'default.latte', + ], + [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 80, + 'default.latte', + ], + [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 83, + 'default.latte', + ], + [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 84, + 'default.latte', + ], + [ + 'Form control with name "5" probably does not exist.', + 87, + 'default.latte', + ], + [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 88, + 'default.latte', + ], + [ + 'Form control with name "5" probably does not exist.', + 91, + 'default.latte', + ], + [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 92, + 'default.latte', + ], + [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 97, + 'default.latte', + ], + [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 98, + 'default.latte', + ], + ]); + return; + } + $expectedErrors = [ + [ + 'Form control with name "password" probably does not exist.', + 4, + 'default.latte', + ], + [ + 'Method Nette\Forms\Controls\BaseControl::getControlPart() invoked with 1 parameter, 0 required.', + 11, + 'default.latte', + ], + [ + 'Method Nette\Forms\Controls\BaseControl::getLabelPart() invoked with 1 parameter, 0 required.', + 11, + 'default.latte', + ], + [ + 'Form control with name "second_submit" probably does not exist.', + 15, + 'default.latte', + ], + [ + 'Form control with name "second_submit_label" probably does not exist.', + 15, + 'default.latte', + ], + [ + 'Method Nette\Forms\Rendering\DefaultFormRenderer::renderControls() invoked with 2 parameters, 1 required.', + 19, + 'default.latte', + ], + [ + 'Parameter #1 $parent of method Nette\Forms\Rendering\DefaultFormRenderer::renderControls() expects Nette\Forms\Container|Nette\Forms\ControlGroup, null given.', + 20, + 'default.latte', + ], + [ + 'Call to an undefined method ' . CustomFormRenderer::class . '::someNonExistingCustomMethod().', + 24, + 'default.latte', + ], + [ + 'Form with name "notExisting" probably does not exist.', + LatteVersion::isLatte3() ? 48 : 45, + 'default.latte', + ], + [ + 'Form control with name "username" probably does not exist.', + 49, + 'default.latte', + ], + [ + 'Form control with name "5" probably does not exist.', + 87, + 'default.latte', + ], + [ + 'Form control with name "5" probably does not exist.', + 91, + 'default.latte', + ], + [ + 'Form control with name "1" probably does not exist.', + 105, + 'default.latte', + ], + [ + 'Form control with name "1" probably does not exist.', + 109, + 'default.latte', + ], + [ + 'Call to an undefined method ' . CustomFormRenderer::class . '::someNonExistingCustomMethod().', + 130, + 'default.latte', + ], + [ + 'Option "item4" for control "checkbox_list" probably doesn\'t exist.', + 137, + 'default.latte', + ], + [ + 'Option "4" for control "radio_list" probably doesn\'t exist.', + 141, + 'default.latte', + ], + [ + 'Dumped type: Nette\Forms\Controls\TextInput', + 142, + 'default.latte', + ], + [ + 'Dumped type: Nette\Forms\Controls\TextInput', + 144, + 'default.latte', + ], + [ + 'Form control with name "2" probably does not exist.', + 164, + 'default.latte', + ], + [ + 'Form control with name "10" probably does not exist.', + 169, + 'default.latte', + ], + [ + 'Undefined variable: $title', + 7, + '@layout.latte', + ], + [ + 'Undefined variable: $title', + 7, + '@layout.latte', + ], + [ + 'Undefined variable: $neverDefined', + 10, + '@layout.latte', ], [ 'Undefined variable: $neverDefined', @@ -1402,6 +1878,226 @@ public function testForms(): void 'default.latte', ]; } + if (DependencyVersion::phpstanAtLeast23()) { + // PHPStan 2.3 dead-code checks on nette/forms 3.1 generated template code + $expectedErrors[] = [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 71, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 72, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 75, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 76, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 79, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 80, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 83, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 84, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 88, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 92, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 97, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 98, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 101, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 102, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 106, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 110, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 116, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 117, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 119, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 120, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 146, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 148, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 154, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 156, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 159, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 161, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 166, + 'default.latte', + ]; + if (LatteVersion::isLatte3()) { + $expectedErrors[] = [ + 'Value assigned to variable $ʟ_elem is never read before being overwritten.', + 2, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value assigned to variable $ʟ_label is never read before being overwritten.', + 10, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value assigned to variable $ʟ_label is never read before being overwritten.', + 11, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value assigned to variable $ʟ_elem is never read before being overwritten.', + 12, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value assigned to variable $ʟ_elem is never read.', + 12, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value assigned to variable $ʟ_label is never read before being overwritten.', + 12, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value assigned to variable $ʟ_label is never read before being overwritten.', + 13, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value assigned to variable $ʟ_label is never read before being overwritten.', + 134, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value assigned to variable $ʟ_label is never read before being overwritten.', + 135, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value assigned to variable $ʟ_label is never read before being overwritten.', + 136, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value assigned to variable $ʟ_label is never read before being overwritten.', + 137, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value assigned to variable $ʟ_label is never read before being overwritten.', + 138, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value assigned to variable $ʟ_label is never read before being overwritten.', + 139, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value assigned to variable $ʟ_label is never read before being overwritten.', + 140, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value assigned to variable $ʟ_label is never read.', + 141, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value assigned to variable $formContainer is never read.', + 171, + 'default.latte', + ]; + } + } $this->analyse([__DIR__ . '/Fixtures/FormsPresenter.php'], $expectedErrors); } @@ -1622,22 +2318,6 @@ public function testLinks(): void 7, 'default.latte', ], - // PHPStan 2.3 dead-code and repeated-assignment checks on generated template code - [ - 'Variable $ʟ_tmp is assigned value array{aa: 1, bb: 1} but it already has that value.', - 7, - 'default.latte', - ], - [ - 'Variable $ʟ_tmp is assigned value array{aa: 1, bb: 1} but it already has that value.', - 101, - 'default.latte', - ], - [ - 'Value assigned to variable $ʟ_fi is never read.', - 102, - 'default.latte', - ], [ 'Method ' . LinksPresenter::class . '::actionCreate() invoked with 1 parameter, 0 required.', 8, @@ -1821,6 +2501,24 @@ public function testLinks(): void '@layout.latte', ], ]; + if (DependencyVersion::phpstanAtLeast23()) { + // PHPStan 2.3 dead-code and repeated-assignment checks on generated template code + $expectedErrors[] = [ + 'Variable $ʟ_tmp is assigned value array{aa: 1, bb: 1} but it already has that value.', + 7, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Variable $ʟ_tmp is assigned value array{aa: 1, bb: 1} but it already has that value.', + 101, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value assigned to variable $ʟ_fi is never read.', + 102, + 'default.latte', + ]; + } $this->analyse([__DIR__ . '/Fixtures/LinksPresenter.php'], $expectedErrors); } @@ -2380,6 +3078,19 @@ public function testSnippets(): void 'compileError.latte', ]; } + if (DependencyVersion::phpstanAtLeast23() && LatteVersion::isLatte2()) { + // Latte 2 compiled foreach code triggers additional PHPStan 2.3 dead-code checks + $expectedErrors[] = [ + 'Value assigned to variable $iterations only flows into values that are never used.', + 3, + 'default.latte', + ]; + $expectedErrors[] = [ + 'Value of variable $iterations after ++ only flows into values that are never used.', + 5, + 'default.latte', + ]; + } $this->analyse([__DIR__ . '/Fixtures/SnippetsPresenter.php'], $expectedErrors); } } diff --git a/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterWithNoMappingTest.php b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterWithNoMappingTest.php index 9ef7b0f6..5f2f76af 100644 --- a/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterWithNoMappingTest.php +++ b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterWithNoMappingTest.php @@ -4,6 +4,7 @@ namespace Efabrica\PHPStanLatte\Tests\Rule\LatteTemplatesRule\PresenterWithoutModule; +use Efabrica\PHPStanLatte\Tests\DependencyVersion; use Efabrica\PHPStanLatte\Tests\Rule\LatteTemplatesRule\LatteTemplatesRuleTest; use Efabrica\PHPStanLatte\Tests\Rule\LatteTemplatesRule\PresenterWithoutModule\Fixtures\LinksPresenter; @@ -109,7 +110,7 @@ public function testLinks(): void '@layout.latte', ], ]; - if (!$this->usesSeparatedPhpStanCommand()) { + if (DependencyVersion::phpstanAtLeast23() && !$this->usesSeparatedPhpStanCommand()) { // PHPStan 2.3 dead-code and repeated-assignment checks on generated template code; // the separated phpstan command does not report them. $expectedErrors[] = [ From b92ab8cc90a04d4e01bd1504b2fee482e7a92f7f Mon Sep 17 00:00:00 2001 From: Alexander Minkin Date: Thu, 22 Oct 2026 20:08:23 +0300 Subject: [PATCH 20/27] fix(type): expand only bare iterables in filter/function PHPDoc TypeHelper::toPhpDocNode() replaced the base identifier of already generic types (e.g. "array" in "array"), which threw a TypeError inside NodeTraverser and aborted analysis of every compiled template. Expand bare iterables only and leave typed generics untouched. Resolve first-class callables of instance methods via the bound object class so the most derived implementation is analysed, as with array callables. Add TypeHelper unit tests and an inherited first-class callable filter fixture that fails without the fix. --- .../NodeVisitor/ChangeFiltersNodeVisitor.php | 7 ++- .../ChangeFunctionsNodeVisitor.php | 7 ++- src/Type/TypeHelper.php | 8 +++ .../FirstClassCallableFilter/default.latte | 1 + .../ParentFirstClassCallableFilters.php | 13 +++++ ...ine.bootstrap-firstClassCallableFilter.php | 11 +++- tests/Type/TypeHelperTest.php | 55 +++++++++++++++++++ 7 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 tests/Rule/LatteTemplatesRule/EngineBootstrap/Source/ParentFirstClassCallableFilters.php create mode 100644 tests/Type/TypeHelperTest.php diff --git a/src/Compiler/NodeVisitor/ChangeFiltersNodeVisitor.php b/src/Compiler/NodeVisitor/ChangeFiltersNodeVisitor.php index 9f3e289e..d625a472 100644 --- a/src/Compiler/NodeVisitor/ChangeFiltersNodeVisitor.php +++ b/src/Compiler/NodeVisitor/ChangeFiltersNodeVisitor.php @@ -155,7 +155,12 @@ private function resolveFirstClassCallable(Closure $filter): ?array return null; } - return [$scopeClass->getName(), $methodName]; + // An instance method callable keeps the object it was created on: use its class + // so the most derived implementation is analysed, as with array callables. + $closureThis = $reflection->getClosureThis(); + $className = $closureThis !== null ? get_class($closureThis) : $scopeClass->getName(); + + return [$className, $methodName]; } private function addFilterVariables(ClassMethod $node): void diff --git a/src/Compiler/NodeVisitor/ChangeFunctionsNodeVisitor.php b/src/Compiler/NodeVisitor/ChangeFunctionsNodeVisitor.php index 5842041a..ba93af37 100644 --- a/src/Compiler/NodeVisitor/ChangeFunctionsNodeVisitor.php +++ b/src/Compiler/NodeVisitor/ChangeFunctionsNodeVisitor.php @@ -164,7 +164,12 @@ private function resolveFirstClassCallable(Closure $function): ?array return null; } - return [$scopeClass->getName(), $methodName]; + // An instance method callable keeps the object it was created on: use its class + // so the most derived implementation is analysed, as with array callables. + $closureThis = $reflection->getClosureThis(); + $className = $closureThis !== null ? get_class($closureThis) : $scopeClass->getName(); + + return [$className, $methodName]; } private function addFunctionVariables(ClassMethod $node): void diff --git a/src/Type/TypeHelper.php b/src/Type/TypeHelper.php index f77d333c..9eb669f8 100644 --- a/src/Type/TypeHelper.php +++ b/src/Type/TypeHelper.php @@ -108,6 +108,14 @@ public static function toPhpDocNode(Type $type): TypeNode new class extends AbstractNodeVisitor { public function enterNode(Node $node): ?Node { + // The base of an already generic type (e.g. "array" in "array") is + // not bare and lives in GenericTypeNode::$type, which is natively typed as + // IdentifierTypeNode - it cannot be replaced by a GenericTypeNode. + if ($node instanceof GenericTypeNode) { + $node->type->setAttribute('latteTypeExpanded', true); + return null; + } + if (!$node instanceof IdentifierTypeNode || $node->getAttribute('latteTypeExpanded')) { return null; } diff --git a/tests/Rule/LatteTemplatesRule/EngineBootstrap/Fixtures/templates/FirstClassCallableFilter/default.latte b/tests/Rule/LatteTemplatesRule/EngineBootstrap/Fixtures/templates/FirstClassCallableFilter/default.latte index 9cedb45e..28d666ae 100644 --- a/tests/Rule/LatteTemplatesRule/EngineBootstrap/Fixtures/templates/FirstClassCallableFilter/default.latte +++ b/tests/Rule/LatteTemplatesRule/EngineBootstrap/Fixtures/templates/FirstClassCallableFilter/default.latte @@ -1 +1,2 @@ {$title|objectFilterFirstClassCallable} +{$title|inheritedFilterFirstClassCallable} diff --git a/tests/Rule/LatteTemplatesRule/EngineBootstrap/Source/ParentFirstClassCallableFilters.php b/tests/Rule/LatteTemplatesRule/EngineBootstrap/Source/ParentFirstClassCallableFilters.php new file mode 100644 index 00000000..43d6cc33 --- /dev/null +++ b/tests/Rule/LatteTemplatesRule/EngineBootstrap/Source/ParentFirstClassCallableFilters.php @@ -0,0 +1,13 @@ +objectFilter('filter'); + } } $engine = new Engine(); $engine->addFilter('objectFilterFirstClassCallable', (new FirstClassCallableFilters())->objectFilter(...)); +$engine->addFilter('inheritedFilterFirstClassCallable', (new FirstClassCallableFilters())->inheritedFilter(...)); return $engine; diff --git a/tests/Type/TypeHelperTest.php b/tests/Type/TypeHelperTest.php new file mode 100644 index 00000000..88d744d5 --- /dev/null +++ b/tests/Type/TypeHelperTest.php @@ -0,0 +1,55 @@ +', (string)TypeHelper::toPhpDocNode(new ArrayType(new MixedType(), new MixedType()))); + self::assertSame('iterable', (string)TypeHelper::toPhpDocNode(new IterableType(new MixedType(), new MixedType()))); + self::assertSame('Generator', (string)TypeHelper::toPhpDocNode(new ObjectType('Generator'))); + } + + public function testKeepsTypedGenericsUntouched(): void + { + self::assertSame('array', (string)TypeHelper::toPhpDocNode(new ArrayType(new IntegerType(), new StringType()))); + self::assertSame('array', (string)TypeHelper::toPhpDocNode(new ArrayType(new MixedType(), new StringType()))); + } + + public function testExpandsBareIterablesInsideTypedGenerics(): void + { + $bare = new ArrayType(new MixedType(), new MixedType()); + self::assertSame('array>', (string)TypeHelper::toPhpDocNode(new ArrayType(new IntegerType(), $bare))); + } + + public function testKeepsCallableParameterGenericsUntouched(): void + { + $callable = new CallableType([ + new NativeParameterReflection('', false, new ArrayType(new IntegerType(), new StringType()), PassedByReference::createNo(), false, null), + ], new StringType()); + self::assertSame('callable(array): string', (string)TypeHelper::toPhpDocNode($callable)); + } + + public function testExpandsBareIterablesInCallableParameters(): void + { + $callable = new CallableType([ + new NativeParameterReflection('', false, new ArrayType(new MixedType(), new MixedType()), PassedByReference::createNo(), false, null), + ], new StringType()); + self::assertSame('callable(array): string', (string)TypeHelper::toPhpDocNode($callable)); + } +} From a48e9e4d1a00f1498c02483001cafbfb7492a690 Mon Sep 17 00:00:00 2001 From: Alexander Minkin Date: Tue, 27 Oct 2026 23:09:06 +0300 Subject: [PATCH 21/27] fix(compiler): keep generated class comment on Latte 3.0.27 Latte 3.0.27 normalizes multi-line template names to null, so passing the generated class comment as TemplateGenerator::generate()'s template name dropped the class docblock and left $this->global typed as mixed in every compiled template. Pass null and inject the comment before the class declaration for both the 3.0 and 3.1 generator APIs, matching the previous 3.0 output. Also keep the first-class callable fixture parseable by PHP 7.4 (PHPDoc @return static instead of the native return type). --- src/Compiler/Compiler/Latte3Compiler.php | 20 +++++++++---------- .../ParentFirstClassCallableFilters.php | 5 ++++- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/Compiler/Compiler/Latte3Compiler.php b/src/Compiler/Compiler/Latte3Compiler.php index 19f3bad8..23f68506 100644 --- a/src/Compiler/Compiler/Latte3Compiler.php +++ b/src/Compiler/Compiler/Latte3Compiler.php @@ -95,19 +95,17 @@ public function compile(string $templateContent, ?string $actualClass, string $c $templateGenerator->buildClass($templateNode); /** @var string $phpContent */ $phpContent = $templateGenerator->generateCode($className, null, $this->strictMode); - $phpContent = str_replace( - "final class {$className} extends", - '/** source: ' . $this->generateClassComment($className, $context) . " */\nfinal class {$className} extends", - $phpContent - ); } else { - $phpContent = $templateGenerator->generate( - $templateNode, - $className, - $this->generateClassComment($className, $context), - $this->strictMode - ); + /** @var string $phpContent */ + $phpContent = $templateGenerator->generate($templateNode, $className, null, $this->strictMode); } + // Inject the class comment (context, @property $global, @generated) ourselves: Latte + // 3.0.27+ drops multi-line template names that older versions used as the class docblock. + $phpContent = str_replace( + "final class {$className} extends", + '/** source: ' . $this->generateClassComment($className, $context) . " */\nfinal class {$className} extends", + $phpContent + ); $phpContent = $this->fixLines($phpContent); $phpContent = $this->addTypes($phpContent, $className, $actualClass); return $phpContent; diff --git a/tests/Rule/LatteTemplatesRule/EngineBootstrap/Source/ParentFirstClassCallableFilters.php b/tests/Rule/LatteTemplatesRule/EngineBootstrap/Source/ParentFirstClassCallableFilters.php index 43d6cc33..e4535fad 100644 --- a/tests/Rule/LatteTemplatesRule/EngineBootstrap/Source/ParentFirstClassCallableFilters.php +++ b/tests/Rule/LatteTemplatesRule/EngineBootstrap/Source/ParentFirstClassCallableFilters.php @@ -6,7 +6,10 @@ abstract class ParentFirstClassCallableFilters { - public function inheritedFilter(string $input): static + /** + * @return static + */ + public function inheritedFilter(string $input) { return $this; } From 449d92869d0e74fc5bc376333d42ec218271405f Mon Sep 17 00:00:00 2001 From: Alexander Minkin Date: Tue, 27 Oct 2026 23:47:55 +0300 Subject: [PATCH 22/27] fix: restore Latte 3.1 escape checks and harden analysis - stub Latte 3.1 runtime escaping helpers (Helpers, XmlHelpers, HtmlHelpers) so escape type errors are reported again, and teach the error transformer about the new helper classes - respect {layout none} and report missing explicit layouts instead of silently replacing them with @layout.latte - include the latte config in the collector cache key so templateTypes and other options invalidate cached collector results - escape and verify the injected generated class comment - keep non-public first-class callables as closures, as generated template classes cannot call them - add regression tests for escapes, layout resolution and private filters - document templateTypes, maxTemplateVariants and breakIncludeCycles --- docs/configuration.md | 49 +++++++++++++ extension.neon | 4 +- src/Analyser/LatteContextAnalyser.php | 7 +- src/Compiler/Compiler/Latte3Compiler.php | 13 +++- .../NodeVisitor/ChangeFiltersNodeVisitor.php | 5 ++ .../ChangeFunctionsNodeVisitor.php | 5 ++ .../Transformer/EscapeErrorTransformer.php | 17 ++++- .../LayoutResolver/LayoutPathResolver.php | 17 +++-- stubs/Latte/Runtime/Helpers.stub | 29 +++++++- stubs/Latte/Runtime/HtmlHelpers.stub | 35 ++++++++++ stubs/Latte/Runtime/XmlHelpers.stub | 35 ++++++++++ .../LayoutResolver/Fixtures/@layout.latte | 1 + .../LayoutResolver/Fixtures/layout.latte | 1 + .../LayoutResolver/Fixtures/with-auto.latte | 1 + .../Fixtures/with-dynamic.latte | 1 + .../Fixtures/with-extends.latte | 1 + .../LayoutResolver/Fixtures/with-layout.latte | 1 + .../Fixtures/with-missing.latte | 1 + .../LayoutResolver/Fixtures/with-none.latte | 1 + .../LayoutResolver/LayoutPathResolverTest.php | 69 +++++++++++++++++++ .../FirstClassCallableFilter/default.latte | 1 + ...ine.bootstrap-firstClassCallableFilter.php | 12 ++++ .../Fixtures/EscapesPresenter.php | 16 +++++ .../Fixtures/templates/Escapes/default.latte | 9 +++ .../LatteTemplatesRuleForPresenterTest.php | 49 +++++++++++++ 25 files changed, 367 insertions(+), 13 deletions(-) create mode 100644 stubs/Latte/Runtime/XmlHelpers.stub create mode 100644 tests/Resolver/LayoutResolver/Fixtures/@layout.latte create mode 100644 tests/Resolver/LayoutResolver/Fixtures/layout.latte create mode 100644 tests/Resolver/LayoutResolver/Fixtures/with-auto.latte create mode 100644 tests/Resolver/LayoutResolver/Fixtures/with-dynamic.latte create mode 100644 tests/Resolver/LayoutResolver/Fixtures/with-extends.latte create mode 100644 tests/Resolver/LayoutResolver/Fixtures/with-layout.latte create mode 100644 tests/Resolver/LayoutResolver/Fixtures/with-missing.latte create mode 100644 tests/Resolver/LayoutResolver/Fixtures/with-none.latte create mode 100644 tests/Resolver/LayoutResolver/LayoutPathResolverTest.php create mode 100644 tests/Rule/LatteTemplatesRule/PresenterWithoutModule/Fixtures/EscapesPresenter.php create mode 100644 tests/Rule/LatteTemplatesRule/PresenterWithoutModule/Fixtures/templates/Escapes/default.latte diff --git a/docs/configuration.md b/docs/configuration.md index 36109829..79f5ccd5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -170,6 +170,55 @@ parameters: reportUnanalysedTemplates: true ``` +### templateTypes +Type: `array` + +List of classes or interfaces which should be considered Latte template objects in addition to +`Nette\Application\UI\Template` and `Nette\Application\UI\ITemplate`. Useful for custom template +classes so their `render()`, `setFile()` and variable calls are collected. + +Default: +```neon +parameters: + latte: + templateTypes: [] +``` + +Example: +```neon +parameters: + latte: + templateTypes: + - App\Latte\CustomTemplate +``` + +### maxTemplateVariants +Type: `int` + +Maximum number of variants analysed for a single template path during one run. `0` means no limit. +Lowering it bounds memory and time on include-heavy projects at the cost of skipping some template +variants. + +Default: +```neon +parameters: + latte: + maxTemplateVariants: 0 +``` + +### breakIncludeCycles +Type: `bool` + +When enabled, an `{include}` that would recurse into the template itself or into one of its parents +is skipped instead of being analysed again. + +Default: +```neon +parameters: + latte: + breakIncludeCycles: false +``` + ## Errors ### errorPatternsToIgnore diff --git a/extension.neon b/extension.neon index 664e1fa7..5c21d87e 100644 --- a/extension.neon +++ b/extension.neon @@ -5,8 +5,9 @@ parameters: - stubs/Latte/Essential/Filters.stub - stubs/Latte/Runtime/FilterInfo.stub - stubs/Latte/Runtime/Filters.stub - - stubs/Latte/Runtime/HtmlHelpers.stub - stubs/Latte/Runtime/Helpers.stub + - stubs/Latte/Runtime/HtmlHelpers.stub + - stubs/Latte/Runtime/XmlHelpers.stub - stubs/Latte/Runtime/HtmlStringable.stub - stubs/Latte/Runtime/Html.stub - stubs/Nette/HtmlStringable.stub @@ -137,6 +138,7 @@ services: arguments: parser: @latteCurrentPhpVersionRichParser debugMode: %latte.debugMode% + cacheKey: ::md5(::json_encode(%latte%)) # Latte template resolvers - Efabrica\PHPStanLatte\LatteTemplateResolver\Nette\NetteApplicationUIPresenter diff --git a/src/Analyser/LatteContextAnalyser.php b/src/Analyser/LatteContextAnalyser.php index 59e03387..ae6018ea 100644 --- a/src/Analyser/LatteContextAnalyser.php +++ b/src/Analyser/LatteContextAnalyser.php @@ -62,6 +62,8 @@ final class LatteContextAnalyser private string $tmpDir; + private string $cacheKey; + /** * @param AbstractLatteContextCollector[] $collectors */ @@ -74,7 +76,8 @@ public function __construct( TypeStringResolver $typeStringResolver, TempDirResolver $tempDirResolver, array $collectors, - bool $debugMode = false + bool $debugMode = false, + string $cacheKey = '' ) { $this->scopeFactory = $scopeFactory; $this->nodeScopeResolver = clone $nodeScopeResolver; @@ -85,6 +88,7 @@ public function __construct( $this->typeStringResolver = $typeStringResolver; $this->collectorRegistry = new LatteContextCollectorRegistry($collectors); $this->tmpDir = $tempDirResolver->resolveCollectorDir(); + $this->cacheKey = $cacheKey; if (file_exists($this->tmpDir) && $debugMode) { FileSystem::delete($this->tmpDir); } @@ -218,6 +222,7 @@ private function cacheFilename(string $file): string $cacheKey = md5( $file . PHP_VERSION_ID . + $this->cacheKey . (class_exists(InstalledVersions::class) ? json_encode(InstalledVersions::getAllRawData()) : '') ); return $this->tmpDir . basename($file) . '.' . $cacheKey . '.json'; diff --git a/src/Compiler/Compiler/Latte3Compiler.php b/src/Compiler/Compiler/Latte3Compiler.php index 23f68506..f4ee2c54 100644 --- a/src/Compiler/Compiler/Latte3Compiler.php +++ b/src/Compiler/Compiler/Latte3Compiler.php @@ -11,6 +11,7 @@ use Latte\Extension; use Nette\Bridges\ApplicationLatte\UIExtension; use Nette\Bridges\FormsLatte\FormsExtension; +use PHPStan\ShouldNotHappenException; use function array_keys; use function array_map; use function array_merge; @@ -101,11 +102,17 @@ public function compile(string $templateContent, ?string $actualClass, string $c } // Inject the class comment (context, @property $global, @generated) ourselves: Latte // 3.0.27+ drops multi-line template names that older versions used as the class docblock. + $classHeader = "final class {$className} extends"; + $classComment = str_replace('*/', '* /', $this->generateClassComment($className, $context)); $phpContent = str_replace( - "final class {$className} extends", - '/** source: ' . $this->generateClassComment($className, $context) . " */\nfinal class {$className} extends", - $phpContent + $classHeader, + '/** source: ' . $classComment . " */\n" . $classHeader, + $phpContent, + $replaced ); + if ($replaced !== 1) { + throw new ShouldNotHappenException('Unable to inject the compiled template class comment.'); + } $phpContent = $this->fixLines($phpContent); $phpContent = $this->addTypes($phpContent, $className, $actualClass); return $phpContent; diff --git a/src/Compiler/NodeVisitor/ChangeFiltersNodeVisitor.php b/src/Compiler/NodeVisitor/ChangeFiltersNodeVisitor.php index d625a472..ec567489 100644 --- a/src/Compiler/NodeVisitor/ChangeFiltersNodeVisitor.php +++ b/src/Compiler/NodeVisitor/ChangeFiltersNodeVisitor.php @@ -155,6 +155,11 @@ private function resolveFirstClassCallable(Closure $filter): ?array return null; } + // Non-public methods stay closures: the generated template class cannot call them. + if (!$scopeClass->getMethod($methodName)->isPublic()) { + return null; + } + // An instance method callable keeps the object it was created on: use its class // so the most derived implementation is analysed, as with array callables. $closureThis = $reflection->getClosureThis(); diff --git a/src/Compiler/NodeVisitor/ChangeFunctionsNodeVisitor.php b/src/Compiler/NodeVisitor/ChangeFunctionsNodeVisitor.php index ba93af37..f8d9a534 100644 --- a/src/Compiler/NodeVisitor/ChangeFunctionsNodeVisitor.php +++ b/src/Compiler/NodeVisitor/ChangeFunctionsNodeVisitor.php @@ -164,6 +164,11 @@ private function resolveFirstClassCallable(Closure $function): ?array return null; } + // Non-public methods stay closures: the generated template class cannot call them. + if (!$scopeClass->getMethod($methodName)->isPublic()) { + return null; + } + // An instance method callable keeps the object it was created on: use its class // so the most derived implementation is analysed, as with array callables. $closureThis = $reflection->getClosureThis(); diff --git a/src/Error/Transformer/EscapeErrorTransformer.php b/src/Error/Transformer/EscapeErrorTransformer.php index 3953149b..d9082344 100644 --- a/src/Error/Transformer/EscapeErrorTransformer.php +++ b/src/Error/Transformer/EscapeErrorTransformer.php @@ -10,14 +10,25 @@ final class EscapeErrorTransformer implements ErrorTransformerInterface { - private const HTML_OUTPUT_REGEX = '/Parameter #1 \$s of static method Latte\\\\(Runtime|Essentials)\\\\(?:Filters|HtmlHelpers)::escape(?.*)\(\) expects [^ ]+, (?.*) given\./'; + private const HTML_OUTPUT_REGEX = '/Parameter #1 \$s of static method Latte\\\\(Runtime|Essentials)\\\\(?Filters|HtmlHelpers|Helpers|XmlHelpers)::escape(?.*)\(\) expects [^ ]+, (?.*) given\./'; public function transform(Error $error): Error { if (preg_match(self::HTML_OUTPUT_REGEX, $error->getMessage(), $match) === 1) { $escape = Strings::upper($match['escape']); - if ($escape === 'HTMLTEXT' || $escape === 'TEXT') { - $escape = 'HTML'; + switch ($escape) { + case 'TEXT': + $escape = $match['helper'] === 'XmlHelpers' ? 'XML' : 'HTML'; + break; + case 'HTMLTEXT': + $escape = 'HTML'; + break; + case 'XMLTEXT': + $escape = 'XML'; + break; + case 'COMMENT': + $escape = 'HTMLCOMMENT'; + break; } $message = "Cannot convert {$match['type']} to {$escape} string."; $error->setMessage($message); diff --git a/src/Resolver/LayoutResolver/LayoutPathResolver.php b/src/Resolver/LayoutResolver/LayoutPathResolver.php index 1cdab7d0..33626f05 100644 --- a/src/Resolver/LayoutResolver/LayoutPathResolver.php +++ b/src/Resolver/LayoutResolver/LayoutPathResolver.php @@ -6,7 +6,6 @@ use function dirname; use function file_get_contents; -use function in_array; use function preg_match; use function realpath; use const DIRECTORY_SEPARATOR; @@ -42,13 +41,23 @@ public function resolve(?string $templatePath): ?string } else { $layoutName = null; } - if ($layoutName !== null && !in_array($layoutName, ['none', 'auto'], true)) { - $layoutFilePath = realpath(dirname($templatePath) . DIRECTORY_SEPARATOR . $layoutName) ?: null; - if ($layoutFilePath !== null) { + $isQuoted = !isset($matches['bare']); + if ($layoutName === 'none') { + return null; // {layout none} disables the layout completely + } + if ($layoutName !== null && $layoutName !== 'auto') { + $layoutFilePath = realpath(dirname($templatePath) . DIRECTORY_SEPARATOR . $layoutName); + if ($layoutFilePath !== false) { return $layoutFilePath; } + // keep explicit literal paths so a missing layout is reported instead of + // silently replaced by @layout.latte; dynamic expressions have no path to check + if ($isQuoted || preg_match('/^\$|[(\[]|->|::/', $layoutName) !== 1) { + return dirname($templatePath) . DIRECTORY_SEPARATOR . $layoutName; + } } + // no layout tag, {layout auto} or a dynamic layout name $layoutFilePath = realpath(dirname($templatePath) . DIRECTORY_SEPARATOR . '@layout.latte') ?: null; if ($layoutFilePath !== null) { return $layoutFilePath; diff --git a/stubs/Latte/Runtime/Helpers.stub b/stubs/Latte/Runtime/Helpers.stub index 3480a090..6892673f 100644 --- a/stubs/Latte/Runtime/Helpers.stub +++ b/stubs/Latte/Runtime/Helpers.stub @@ -2,9 +2,16 @@ namespace Latte\Runtime; +use Latte\Runtime\HtmlStringable as LatteHtmlStringable; +use Nette\HtmlStringable as NetteHtmlStringable; +use Stringable; + +/** + * Latte 3.1 moved escaping from Latte\Runtime\Filters to Helpers. + * @phpstan-type FilterString string|int|float|bool|null|Stringable|NetteHtmlStringable|LatteHtmlStringable + */ class Helpers implements HtmlStringable { - /** * @template A * @param A $value @@ -14,4 +21,24 @@ class Helpers implements HtmlStringable { } + /** + * @param FilterString $s + */ + public static function escapeCss(mixed $s): string + { + } + + /** + * @param FilterString|array $s + */ + public static function escapeJs(mixed $s): string + { + } + + /** + * @param FilterString $s + */ + public static function escapeICal(mixed $s): string + { + } } diff --git a/stubs/Latte/Runtime/HtmlHelpers.stub b/stubs/Latte/Runtime/HtmlHelpers.stub index 0bfefc13..5d2fa79b 100644 --- a/stubs/Latte/Runtime/HtmlHelpers.stub +++ b/stubs/Latte/Runtime/HtmlHelpers.stub @@ -18,4 +18,39 @@ final class HtmlHelpers public static function escapeText(mixed $s): string { } + + /** + * @param FilterString $s + */ + public static function escapeAttr(mixed $s): string + { + } + + /** + * @param FilterString $s + */ + public static function escapeTag(mixed $s): string + { + } + + /** + * @param FilterString $s + */ + public static function escapeComment(mixed $s): string + { + } + + /** + * @param FilterString $s + */ + public static function escapeRawHtml(mixed $s): string + { + } + + /** + * @param FilterString $s + */ + public static function escapeQuotes(mixed $s): string + { + } } diff --git a/stubs/Latte/Runtime/XmlHelpers.stub b/stubs/Latte/Runtime/XmlHelpers.stub new file mode 100644 index 00000000..3de68d33 --- /dev/null +++ b/stubs/Latte/Runtime/XmlHelpers.stub @@ -0,0 +1,35 @@ +resolver = new LayoutPathResolver(true); + } + + public function testResolvesQuotedLayout(): void + { + self::assertSame( + realpath(__DIR__ . '/Fixtures/layout.latte'), + $this->resolver->resolve(__DIR__ . '/Fixtures/with-layout.latte') + ); + } + + public function testResolvesExtends(): void + { + self::assertSame( + realpath(__DIR__ . '/Fixtures/layout.latte'), + $this->resolver->resolve(__DIR__ . '/Fixtures/with-extends.latte') + ); + } + + public function testLayoutNoneDisablesLayout(): void + { + self::assertNull($this->resolver->resolve(__DIR__ . '/Fixtures/with-none.latte')); + } + + public function testMissingExplicitLayoutIsReported(): void + { + self::assertSame( + __DIR__ . '/Fixtures/missing.latte', + $this->resolver->resolve(__DIR__ . '/Fixtures/with-missing.latte') + ); + } + + public function testDynamicLayoutFallsBackToConventionalLayout(): void + { + self::assertSame( + realpath(__DIR__ . '/Fixtures/@layout.latte'), + $this->resolver->resolve(__DIR__ . '/Fixtures/with-dynamic.latte') + ); + } + + public function testAutoLayoutUsesConventionalLayout(): void + { + self::assertSame( + realpath(__DIR__ . '/Fixtures/@layout.latte'), + $this->resolver->resolve(__DIR__ . '/Fixtures/with-auto.latte') + ); + } + + public function testDisabledFeatureDoesNotResolve(): void + { + self::assertNull((new LayoutPathResolver(false))->resolve(__DIR__ . '/Fixtures/with-layout.latte')); + } +} diff --git a/tests/Rule/LatteTemplatesRule/EngineBootstrap/Fixtures/templates/FirstClassCallableFilter/default.latte b/tests/Rule/LatteTemplatesRule/EngineBootstrap/Fixtures/templates/FirstClassCallableFilter/default.latte index 28d666ae..4178d04a 100644 --- a/tests/Rule/LatteTemplatesRule/EngineBootstrap/Fixtures/templates/FirstClassCallableFilter/default.latte +++ b/tests/Rule/LatteTemplatesRule/EngineBootstrap/Fixtures/templates/FirstClassCallableFilter/default.latte @@ -1,2 +1,3 @@ {$title|objectFilterFirstClassCallable} {$title|inheritedFilterFirstClassCallable} +{$title|privateFilterFirstClassCallable} diff --git a/tests/Rule/LatteTemplatesRule/EngineBootstrap/engine.bootstrap-firstClassCallableFilter.php b/tests/Rule/LatteTemplatesRule/EngineBootstrap/engine.bootstrap-firstClassCallableFilter.php index 224a9920..a5ef07e2 100644 --- a/tests/Rule/LatteTemplatesRule/EngineBootstrap/engine.bootstrap-firstClassCallableFilter.php +++ b/tests/Rule/LatteTemplatesRule/EngineBootstrap/engine.bootstrap-firstClassCallableFilter.php @@ -4,6 +4,7 @@ namespace Efabrica\PHPStanLatte\Tests\Rule\LatteTemplatesRule\EngineBootstrap; +use Closure; use Latte\Engine; use Stringable; @@ -16,13 +17,24 @@ public function objectFilter(string $input): string return $input; } + public function privateFilterCallable(): Closure + { + return $this->privateFilter(...); + } + public function __toString(): string { return $this->objectFilter('filter'); } + + private function privateFilter(string $input): string + { + return $input; + } } $engine = new Engine(); $engine->addFilter('objectFilterFirstClassCallable', (new FirstClassCallableFilters())->objectFilter(...)); $engine->addFilter('inheritedFilterFirstClassCallable', (new FirstClassCallableFilters())->inheritedFilter(...)); +$engine->addFilter('privateFilterFirstClassCallable', (new FirstClassCallableFilters())->privateFilterCallable()); return $engine; diff --git a/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/Fixtures/EscapesPresenter.php b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/Fixtures/EscapesPresenter.php new file mode 100644 index 00000000..2eba07b5 --- /dev/null +++ b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/Fixtures/EscapesPresenter.php @@ -0,0 +1,16 @@ +template->value = new stdClass(); + } +} diff --git a/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/Fixtures/templates/Escapes/default.latte b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/Fixtures/templates/Escapes/default.latte new file mode 100644 index 00000000..de2f4bc6 --- /dev/null +++ b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/Fixtures/templates/Escapes/default.latte @@ -0,0 +1,9 @@ +{layout none} +{$value|escapeJs} +{$value|escapeCss} +{$value|escapeICal} +{$value|escapeXml} +{$value|escapeHtmlComment} + + + diff --git a/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterTest.php b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterTest.php index 0ce4d93b..dea6fb1c 100644 --- a/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterTest.php +++ b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterTest.php @@ -3093,4 +3093,53 @@ public function testSnippets(): void } $this->analyse([__DIR__ . '/Fixtures/SnippetsPresenter.php'], $expectedErrors); } + + public function testEscapes(): void + { + if (!LatteVersion::isLatte31()) { + $this->markTestSkipped('Latte 3.1 moved escaping from Filters to dedicated runtime helpers.'); + } + $this->analyse([__DIR__ . '/Fixtures/EscapesPresenter.php'], [ + [ + 'Cannot convert stdClass to JS string.', + 2, + 'default.latte', + ], + [ + 'Cannot convert stdClass to CSS string.', + 3, + 'default.latte', + ], + [ + 'Cannot convert stdClass to ICAL string.', + 4, + 'default.latte', + ], + [ + 'Cannot convert stdClass to XML string.', + 5, + 'default.latte', + ], + [ + 'Cannot convert stdClass to HTMLCOMMENT string.', + 6, + 'default.latte', + ], + [ + 'Cannot convert stdClass to JS string.', + 7, + 'default.latte', + ], + [ + 'Cannot convert stdClass to CSS string.', + 8, + 'default.latte', + ], + [ + 'Cannot convert stdClass to HTMLCOMMENT string.', + 9, + 'default.latte', + ], + ]); + } } From 38d56cfb80ed4eec0330e72f07bc4fab33a87453 Mon Sep 17 00:00:00 2001 From: Alexander Minkin Date: Wed, 28 Oct 2026 00:32:06 +0300 Subject: [PATCH 23/27] fix: honor Latte 3.1 features and improve slice/escape typing - add a dynamic static return type extension for slice() so strings stay strings and arrays keep their value type instead of leaking Generator into escape checks on Latte 3.1 - stop narrowing escapeJs to FilterString: Latte JSON-encodes arrays and objects, so those are valid arguments - pass the engine feature map to TemplateGenerator::buildClass() so ScopedLoopVariables and other 3.1 features affect generated code; ignore the unset() false positive produced by Latte's scoped-loop code - add a ScopedLoopVariables engine-bootstrap regression test and update slice/escape expectations --- extension.neon | 14 +++ src/Compiler/Compiler/Latte3Compiler.php | 7 +- src/Error/ErrorBuilder.php | 1 + src/Type/SliceReturnTypeExtension.php | 87 +++++++++++++++++++ stubs/Latte/Runtime/Filters.stub | 2 +- stubs/Latte/Runtime/Helpers.stub | 2 +- .../Fixtures/ScopedLoopVariablesPresenter.php | 14 +++ .../ScopedLoopVariables/default.latte | 5 ++ ...EngineBootstrapScopedLoopVariablesTest.php | 34 ++++++++ .../config-scopedLoopVariables.neon | 3 + .../engine.bootstrap-scopedLoopVariables.php | 12 +++ .../LatteTemplatesRuleForPresenterTest.php | 37 ++------ 12 files changed, 184 insertions(+), 34 deletions(-) create mode 100644 src/Type/SliceReturnTypeExtension.php create mode 100644 tests/Rule/LatteTemplatesRule/EngineBootstrap/Fixtures/ScopedLoopVariablesPresenter.php create mode 100644 tests/Rule/LatteTemplatesRule/EngineBootstrap/Fixtures/templates/ScopedLoopVariables/default.latte create mode 100644 tests/Rule/LatteTemplatesRule/EngineBootstrap/LatteTemplatesRuleForEngineBootstrapScopedLoopVariablesTest.php create mode 100644 tests/Rule/LatteTemplatesRule/EngineBootstrap/config-scopedLoopVariables.neon create mode 100644 tests/Rule/LatteTemplatesRule/EngineBootstrap/engine.bootstrap-scopedLoopVariables.php diff --git a/extension.neon b/extension.neon index 5c21d87e..99eda472 100644 --- a/extension.neon +++ b/extension.neon @@ -149,6 +149,20 @@ services: - Efabrica\PHPStanLatte\Resolver\TypeResolver\TypeResolver - Efabrica\PHPStanLatte\Resolver\TypeResolver\TemplateTypeResolver(%latte.templateTypes%) + - + class: Efabrica\PHPStanLatte\Type\SliceReturnTypeExtension + arguments: + className: Latte\Essential\Filters + tags: + - phpstan.broker.dynamicStaticMethodReturnTypeExtension + + - + class: Efabrica\PHPStanLatte\Type\SliceReturnTypeExtension + arguments: + className: Latte\Runtime\Filters + tags: + - phpstan.broker.dynamicStaticMethodReturnTypeExtension + # Value resolvers - Efabrica\PHPStanLatte\Resolver\ValueResolver\ValueResolver() - Efabrica\PHPStanLatte\Resolver\ValueResolver\PathResolver(%latte.resolveAllPossiblePaths%) diff --git a/src/Compiler/Compiler/Latte3Compiler.php b/src/Compiler/Compiler/Latte3Compiler.php index f4ee2c54..1fb6b38e 100644 --- a/src/Compiler/Compiler/Latte3Compiler.php +++ b/src/Compiler/Compiler/Latte3Compiler.php @@ -9,6 +9,7 @@ use Latte\Engine; use Latte\Essential\RawPhpExtension; use Latte\Extension; +use Latte\Feature; use Nette\Bridges\ApplicationLatte\UIExtension; use Nette\Bridges\FormsLatte\FormsExtension; use PHPStan\ShouldNotHappenException; @@ -93,7 +94,11 @@ public function compile(string $templateContent, ?string $actualClass, string $c $templateGenerator = new TemplateGenerator(); if (LatteVersion::isLatte31()) { // Latte 3.1+ (TemplateGenerator::generate() was split into buildClass() + generateCode()) - $templateGenerator->buildClass($templateNode); + $features = []; + foreach (Feature::cases() as $feature) { + $features[$feature->name] = $this->engine->hasFeature($feature); + } + $templateGenerator->buildClass($templateNode, $features); /** @var string $phpContent */ $phpContent = $templateGenerator->generateCode($className, null, $this->strictMode); } else { diff --git a/src/Error/ErrorBuilder.php b/src/Error/ErrorBuilder.php index 3417518d..5c85a2a8 100644 --- a/src/Error/ErrorBuilder.php +++ b/src/Error/ErrorBuilder.php @@ -73,6 +73,7 @@ final class ErrorBuilder '/Cannot convert mixed to .*\./', '/Call to function array_key_exists\(\) with .* will always evaluate to (true|false)\./', '/Parameter #2 \$array of function implode expects array, array given\./', // n:class generates implode with mixed array + '/Call to function unset\(\) contains undefined variable .*/', // Latte 3.1 ScopedLoopVariables generated code ]; /** @var string[] */ diff --git a/src/Type/SliceReturnTypeExtension.php b/src/Type/SliceReturnTypeExtension.php new file mode 100644 index 00000000..4e409281 --- /dev/null +++ b/src/Type/SliceReturnTypeExtension.php @@ -0,0 +1,87 @@ +className = $className; + } + + public function getClass(): string + { + return $this->className; + } + + public function isStaticMethodSupported(MethodReflection $methodReflection): bool + { + return $methodReflection->getName() === 'slice'; + } + + public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, Scope $scope): ?Type + { + $args = $methodCall->getArgs(); + if (!isset($args[0])) { + return null; + } + + return $this->resolve($scope->getType($args[0]->value)); + } + + private function resolve(Type $type): ?Type + { + if ($type instanceof UnionType) { + $types = []; + foreach ($type->getTypes() as $innerType) { + // null is rejected by slice() itself; drop it to avoid the Generator branch + if ($innerType->isNull()->yes()) { + continue; + } + $resolved = $this->resolve($innerType); + if ($resolved === null) { + return null; + } + $types[] = $resolved; + } + return $types === [] ? null : TypeCombinator::union(...$types); + } + + if ($type->isString()->yes()) { + return new StringType(); + } + if ($type->isArray()->yes()) { + return new ArrayType(new MixedType(), $type->getIterableValueType()); + } + if ($type->isIterable()->yes()) { + return new ObjectType('Generator'); + } + + return null; + } +} diff --git a/stubs/Latte/Runtime/Filters.stub b/stubs/Latte/Runtime/Filters.stub index 704bb8af..38482ae0 100644 --- a/stubs/Latte/Runtime/Filters.stub +++ b/stubs/Latte/Runtime/Filters.stub @@ -47,7 +47,7 @@ class Filters } /** - * @param FilterString|array $s + * json_encode() accepts any value, including arrays and objects. */ public static function escapeJs($s): string { diff --git a/stubs/Latte/Runtime/Helpers.stub b/stubs/Latte/Runtime/Helpers.stub index 6892673f..1bc40d40 100644 --- a/stubs/Latte/Runtime/Helpers.stub +++ b/stubs/Latte/Runtime/Helpers.stub @@ -29,7 +29,7 @@ class Helpers implements HtmlStringable } /** - * @param FilterString|array $s + * json_encode() accepts any value, including arrays and objects. */ public static function escapeJs(mixed $s): string { diff --git a/tests/Rule/LatteTemplatesRule/EngineBootstrap/Fixtures/ScopedLoopVariablesPresenter.php b/tests/Rule/LatteTemplatesRule/EngineBootstrap/Fixtures/ScopedLoopVariablesPresenter.php new file mode 100644 index 00000000..23f4dc6f --- /dev/null +++ b/tests/Rule/LatteTemplatesRule/EngineBootstrap/Fixtures/ScopedLoopVariablesPresenter.php @@ -0,0 +1,14 @@ +markTestSkipped('ScopedLoopVariables is a Latte 3.1 feature.'); + } + $this->analyse([__DIR__ . '/Fixtures/ScopedLoopVariablesPresenter.php'], [ + [ + "Dumped type: 'before'", + 5, + 'default.latte', + ], + ]); + } +} diff --git a/tests/Rule/LatteTemplatesRule/EngineBootstrap/config-scopedLoopVariables.neon b/tests/Rule/LatteTemplatesRule/EngineBootstrap/config-scopedLoopVariables.neon new file mode 100644 index 00000000..5b9efd0e --- /dev/null +++ b/tests/Rule/LatteTemplatesRule/EngineBootstrap/config-scopedLoopVariables.neon @@ -0,0 +1,3 @@ +parameters: + latte: + engineBootstrap: %rootDir%/../../../tests/Rule/LatteTemplatesRule/EngineBootstrap/engine.bootstrap-scopedLoopVariables.php diff --git a/tests/Rule/LatteTemplatesRule/EngineBootstrap/engine.bootstrap-scopedLoopVariables.php b/tests/Rule/LatteTemplatesRule/EngineBootstrap/engine.bootstrap-scopedLoopVariables.php new file mode 100644 index 00000000..729fda1c --- /dev/null +++ b/tests/Rule/LatteTemplatesRule/EngineBootstrap/engine.bootstrap-scopedLoopVariables.php @@ -0,0 +1,12 @@ +setFeature(Feature::ScopedLoopVariables); +return $engine; diff --git a/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterTest.php b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterTest.php index dea6fb1c..1c7934ab 100644 --- a/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterTest.php +++ b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterTest.php @@ -27,12 +27,10 @@ protected static function additionalConfigFiles(): array public function testVariables(): void { - // Latte 3.1 declares a native return type on Filters::slice, which PHPStan cannot - // narrow with the stub's conditional return type anymore. - $sliceArrayType = LatteVersion::isLatte31() - ? "Dumped type: array<'bar'|'baz'|'foo'>|Generator" - : "Dumped type: array<'bar'|'baz'|'foo'>"; - $sliceStringType = LatteVersion::isLatte31() ? 'Dumped type: Generator|string' : 'Dumped type: string'; + // slice() keeps string for strings and array for arrays; its native + // string|array|Generator return type is narrowed by SliceReturnTypeExtension. + $sliceArrayType = "Dumped type: array<'bar'|'baz'|'foo'>"; + $sliceStringType = 'Dumped type: string'; $expectedErrors = [ [ 'Undefined variable: $items', @@ -170,7 +168,7 @@ public function testVariables(): void 'default.latte', ], [ - $sliceArrayType, // False positive - TODO create DynamicMethodReturnTypeExtension for Latte\Runtime\Filters::slice - inspire in ArraySliceFunctionReturnTypeExtension and SubstrDynamicReturnTypeExtension + $sliceArrayType, // Latte array_slice() preserves the item type 92, 'default.latte', ], @@ -2217,10 +2215,8 @@ public function testFilters(): void if (LatteVersion::isLatte31()) { // Latte 3.1 declares native types on built-in filters. PHPStan stub PHPDoc // can only narrow native types, so the permissive FilterString stub no longer - // widens |lower/|upper/|capitalize, and the native |slice return type - // (including Generator) leaks into the escape checks. + // widens |lower/|upper/|capitalize. $filterStringType = 'string|Stringable|null'; - $sliceReturnType = 'array|Generator|string'; $expectedErrors[] = [ 'Parameter #3 $s of static method Latte\Runtime\Helpers::convertTo() expects string, mixed given.', 22, @@ -2228,7 +2224,6 @@ public function testFilters(): void ]; } else { $filterStringType = 'bool|float|int|string|Stringable|null'; - $sliceReturnType = 'array|string'; } $expectedErrors[] = [ 'Parameter #1 $s of static method Latte\Essential\Filters::lower() expects ' . $filterStringType . ', stdClass given.', @@ -2245,11 +2240,6 @@ public function testFilters(): void 21, 'default.latte', ]; - $expectedErrors[] = [ - 'Cannot convert ' . $sliceReturnType . ' to HTML string.', - 22, - 'default.latte', - ]; $expectedErrors[] = [ 'Parameter #1 $value of static method Latte\Essential\Filters::slice() expects array|string, string|null given.', 22, @@ -2289,11 +2279,6 @@ public function testFilters(): void 21, 'default.latte', ]; - $expectedErrors[] = [ - 'Cannot convert array|string to HTML string.', - 22, - 'default.latte', - ]; $expectedErrors[] = [ 'Parameter #1 $value of static method Latte\Runtime\Filters::slice() expects array|string, string|null given.', 22, @@ -3100,11 +3085,6 @@ public function testEscapes(): void $this->markTestSkipped('Latte 3.1 moved escaping from Filters to dedicated runtime helpers.'); } $this->analyse([__DIR__ . '/Fixtures/EscapesPresenter.php'], [ - [ - 'Cannot convert stdClass to JS string.', - 2, - 'default.latte', - ], [ 'Cannot convert stdClass to CSS string.', 3, @@ -3125,11 +3105,6 @@ public function testEscapes(): void 6, 'default.latte', ], - [ - 'Cannot convert stdClass to JS string.', - 7, - 'default.latte', - ], [ 'Cannot convert stdClass to CSS string.', 8, From a60b8388888c7c6932e007422eadbdc610ebd17d Mon Sep 17 00:00:00 2001 From: Alexander Minkin Date: Wed, 28 Oct 2026 00:35:41 +0300 Subject: [PATCH 24/27] fix(phpstan): ignore Latte 3.1 feature API on older Latte versions phpstan.neon now skips Feature::cases() and Engine::hasFeature() when self-analysis runs against Latte 2.11/3.0, as it already does for the version-specific TemplateGenerator methods. --- phpstan.neon | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/phpstan.neon b/phpstan.neon index 71dc72c0..c2426501 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -159,6 +159,13 @@ parameters: - '#^Call to an undefined method Latte\\Compiler\\TemplateGenerator\:\:buildClass\(\)\.$#' - '#^Call to an undefined method Latte\\Compiler\\TemplateGenerator\:\:generateCode\(\)\.$#' - '#^Call to an undefined method Latte\\Compiler\\TemplateGenerator\:\:generate\(\)\.$#' + - '#^Call to static method cases\(\) on an unknown class Latte\\Feature\.$#' + - '#^Call to an undefined static method Latte\\Feature\:\:cases\(\)\.$#' + - '#^Call to an undefined method Latte\\Engine\:\:hasFeature\(\)\.$#' + - '#^Cannot access property \$name on mixed\.$#' + - '#^Parameter \#1 \$feature of method Latte\\Engine\:\:hasFeature\(\) expects string, mixed given\.$#' + - '#^Possibly invalid array key type mixed\.$#' + - '#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\.$#' - '#^Call to an undefined method Latte\\Engine\:\:getExtensions\(\)\.$#' - '#^Call to an undefined method Latte\\Engine\:\:getFunctions\(\)\.$#' - '#^Parameter \$extensions of method Efabrica\\PHPStanLatte\\Compiler\\Compiler\\Latte3Compiler\:\:installExtensions\(\) has invalid type Latte\\Extension\.$#' From d50d6d09a09e2f63a648f67dc3c74c7d1d2c6961 Mon Sep 17 00:00:00 2001 From: Alexander Minkin Date: Wed, 28 Oct 2026 01:44:23 +0300 Subject: [PATCH 25/27] fix: harden layout resolution, resolveTypes and 3.1 compiler paths - report a missing layout as a regular rule error instead of crashing on a non-existent template path; guard all layout template additions with is_file() and make ErrorBuilder fall back to an existing file - parse layout tags with quote awareness: only single non-interpolated string literals (or bare file names) are paths, expressions fall back to @layout.latte; support absolute paths and treat quoted none/auto as files - include engine feature state in the Latte3Compiler cache key - keep running semantic scope visitors when resolveTypes=false, with a fallback for filter/function class detection and snippet driver rewrite - normalize '/* pos */' comments via token_get_all so user string literals are not rewritten - give the Latte 3.1 phantom Filters::escapeJs stub an explicit mixed type - add tests for missing layouts, layout expressions, maxTemplateVariants, breakIncludeCycles, resolveTypes=false, templateTypes and compiler cache key; fix the first-class callable @requires annotation --- docs/features.md | 6 +- src/Compiler/Compiler/Latte3Compiler.php | 26 ++++- .../NodeVisitor/ChangeFiltersNodeVisitor.php | 7 +- .../ChangeFunctionsNodeVisitor.php | 7 +- .../NotNullableSnippetDriverNodeVisitor.php | 27 ++++- src/Compiler/Postprocessor.php | 34 +++--- src/Error/ErrorBuilder.php | 7 +- ...bstractClassStandaloneTemplateResolver.php | 10 +- .../Nette/NetteApplicationUIPresenter.php | 17 ++- .../LayoutResolver/LayoutPathResolver.php | 105 ++++++++++++++---- stubs/Latte/Runtime/Filters.stub | 1 + .../Compiler/Compiler/Latte3CompilerTest.php | 41 +++++++ .../LayoutResolver/Fixtures/with-bare.latte | 1 + .../LayoutResolver/Fixtures/with-concat.latte | 1 + .../Fixtures/with-double-quoted.latte | 1 + .../Fixtures/with-interpolation.latte | 1 + .../Fixtures/with-quoted-none.latte | 1 + .../LayoutResolver/LayoutPathResolverTest.php | 64 +++++++++++ ...eBootstrapFirstClassCallableFilterTest.php | 2 +- .../Fixtures/MissingLayoutPresenter.php | 15 +++ .../Fixtures/ResolveTypesPresenter.php | 15 +++ .../Fixtures/TemplateTypesPresenter.php | 18 +++ .../templates/MissingLayout/default.latte | 2 + .../templates/ResolveTypes/default.latte | 5 + .../templates/TemplateTypes/default.latte | 2 + .../templates/TemplateTypes/other.latte | 2 + ...RuleForPresenterBreakIncludeCyclesTest.php | 92 +++++++++++++++ ...uleForPresenterMaxTemplateVariantsTest.php | 97 ++++++++++++++++ ...sRuleForPresenterResolveTypesFalseTest.php | 32 ++++++ ...latesRuleForPresenterTemplateTypesTest.php | 32 ++++++ .../LatteTemplatesRuleForPresenterTest.php | 11 ++ .../Source/CustomTemplate.php | 20 ++++ .../config-breakIncludeCycles.neon | 3 + .../config-maxTemplateVariants.neon | 3 + .../config-resolveTypesFalse.neon | 3 + .../config-templateTypes.neon | 5 + 36 files changed, 664 insertions(+), 52 deletions(-) create mode 100644 tests/Compiler/Compiler/Latte3CompilerTest.php create mode 100644 tests/Resolver/LayoutResolver/Fixtures/with-bare.latte create mode 100644 tests/Resolver/LayoutResolver/Fixtures/with-concat.latte create mode 100644 tests/Resolver/LayoutResolver/Fixtures/with-double-quoted.latte create mode 100644 tests/Resolver/LayoutResolver/Fixtures/with-interpolation.latte create mode 100644 tests/Resolver/LayoutResolver/Fixtures/with-quoted-none.latte create mode 100644 tests/Rule/LatteTemplatesRule/PresenterWithoutModule/Fixtures/MissingLayoutPresenter.php create mode 100644 tests/Rule/LatteTemplatesRule/PresenterWithoutModule/Fixtures/ResolveTypesPresenter.php create mode 100644 tests/Rule/LatteTemplatesRule/PresenterWithoutModule/Fixtures/TemplateTypesPresenter.php create mode 100644 tests/Rule/LatteTemplatesRule/PresenterWithoutModule/Fixtures/templates/MissingLayout/default.latte create mode 100644 tests/Rule/LatteTemplatesRule/PresenterWithoutModule/Fixtures/templates/ResolveTypes/default.latte create mode 100644 tests/Rule/LatteTemplatesRule/PresenterWithoutModule/Fixtures/templates/TemplateTypes/default.latte create mode 100644 tests/Rule/LatteTemplatesRule/PresenterWithoutModule/Fixtures/templates/TemplateTypes/other.latte create mode 100644 tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterBreakIncludeCyclesTest.php create mode 100644 tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterMaxTemplateVariantsTest.php create mode 100644 tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterResolveTypesFalseTest.php create mode 100644 tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterTemplateTypesTest.php create mode 100644 tests/Rule/LatteTemplatesRule/PresenterWithoutModule/Source/CustomTemplate.php create mode 100644 tests/Rule/LatteTemplatesRule/PresenterWithoutModule/config-breakIncludeCycles.neon create mode 100644 tests/Rule/LatteTemplatesRule/PresenterWithoutModule/config-maxTemplateVariants.neon create mode 100644 tests/Rule/LatteTemplatesRule/PresenterWithoutModule/config-resolveTypesFalse.neon create mode 100644 tests/Rule/LatteTemplatesRule/PresenterWithoutModule/config-templateTypes.neon diff --git a/docs/features.md b/docs/features.md index 7489eb09..f188d811 100644 --- a/docs/features.md +++ b/docs/features.md @@ -67,8 +67,10 @@ node scope resolver over the generated code and the result is used by of the largest consumers of retained memory (compiled classes and reflection caches cannot be released in PHP). -Disabling the pass skips those visitors and saves memory at the cost of less -precise analysis inside templates: +Disabling the pass skips the type attribution. Type-aware visitors then have no +inferred types to work with, while semantic transformations (filters, functions, +snippets) still run. This saves memory at the cost of less precise analysis +inside templates: ```neon parameters: diff --git a/src/Compiler/Compiler/Latte3Compiler.php b/src/Compiler/Compiler/Latte3Compiler.php index 1fb6b38e..651fba9f 100644 --- a/src/Compiler/Compiler/Latte3Compiler.php +++ b/src/Compiler/Compiler/Latte3Compiler.php @@ -24,6 +24,8 @@ use function md5; use function preg_replace; use function str_replace; +use function token_get_all; +use const T_COMMENT; final class Latte3Compiler extends AbstractCompiler { @@ -48,12 +50,19 @@ public function getCacheKey(): string if (!is_array($extensions)) { $extensions = []; } + $features = ''; + if (LatteVersion::isLatte31()) { + foreach (Feature::cases() as $feature) { + $features .= $feature->name . ($this->engine->hasFeature($feature) ? '1' : '0'); + } + } return md5( implode('', array_keys($this->getFilters())) . implode('', array_keys($this->getFunctions())) . implode('', array_map(function ($extension) { return is_object($extension) ? get_class($extension) : null; - }, $extensions)) + }, $extensions)) . + $features ); } @@ -136,7 +145,7 @@ private function installExtensions(array $extensions): void private function fixLines(string $phpContent): string { // Latte 3.1+ emits "/* pos [:] */" instead of "/* line */" - $phpContent = preg_replace('/\/\* pos (\d+)(?::\d+)? \*\//', '/* line $1 */', $phpContent) ?: ''; + $phpContent = $this->normalizePosComments($phpContent); // fix lines after $component->render() $pattern = '/\$ʟ_tmp = \$this->global->uiControl->getComponent(.*?)\$ʟ_tmp->render\((.*?)\) (?(.*?)\/\*(.*?)line (?\d+)(.*?)\*\/);/s'; @@ -146,4 +155,17 @@ private function fixLines(string $phpContent): string $pattern = '/(.*?) (?\/\*(.*?)line (?\d+)(.*?)\*\/)/'; return preg_replace($pattern, '${2}${1}', $phpContent) ?: ''; } + + private function normalizePosComments(string $phpContent): string + { + $result = ''; + foreach (token_get_all($phpContent) as $token) { + if (is_array($token) && $token[0] === T_COMMENT) { + $result .= preg_replace('/\/\* pos (\d+)(?::\d+)? \*\//', '/* line $1 */', $token[1]) ?: $token[1]; + } else { + $result .= is_array($token) ? $token[1] : $token; + } + } + return $result; + } } diff --git a/src/Compiler/NodeVisitor/ChangeFiltersNodeVisitor.php b/src/Compiler/NodeVisitor/ChangeFiltersNodeVisitor.php index ec567489..92a00cf3 100644 --- a/src/Compiler/NodeVisitor/ChangeFiltersNodeVisitor.php +++ b/src/Compiler/NodeVisitor/ChangeFiltersNodeVisitor.php @@ -176,7 +176,12 @@ private function addFilterVariables(ClassMethod $node): void } $type = $this->getType($class); - if ($type === null || !(new ObjectType('\Latte\Runtime\Template'))->isSuperTypeOf($type)->yes()) { + if ($type === null) { + // without the type resolution pass fall back to the generated class header + if ($class->extends === null || $class->extends->toString() !== 'Latte\Runtime\Template') { + return; + } + } elseif (!(new ObjectType('\Latte\Runtime\Template'))->isSuperTypeOf($type)->yes()) { return; } diff --git a/src/Compiler/NodeVisitor/ChangeFunctionsNodeVisitor.php b/src/Compiler/NodeVisitor/ChangeFunctionsNodeVisitor.php index f8d9a534..78c66bc9 100644 --- a/src/Compiler/NodeVisitor/ChangeFunctionsNodeVisitor.php +++ b/src/Compiler/NodeVisitor/ChangeFunctionsNodeVisitor.php @@ -185,7 +185,12 @@ private function addFunctionVariables(ClassMethod $node): void } $type = $this->getType($class); - if ($type === null || !(new ObjectType('\Latte\Runtime\Template'))->isSuperTypeOf($type)->yes()) { + if ($type === null) { + // without the type resolution pass fall back to the generated class header + if ($class->extends === null || $class->extends->toString() !== 'Latte\Runtime\Template') { + return; + } + } elseif (!(new ObjectType('\Latte\Runtime\Template'))->isSuperTypeOf($type)->yes()) { return; } diff --git a/src/Compiler/NodeVisitor/NotNullableSnippetDriverNodeVisitor.php b/src/Compiler/NodeVisitor/NotNullableSnippetDriverNodeVisitor.php index f88b2a0a..b2822121 100644 --- a/src/Compiler/NodeVisitor/NotNullableSnippetDriverNodeVisitor.php +++ b/src/Compiler/NodeVisitor/NotNullableSnippetDriverNodeVisitor.php @@ -7,10 +7,15 @@ use Efabrica\PHPStanLatte\Compiler\NodeVisitor\Behavior\ExprTypeNodeVisitorBehavior; use Efabrica\PHPStanLatte\Compiler\NodeVisitor\Behavior\ExprTypeNodeVisitorInterface; use PhpParser\Node; +use PhpParser\Node\Expr; use PhpParser\Node\Expr\MethodCall; use PhpParser\Node\Expr\NullsafeMethodCall; +use PhpParser\Node\Expr\PropertyFetch; +use PhpParser\Node\Expr\Variable; +use PhpParser\Node\Identifier; use PhpParser\NodeVisitorAbstract; use PHPStan\Type\ObjectType; +use function in_array; final class NotNullableSnippetDriverNodeVisitor extends NodeVisitorAbstract implements ExprTypeNodeVisitorInterface { @@ -23,7 +28,12 @@ public function enterNode(Node $node): ?Node } $callerType = $this->getType($node->var); - if ($callerType !== null && ((new ObjectType('Nette\Bridges\ApplicationLatte\SnippetDriver'))->isSuperTypeOf($callerType)->yes() || (new ObjectType('Nette\Bridges\ApplicationLatte\SnippetRuntime'))->isSuperTypeOf($callerType)->yes())) { + $isSnippetDriver = $callerType !== null + ? (new ObjectType('Nette\Bridges\ApplicationLatte\SnippetDriver'))->isSuperTypeOf($callerType)->yes() + || (new ObjectType('Nette\Bridges\ApplicationLatte\SnippetRuntime'))->isSuperTypeOf($callerType)->yes() + : $this->isSnippetDriverProperty($node->var); + + if ($isSnippetDriver) { $methodCall = new MethodCall($node->var, $node->name, $node->args); $methodCall->setAttributes($node->getAttributes()); return $methodCall; @@ -31,4 +41,19 @@ public function enterNode(Node $node): ?Node return null; } + + /** + * Fallback for the resolveTypes=false pass, when no inferred types are available. + */ + private function isSnippetDriverProperty(Expr $expr): bool + { + return $expr instanceof PropertyFetch + && $expr->name instanceof Identifier + && in_array($expr->name->name, ['snippetDriver', 'snippetRuntime'], true) + && $expr->var instanceof PropertyFetch + && $expr->var->name instanceof Identifier + && $expr->var->name->name === 'global' + && $expr->var->var instanceof Variable + && $expr->var->var->name === 'this'; + } } diff --git a/src/Compiler/Postprocessor.php b/src/Compiler/Postprocessor.php index 27c770c3..b4dc22a4 100644 --- a/src/Compiler/Postprocessor.php +++ b/src/Compiler/Postprocessor.php @@ -108,28 +108,28 @@ public function postProcess(string $phpContent, Template $template, string $comp } require_once($compileFilePath); // load type definitions from compiled template - // Type resolution pass: making it optional trades analysis precision - // inside templates (ExprTypeNodeVisitors) for a significant amount of - // retained memory on projects with many template variants. - if (!$this->resolveTypes) { - return $realPath; - } - $scope = $this->scopeFactory->create(ScopeContext::create($realPath)); $phpStmts = $this->findNodes($phpContent, $compileFilePath); - $nodeScopeResolver = clone $this->nodeScopeResolver; - $nodeScopeResolver->processNodes($phpStmts, $scope, function (Node $node, Scope $scope) { - if ($node instanceof Expr) { - $node->setAttribute(ExprTypeNodeVisitorInterface::TYPE_ATTRIBUTE, $scope->getType($node)); - } elseif ($node instanceof Class_ || $node instanceof Interface_) { - /** @var string $name */ - $name = $this->nameResolver->resolve($node->name); - $node->setAttribute(ExprTypeNodeVisitorInterface::TYPE_ATTRIBUTE, new ObjectType($name)); - } - }); + // Type resolution pass: making it optional trades analysis precision + // inside templates (ExprTypeNodeVisitors) for a significant amount of + // retained memory on projects with many template variants. + if ($this->resolveTypes) { + $nodeScopeResolver = clone $this->nodeScopeResolver; + $nodeScopeResolver->processNodes($phpStmts, $scope, function (Node $node, Scope $scope) { + if ($node instanceof Expr) { + $node->setAttribute(ExprTypeNodeVisitorInterface::TYPE_ATTRIBUTE, $scope->getType($node)); + } elseif ($node instanceof Class_ || $node instanceof Interface_) { + /** @var string $name */ + $name = $this->nameResolver->resolve($node->name); + $node->setAttribute(ExprTypeNodeVisitorInterface::TYPE_ATTRIBUTE, new ObjectType($name)); + } + }); + } + // Semantic (scope) visitors still run without type resolution; type-aware ones + // simply have no inferred types to work with. foreach ($this->nodeVisitorStorage->getNodeVisitors(true) as $nodeVisitors) { $phpStmts = $this->processNodeVisitors($phpStmts, $nodeVisitors, $template, $scope); } diff --git a/src/Error/ErrorBuilder.php b/src/Error/ErrorBuilder.php index 5c85a2a8..ef7393d9 100644 --- a/src/Error/ErrorBuilder.php +++ b/src/Error/ErrorBuilder.php @@ -18,6 +18,7 @@ use PHPStan\Rules\RuleErrorBuilder; use PHPStan\Rules\TipRuleError; use function array_merge; +use function is_file; use function json_encode; use function md5; use function preg_match; @@ -140,7 +141,6 @@ public function buildError(Error $originalError, string $templatePath, ?string $ $error = $this->transformError($error); $ruleErrorBuilder = RuleErrorBuilder::message($error->getMessage()) - ->file($templatePath) ->identifier('latte.error') ->metadata(array_merge($originalError->getMetadata(), [ 'context' => $context === '' ? null : $context, @@ -148,6 +148,11 @@ public function buildError(Error $originalError, string $templatePath, ?string $ 'compiled_template_path' => $compiledTemplatePath, 'compiled_template_error_line' => $originalError->getLine(), ])); + if (is_file($templatePath)) { + $ruleErrorBuilder->file($templatePath); + } elseif ($compiledTemplatePath !== null && is_file($compiledTemplatePath)) { + $ruleErrorBuilder->file($compiledTemplatePath); + } if ($originalError->getLine()) { $ruleErrorBuilder->line($lineMap->get($originalError->getLine())); } diff --git a/src/LatteTemplateResolver/AbstractClassStandaloneTemplateResolver.php b/src/LatteTemplateResolver/AbstractClassStandaloneTemplateResolver.php index ef45e5ec..c9b34467 100644 --- a/src/LatteTemplateResolver/AbstractClassStandaloneTemplateResolver.php +++ b/src/LatteTemplateResolver/AbstractClassStandaloneTemplateResolver.php @@ -11,7 +11,9 @@ use Nette\Utils\Finder; use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\ReflectionProvider; +use PHPStan\Rules\RuleErrorBuilder; use SplFileInfo; +use function is_file; use function preg_match; use function str_contains; @@ -40,7 +42,13 @@ protected function getClassResult(ClassReflection $classReflection, LatteContext $layoutFilePath = $this->layoutPathResolver->resolve($standaloneTemplateFile); if ($layoutFilePath !== null) { - $result->addTemplate(new Template($layoutFilePath, $classReflection->getName(), null, $templateContext)); + if (is_file($layoutFilePath)) { + $result->addTemplate(new Template($layoutFilePath, $classReflection->getName(), null, $templateContext)); + } else { + $result->addErrorFromBuilder(RuleErrorBuilder::message('Layout template ' . $layoutFilePath . ' does not exist.') + ->identifier('latte.notFound') + ->file($standaloneTemplateFile)); + } } } return $result; diff --git a/src/LatteTemplateResolver/Nette/NetteApplicationUIPresenter.php b/src/LatteTemplateResolver/Nette/NetteApplicationUIPresenter.php index a9caa4d5..ffe7b9cc 100644 --- a/src/LatteTemplateResolver/Nette/NetteApplicationUIPresenter.php +++ b/src/LatteTemplateResolver/Nette/NetteApplicationUIPresenter.php @@ -23,6 +23,7 @@ use function dirname; use function file_exists; use function is_dir; +use function is_file; use function lcfirst; use function preg_replace; use function str_replace; @@ -149,7 +150,13 @@ protected function getClassResult(ClassReflection $classReflection, LatteContext $layoutFilePath = $this->layoutPathResolver->resolve($template); if ($layoutFilePath !== null) { - $result->addTemplate(new Template($layoutFilePath, $classReflection->getName(), $actionName, $actionDefinition['templateContext'])); + if (is_file($layoutFilePath)) { + $result->addTemplate(new Template($layoutFilePath, $classReflection->getName(), $actionName, $actionDefinition['templateContext'])); + } else { + $result->addErrorFromBuilder(RuleErrorBuilder::message('Layout template ' . $layoutFilePath . ' does not exist.') + ->identifier('latte.notFound') + ->file($template)); + } } } @@ -168,7 +175,13 @@ protected function getClassResult(ClassReflection $classReflection, LatteContext $layoutFilePath = $this->layoutPathResolver->resolve($actionDefinition['defaultTemplate']); if ($layoutFilePath !== null) { - $result->addTemplate(new Template($layoutFilePath, $classReflection->getName(), $actionName, $actionDefinition['templateContext'])); + if (is_file($layoutFilePath)) { + $result->addTemplate(new Template($layoutFilePath, $classReflection->getName(), $actionName, $actionDefinition['templateContext'])); + } else { + $result->addErrorFromBuilder(RuleErrorBuilder::message('Layout template ' . $layoutFilePath . ' does not exist.') + ->identifier('latte.notFound') + ->file($actionDefinition['defaultTemplate'])); + } } } diff --git a/src/Resolver/LayoutResolver/LayoutPathResolver.php b/src/Resolver/LayoutResolver/LayoutPathResolver.php index 33626f05..698d01ac 100644 --- a/src/Resolver/LayoutResolver/LayoutPathResolver.php +++ b/src/Resolver/LayoutResolver/LayoutPathResolver.php @@ -8,6 +8,12 @@ use function file_get_contents; use function preg_match; use function realpath; +use function str_contains; +use function str_replace; +use function str_starts_with; +use function stripcslashes; +use function strlen; +use function trim; use const DIRECTORY_SEPARATOR; final class LayoutPathResolver @@ -30,34 +36,22 @@ public function resolve(?string $templatePath): ?string } $templateContent = file_get_contents($templatePath) ?: ''; - $matches = []; - if (preg_match( - '/\{(?:layout|extend|extends)\s+(?:[\'"](?[^\'"]+)[\'"]|(?[^\s,}]+))/', - $templateContent, - $matches - ) === 1) { - // "bare" is the only key PHP can leave unset (see preg_match named-group behavior) - $layoutName = isset($matches['bare']) ? $matches['bare'] : $matches['quoted']; - } else { - $layoutName = null; - } - $isQuoted = !isset($matches['bare']); - if ($layoutName === 'none') { + $expression = $this->extractLayoutExpression($templateContent); + + if ($expression === 'none') { return null; // {layout none} disables the layout completely } - if ($layoutName !== null && $layoutName !== 'auto') { - $layoutFilePath = realpath(dirname($templatePath) . DIRECTORY_SEPARATOR . $layoutName); - if ($layoutFilePath !== false) { - return $layoutFilePath; - } - // keep explicit literal paths so a missing layout is reported instead of - // silently replaced by @layout.latte; dynamic expressions have no path to check - if ($isQuoted || preg_match('/^\$|[(\[]|->|::/', $layoutName) !== 1) { - return dirname($templatePath) . DIRECTORY_SEPARATOR . $layoutName; + if ($expression !== null && $expression !== 'auto') { + $layoutName = $this->resolveLiteral($expression); + if ($layoutName !== null) { + $layoutFilePath = $this->isAbsolutePath($layoutName) + ? $layoutName + : dirname($templatePath) . DIRECTORY_SEPARATOR . $layoutName; + return realpath($layoutFilePath) ?: $layoutFilePath; } } - // no layout tag, {layout auto} or a dynamic layout name + // no layout tag, {layout auto} or a dynamic layout expression $layoutFilePath = realpath(dirname($templatePath) . DIRECTORY_SEPARATOR . '@layout.latte') ?: null; if ($layoutFilePath !== null) { return $layoutFilePath; @@ -66,4 +60,69 @@ public function resolve(?string $templatePath): ?string $layoutFilePath = realpath(dirname($templatePath) . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . '@layout.latte') ?: null; return $layoutFilePath; } + + /** + * Returns the raw layout tag argument, e.g. "'layout.latte'" or "$layout". + */ + private function extractLayoutExpression(string $templateContent): ?string + { + if (preg_match('/\{(?:layout|extend|extends)\s+/', $templateContent, $match, PREG_OFFSET_CAPTURE) !== 1) { + return null; + } + + $offset = $match[0][1] + strlen($match[0][0]); + $length = strlen($templateContent); + $quote = null; + $expression = ''; + for ($i = $offset; $i < $length; $i++) { + $char = $templateContent[$i]; + if ($quote !== null) { + $expression .= $char; + if ($char === '\\' && $i + 1 < $length) { + $expression .= $templateContent[++$i]; + } elseif ($char === $quote) { + $quote = null; + } + continue; + } + if ($char === "'" || $char === '"') { + $quote = $char; + $expression .= $char; + continue; + } + if ($char === '}') { + return trim($expression); + } + $expression .= $char; + } + + return null; + } + + /** + * Returns the value of a single, non-interpolated string literal, or null for any + * other expression (concatenation, variable, interpolation, ...). + */ + private function resolveLiteral(string $expression): ?string + { + if (preg_match('/^\'((?:[^\'\\\\]|\\\\.)*)\'$/s', $expression, $match) === 1) { + return str_replace(["\\'", '\\\\'], ["'", '\\'], $match[1]); + } + if (preg_match('/^"((?:[^"\\\\$]|\\\\.)*)"$/s', $expression, $match) === 1) { + return stripcslashes($match[1]); + } + // Latte also accepts bare layout file names such as ../@layout.latte + if (preg_match('/^[^\s,()\[\]\$\'"{}]+$/', $expression) === 1 + && (str_contains($expression, '/') || str_contains($expression, '.'))) { + return $expression; + } + + return null; + } + + private function isAbsolutePath(string $path): bool + { + return str_starts_with($path, DIRECTORY_SEPARATOR) + || preg_match('/^[A-Za-z]:[\\\\\/]/', $path) === 1; + } } diff --git a/stubs/Latte/Runtime/Filters.stub b/stubs/Latte/Runtime/Filters.stub index 38482ae0..6689973a 100644 --- a/stubs/Latte/Runtime/Filters.stub +++ b/stubs/Latte/Runtime/Filters.stub @@ -48,6 +48,7 @@ class Filters /** * json_encode() accepts any value, including arrays and objects. + * @param mixed $s */ public static function escapeJs($s): string { diff --git a/tests/Compiler/Compiler/Latte3CompilerTest.php b/tests/Compiler/Compiler/Latte3CompilerTest.php new file mode 100644 index 00000000..ad3f5dd6 --- /dev/null +++ b/tests/Compiler/Compiler/Latte3CompilerTest.php @@ -0,0 +1,41 @@ +markTestSkipped('Engine features are a Latte 3.1 feature.'); + } + + $engine = new Engine(); + $engineWithFeature = new Engine(); + $engineWithFeature->setFeature(Feature::ScopedLoopVariables); + + self::assertNotSame( + (new Latte3Compiler($engine))->getCacheKey(), + (new Latte3Compiler($engineWithFeature))->getCacheKey() + ); + } + + public function testDoesNotRewriteStringLiteralsInPosComments(): void + { + if (!LatteVersion::isLatte3()) { + $this->markTestSkipped('Latte 3 compiler only.'); + } + + $phpContent = (new Latte3Compiler())->compile('{php \PHPStan\dumpType(\'/* pos 1:2 */\')}', null); + + self::assertStringContainsString("'/* pos 1:2 */'", $phpContent); + } +} diff --git a/tests/Resolver/LayoutResolver/Fixtures/with-bare.latte b/tests/Resolver/LayoutResolver/Fixtures/with-bare.latte new file mode 100644 index 00000000..4c8d803c --- /dev/null +++ b/tests/Resolver/LayoutResolver/Fixtures/with-bare.latte @@ -0,0 +1 @@ +{layout layout.latte} diff --git a/tests/Resolver/LayoutResolver/Fixtures/with-concat.latte b/tests/Resolver/LayoutResolver/Fixtures/with-concat.latte new file mode 100644 index 00000000..61cad67f --- /dev/null +++ b/tests/Resolver/LayoutResolver/Fixtures/with-concat.latte @@ -0,0 +1 @@ +{layout 'layout' . '.latte'} diff --git a/tests/Resolver/LayoutResolver/Fixtures/with-double-quoted.latte b/tests/Resolver/LayoutResolver/Fixtures/with-double-quoted.latte new file mode 100644 index 00000000..6bfe2a67 --- /dev/null +++ b/tests/Resolver/LayoutResolver/Fixtures/with-double-quoted.latte @@ -0,0 +1 @@ +{layout "layout.latte"} diff --git a/tests/Resolver/LayoutResolver/Fixtures/with-interpolation.latte b/tests/Resolver/LayoutResolver/Fixtures/with-interpolation.latte new file mode 100644 index 00000000..d3ab74aa --- /dev/null +++ b/tests/Resolver/LayoutResolver/Fixtures/with-interpolation.latte @@ -0,0 +1 @@ +{layout "$layout.latte"} diff --git a/tests/Resolver/LayoutResolver/Fixtures/with-quoted-none.latte b/tests/Resolver/LayoutResolver/Fixtures/with-quoted-none.latte new file mode 100644 index 00000000..82fded67 --- /dev/null +++ b/tests/Resolver/LayoutResolver/Fixtures/with-quoted-none.latte @@ -0,0 +1 @@ +{layout 'none'} diff --git a/tests/Resolver/LayoutResolver/LayoutPathResolverTest.php b/tests/Resolver/LayoutResolver/LayoutPathResolverTest.php index e6250a0b..aaa5c568 100644 --- a/tests/Resolver/LayoutResolver/LayoutPathResolverTest.php +++ b/tests/Resolver/LayoutResolver/LayoutPathResolverTest.php @@ -6,7 +6,13 @@ use Efabrica\PHPStanLatte\Resolver\LayoutResolver\LayoutPathResolver; use PHPUnit\Framework\TestCase; +use function file_put_contents; +use function mkdir; use function realpath; +use function rmdir; +use function sys_get_temp_dir; +use function uniqid; +use function unlink; final class LayoutPathResolverTest extends TestCase { @@ -33,6 +39,14 @@ public function testResolvesExtends(): void ); } + public function testResolvesBareLayout(): void + { + self::assertSame( + realpath(__DIR__ . '/Fixtures/layout.latte'), + $this->resolver->resolve(__DIR__ . '/Fixtures/with-bare.latte') + ); + } + public function testLayoutNoneDisablesLayout(): void { self::assertNull($this->resolver->resolve(__DIR__ . '/Fixtures/with-none.latte')); @@ -66,4 +80,54 @@ public function testDisabledFeatureDoesNotResolve(): void { self::assertNull((new LayoutPathResolver(false))->resolve(__DIR__ . '/Fixtures/with-layout.latte')); } + + public function testConcatenatedLayoutIsDynamic(): void + { + self::assertSame( + realpath(__DIR__ . '/Fixtures/@layout.latte'), + $this->resolver->resolve(__DIR__ . '/Fixtures/with-concat.latte') + ); + } + + public function testInterpolatedLayoutIsDynamic(): void + { + self::assertSame( + realpath(__DIR__ . '/Fixtures/@layout.latte'), + $this->resolver->resolve(__DIR__ . '/Fixtures/with-interpolation.latte') + ); + } + + public function testDoubleQuotedLayoutIsResolved(): void + { + self::assertSame( + realpath(__DIR__ . '/Fixtures/layout.latte'), + $this->resolver->resolve(__DIR__ . '/Fixtures/with-double-quoted.latte') + ); + } + + public function testQuotedNoneIsALiteralPath(): void + { + self::assertSame( + __DIR__ . '/Fixtures/none', + $this->resolver->resolve(__DIR__ . '/Fixtures/with-quoted-none.latte') + ); + } + + public function testAbsoluteLayoutPathIsResolved(): void + { + $dir = sys_get_temp_dir() . '/phpstan-latte-layout-' . uniqid('', true); + mkdir($dir); + file_put_contents($dir . '/layout.latte', ''); + file_put_contents($dir . '/template.latte', "{layout '" . $dir . "/layout.latte'}"); + try { + self::assertSame( + realpath($dir . '/layout.latte'), + $this->resolver->resolve($dir . '/template.latte') + ); + } finally { + unlink($dir . '/layout.latte'); + unlink($dir . '/template.latte'); + rmdir($dir); + } + } } diff --git a/tests/Rule/LatteTemplatesRule/EngineBootstrap/LatteTemplatesRuleForEngineBootstrapFirstClassCallableFilterTest.php b/tests/Rule/LatteTemplatesRule/EngineBootstrap/LatteTemplatesRuleForEngineBootstrapFirstClassCallableFilterTest.php index f5f055e2..66c459e2 100644 --- a/tests/Rule/LatteTemplatesRule/EngineBootstrap/LatteTemplatesRuleForEngineBootstrapFirstClassCallableFilterTest.php +++ b/tests/Rule/LatteTemplatesRule/EngineBootstrap/LatteTemplatesRuleForEngineBootstrapFirstClassCallableFilterTest.php @@ -7,7 +7,7 @@ use Efabrica\PHPStanLatte\Tests\Rule\LatteTemplatesRule\LatteTemplatesRuleTest; /** - * @requires PHP > 8.1 + * @requires PHP >= 8.1 */ final class LatteTemplatesRuleForEngineBootstrapFirstClassCallableFilterTest extends LatteTemplatesRuleTest { diff --git a/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/Fixtures/MissingLayoutPresenter.php b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/Fixtures/MissingLayoutPresenter.php new file mode 100644 index 00000000..ff3f0d54 --- /dev/null +++ b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/Fixtures/MissingLayoutPresenter.php @@ -0,0 +1,15 @@ +template->title = 'title'; + } +} diff --git a/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/Fixtures/ResolveTypesPresenter.php b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/Fixtures/ResolveTypesPresenter.php new file mode 100644 index 00000000..96ba8419 --- /dev/null +++ b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/Fixtures/ResolveTypesPresenter.php @@ -0,0 +1,15 @@ +template->title = 'title'; + } +} diff --git a/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/Fixtures/TemplateTypesPresenter.php b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/Fixtures/TemplateTypesPresenter.php new file mode 100644 index 00000000..8c37ccae --- /dev/null +++ b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/Fixtures/TemplateTypesPresenter.php @@ -0,0 +1,18 @@ +template->title = 'title'; + $customTemplate = new CustomTemplate(); + $customTemplate->setFile(__DIR__ . '/templates/TemplateTypes/other.latte'); + } +} diff --git a/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/Fixtures/templates/MissingLayout/default.latte b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/Fixtures/templates/MissingLayout/default.latte new file mode 100644 index 00000000..60ddaf9d --- /dev/null +++ b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/Fixtures/templates/MissingLayout/default.latte @@ -0,0 +1,2 @@ +{layout 'missing.latte'} +{$title} diff --git a/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/Fixtures/templates/ResolveTypes/default.latte b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/Fixtures/templates/ResolveTypes/default.latte new file mode 100644 index 00000000..4eee85b6 --- /dev/null +++ b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/Fixtures/templates/ResolveTypes/default.latte @@ -0,0 +1,5 @@ +{layout none} +{snippet foo} + {$title} +{/snippet} +{123|closureFilter, 456} diff --git a/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/Fixtures/templates/TemplateTypes/default.latte b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/Fixtures/templates/TemplateTypes/default.latte new file mode 100644 index 00000000..45044a1f --- /dev/null +++ b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/Fixtures/templates/TemplateTypes/default.latte @@ -0,0 +1,2 @@ +{layout none} +{$title} diff --git a/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/Fixtures/templates/TemplateTypes/other.latte b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/Fixtures/templates/TemplateTypes/other.latte new file mode 100644 index 00000000..44dda71d --- /dev/null +++ b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/Fixtures/templates/TemplateTypes/other.latte @@ -0,0 +1,2 @@ +{layout none} +{$undefinedVariable} diff --git a/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterBreakIncludeCyclesTest.php b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterBreakIncludeCyclesTest.php new file mode 100644 index 00000000..0b584b51 --- /dev/null +++ b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterBreakIncludeCyclesTest.php @@ -0,0 +1,92 @@ +analyse([__DIR__ . '/Fixtures/RecursionPresenter.php'], [ + [ + 'Dumped type: 9', + 1, + '@indirectRecursionB.latte', + ], + [ + 'Undefined variable: $title', + 7, + '@layout.latte', + ], + [ + 'Undefined variable: $title', + 7, + '@layout.latte', + ], + [ + 'Undefined variable: $title', + 7, + '@layout.latte', + ], + [ + 'Undefined variable: $title', + 7, + '@layout.latte', + ], + [ + 'Undefined variable: $neverDefined', + 10, + '@layout.latte', + ], + [ + 'Undefined variable: $neverDefined', + 10, + '@layout.latte', + ], + [ + 'Undefined variable: $neverDefined', + 10, + '@layout.latte', + ], + [ + 'Undefined variable: $neverDefined', + 10, + '@layout.latte', + ], + [ + 'Dumped type: 10', + 1, + 'indirectRecursion.latte', + ], + [ + 'Comparison operation ">" between 10 and 0 is always true.', + 2, + 'indirectRecursion.latte', + ], + [ + 'Dumped type: 10', + 1, + 'recursion.latte', + ], + [ + 'Comparison operation ">" between 10 and 0 is always true.', + 2, + 'recursion.latte', + ], + ]); + } +} diff --git a/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterMaxTemplateVariantsTest.php b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterMaxTemplateVariantsTest.php new file mode 100644 index 00000000..69c04143 --- /dev/null +++ b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterMaxTemplateVariantsTest.php @@ -0,0 +1,97 @@ +analyse([__DIR__ . '/Fixtures/RecursionPresenter.php'], [ + [ + 'Dumped type: 9', + 1, + '@indirectRecursionB.latte', + ], + [ + 'Dumped type: 7', + 1, + '@indirectRecursionB.latte', + ], + [ + 'Undefined variable: $title', + 7, + '@layout.latte', + ], + [ + 'Undefined variable: $title', + 7, + '@layout.latte', + ], + [ + 'Undefined variable: $neverDefined', + 10, + '@layout.latte', + ], + [ + 'Undefined variable: $neverDefined', + 10, + '@layout.latte', + ], + [ + 'Dumped type: 10', + 1, + 'indirectRecursion.latte', + ], + [ + 'Dumped type: 8', + 1, + 'indirectRecursion.latte', + ], + [ + 'Comparison operation ">" between 10 and 0 is always true.', + 2, + 'indirectRecursion.latte', + ], + [ + 'Comparison operation ">" between 8 and 0 is always true.', + 2, + 'indirectRecursion.latte', + ], + [ + 'Dumped type: 10', + 1, + 'recursion.latte', + ], + [ + 'Dumped type: 9', + 1, + 'recursion.latte', + ], + [ + 'Comparison operation ">" between 10 and 0 is always true.', + 2, + 'recursion.latte', + ], + [ + 'Comparison operation ">" between 9 and 0 is always true.', + 2, + 'recursion.latte', + ], + ]); + } +} diff --git a/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterResolveTypesFalseTest.php b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterResolveTypesFalseTest.php new file mode 100644 index 00000000..c2eae456 --- /dev/null +++ b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterResolveTypesFalseTest.php @@ -0,0 +1,32 @@ +analyse([__DIR__ . '/Fixtures/ResolveTypesPresenter.php'], [ + [ + 'Parameter #1 of closure expects string, int given.', + 5, + 'default.latte', + ], + ]); + } +} diff --git a/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterTemplateTypesTest.php b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterTemplateTypesTest.php new file mode 100644 index 00000000..b75ea042 --- /dev/null +++ b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterTemplateTypesTest.php @@ -0,0 +1,32 @@ +analyse([__DIR__ . '/Fixtures/TemplateTypesPresenter.php'], [ + [ + 'Undefined variable: $undefinedVariable', + 2, + 'other.latte', + ], + ]); + } +} diff --git a/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterTest.php b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterTest.php index 1c7934ab..3439a533 100644 --- a/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterTest.php +++ b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterTest.php @@ -3079,6 +3079,17 @@ public function testSnippets(): void $this->analyse([__DIR__ . '/Fixtures/SnippetsPresenter.php'], $expectedErrors); } + public function testMissingLayout(): void + { + $this->analyse([__DIR__ . '/Fixtures/MissingLayoutPresenter.php'], [ + [ + 'Layout template ' . __DIR__ . '/Fixtures/templates/MissingLayout/missing.latte does not exist.', + -1, + 'default.latte', + ], + ]); + } + public function testEscapes(): void { if (!LatteVersion::isLatte31()) { diff --git a/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/Source/CustomTemplate.php b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/Source/CustomTemplate.php new file mode 100644 index 00000000..b970410f --- /dev/null +++ b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/Source/CustomTemplate.php @@ -0,0 +1,20 @@ + Date: Wed, 28 Oct 2026 10:49:30 +0300 Subject: [PATCH 26/27] fix: green CI on older Latte and tokenizer dependency - ignore the mixed feature-name concatenation when self-analysing against Latte 2.11/3.0 - import PREG_OFFSET_CAPTURE for the coding standard - require ext-tokenizer now that the compiler uses token_get_all() --- composer.json | 1 + phpstan.neon | 1 + src/Resolver/LayoutResolver/LayoutPathResolver.php | 1 + 3 files changed, 3 insertions(+) diff --git a/composer.json b/composer.json index d492e044..0179a662 100644 --- a/composer.json +++ b/composer.json @@ -8,6 +8,7 @@ "require": { "php": ">=7.4 <8.6", "ext-json": "*", + "ext-tokenizer": "*", "phpstan/phpstan": "^2.1.33", "phpstan/phpdoc-parser": "^2.3.0", "phpstan/phpstan-nette": "^2.0.7", diff --git a/phpstan.neon b/phpstan.neon index c2426501..f591e133 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -166,6 +166,7 @@ parameters: - '#^Parameter \#1 \$feature of method Latte\\Engine\:\:hasFeature\(\) expects string, mixed given\.$#' - '#^Possibly invalid array key type mixed\.$#' - '#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\.$#' + - '#^Binary operation "\." between mixed and .* results in an error\.$#' - '#^Call to an undefined method Latte\\Engine\:\:getExtensions\(\)\.$#' - '#^Call to an undefined method Latte\\Engine\:\:getFunctions\(\)\.$#' - '#^Parameter \$extensions of method Efabrica\\PHPStanLatte\\Compiler\\Compiler\\Latte3Compiler\:\:installExtensions\(\) has invalid type Latte\\Extension\.$#' diff --git a/src/Resolver/LayoutResolver/LayoutPathResolver.php b/src/Resolver/LayoutResolver/LayoutPathResolver.php index 698d01ac..6866c385 100644 --- a/src/Resolver/LayoutResolver/LayoutPathResolver.php +++ b/src/Resolver/LayoutResolver/LayoutPathResolver.php @@ -15,6 +15,7 @@ use function strlen; use function trim; use const DIRECTORY_SEPARATOR; +use const PREG_OFFSET_CAPTURE; final class LayoutPathResolver { From 2221f7a571ab318fd2be7a91ab243239b7ac7669 Mon Sep 17 00:00:00 2001 From: Alexander Minkin Date: Wed, 28 Oct 2026 11:40:34 +0300 Subject: [PATCH 27/27] fix: validate template paths and report skipped variants - reject non-file setFile() template paths with latte.notFound instead of letting them crash compilation, and guard LayoutPathResolver::resolve() against non-file inputs - treat extensionless unquoted layout names ({layout main}) as literal paths again, while keeping interpolated/quoted expressions dynamic - emit warnings when maxTemplateVariants or breakIncludeCycles skip a variant/include so analysis gaps are visible, preserving explicit is_warning metadata in ErrorBuilder - document the resolveTypes tradeoff for forms and the new skipped-variant warnings - add missing setFile and extensionless layout regression tests --- docs/configuration.md | 4 +-- docs/features.md | 3 +- src/Error/ErrorBuilder.php | 2 +- .../Nette/NetteApplicationUIPresenter.php | 10 ++++++ .../LayoutResolver/LayoutPathResolver.php | 11 +++--- src/Rule/LatteTemplatesRule.php | 35 +++++++++++++++++++ tests/Resolver/LayoutResolver/Fixtures/main | 1 + .../Fixtures/with-extensionless.latte | 1 + .../LayoutResolver/LayoutPathResolverTest.php | 8 +++++ .../Fixtures/SetFileMissingPresenter.php | 15 ++++++++ .../templates/SetFileMissing/default.latte | 2 ++ ...RuleForPresenterBreakIncludeCyclesTest.php | 10 ++++++ ...uleForPresenterMaxTemplateVariantsTest.php | 15 ++++++++ ...latesRuleForPresenterTemplateTypesTest.php | 5 +++ .../LatteTemplatesRuleForPresenterTest.php | 11 ++++++ 15 files changed, 124 insertions(+), 9 deletions(-) create mode 100644 tests/Resolver/LayoutResolver/Fixtures/main create mode 100644 tests/Resolver/LayoutResolver/Fixtures/with-extensionless.latte create mode 100644 tests/Rule/LatteTemplatesRule/PresenterWithoutModule/Fixtures/SetFileMissingPresenter.php create mode 100644 tests/Rule/LatteTemplatesRule/PresenterWithoutModule/Fixtures/templates/SetFileMissing/default.latte diff --git a/docs/configuration.md b/docs/configuration.md index 79f5ccd5..a2582ea1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -197,7 +197,7 @@ Type: `int` Maximum number of variants analysed for a single template path during one run. `0` means no limit. Lowering it bounds memory and time on include-heavy projects at the cost of skipping some template -variants. +variants. Skipped variants are reported as warnings so gaps stay visible. Default: ```neon @@ -210,7 +210,7 @@ parameters: Type: `bool` When enabled, an `{include}` that would recurse into the template itself or into one of its parents -is skipped instead of being analysed again. +is skipped instead of being analysed again. Skipped recursive includes are reported as warnings. Default: ```neon diff --git a/docs/features.md b/docs/features.md index f188d811..876f7bf6 100644 --- a/docs/features.md +++ b/docs/features.md @@ -70,7 +70,8 @@ caches cannot be released in PHP). Disabling the pass skips the type attribution. Type-aware visitors then have no inferred types to work with, while semantic transformations (filters, functions, snippets) still run. This saves memory at the cost of less precise analysis -inside templates: +inside templates; form-specific visitors in particular need inferred types and +will skip their transformations: ```neon parameters: diff --git a/src/Error/ErrorBuilder.php b/src/Error/ErrorBuilder.php index ef7393d9..5598f5e8 100644 --- a/src/Error/ErrorBuilder.php +++ b/src/Error/ErrorBuilder.php @@ -184,7 +184,7 @@ public function buildRuleErrors(array $ruleErrors): array if ($ruleError instanceof MetadataRuleError) { $metaData = $ruleError->getMetadata(); } - $metaData['is_warning'] = $this->isWarning($ruleError->getMessage()); + $metaData['is_warning'] = $metaData['is_warning'] ?? $this->isWarning($ruleError->getMessage()); $newRuleError->metadata($metaData); if ($ruleError instanceof FileRuleError) { diff --git a/src/LatteTemplateResolver/Nette/NetteApplicationUIPresenter.php b/src/LatteTemplateResolver/Nette/NetteApplicationUIPresenter.php index ffe7b9cc..35b3e253 100644 --- a/src/LatteTemplateResolver/Nette/NetteApplicationUIPresenter.php +++ b/src/LatteTemplateResolver/Nette/NetteApplicationUIPresenter.php @@ -146,6 +146,16 @@ protected function getClassResult(ClassReflection $classReflection, LatteContext ->line($actionDefinition['line'])); continue; } + if (!is_file($template)) { + $errorBuilder = RuleErrorBuilder::message('Template ' . $template . ' does not exist.') + ->identifier('latte.notFound'); + $presenterFile = $classReflection->getFileName(); + if ($presenterFile !== null && is_file($presenterFile)) { + $errorBuilder->file($presenterFile); + } + $result->addErrorFromBuilder($errorBuilder); + continue; + } $result->addTemplate(new Template($template, $classReflection->getName(), $actionName, $actionDefinition['templateContext'])); $layoutFilePath = $this->layoutPathResolver->resolve($template); diff --git a/src/Resolver/LayoutResolver/LayoutPathResolver.php b/src/Resolver/LayoutResolver/LayoutPathResolver.php index 6866c385..6727aaeb 100644 --- a/src/Resolver/LayoutResolver/LayoutPathResolver.php +++ b/src/Resolver/LayoutResolver/LayoutPathResolver.php @@ -6,9 +6,9 @@ use function dirname; use function file_get_contents; +use function is_file; use function preg_match; use function realpath; -use function str_contains; use function str_replace; use function str_starts_with; use function stripcslashes; @@ -32,7 +32,7 @@ public function resolve(?string $templatePath): ?string return null; } - if ($templatePath === null) { + if ($templatePath === null || !is_file($templatePath)) { return null; } @@ -112,9 +112,10 @@ private function resolveLiteral(string $expression): ?string if (preg_match('/^"((?:[^"\\\\$]|\\\\.)*)"$/s', $expression, $match) === 1) { return stripcslashes($match[1]); } - // Latte also accepts bare layout file names such as ../@layout.latte - if (preg_match('/^[^\s,()\[\]\$\'"{}]+$/', $expression) === 1 - && (str_contains($expression, '/') || str_contains($expression, '.'))) { + // unquoted literal name (including extensionless names and ../@layout.latte); + // variables, calls, arrays and constants are expressions + if (preg_match('/^\$|[(\[]|->|::|[\'"]/', $expression) !== 1 + && preg_match('/^[^\s,}]+$/', $expression) === 1) { return $expression; } diff --git a/src/Rule/LatteTemplatesRule.php b/src/Rule/LatteTemplatesRule.php index d416adc1..e5a156d3 100644 --- a/src/Rule/LatteTemplatesRule.php +++ b/src/Rule/LatteTemplatesRule.php @@ -46,6 +46,7 @@ use function pathinfo; use function realpath; use function shell_exec; +use function sprintf; use function str_replace; use function strlen; use function strpos; @@ -61,6 +62,12 @@ final class LatteTemplatesRule implements Rule /** @var array number of analysed variants per template path in the current run */ private array $templateVariantCounts = []; + /** @var array variants skipped by maxTemplateVariants in the current run */ + private array $skippedTemplateVariants = []; + + /** @var array recursive includes skipped by breakIncludeCycles in the current run */ + private array $skippedIncludeCycles = []; + /** @var LatteTemplateResolverInterface[] */ private array $latteTemplateResolvers; @@ -137,6 +144,8 @@ public function getNodeType(): string public function processNode(Node $collectedDataNode, Scope $scope): array { $this->templateVariantCounts = []; + $this->skippedTemplateVariants = []; + $this->skippedIncludeCycles = []; $resolvedNodeFinder = new ResolvedNodeFinder($collectedDataNode, $this->latteTemplateResolvers); @@ -155,6 +164,7 @@ public function processNode(Node $collectedDataNode, Scope $scope): array } $compiledTemplates = $this->compileTemplates($templates, $errors); + $errors = array_merge($errors, $this->buildSkippedVariantWarnings()); $errors = array_merge(array_filter($errors), $this->analyseTemplates($compiledTemplates)); if (count($errors) > 1000) { @@ -190,6 +200,7 @@ private function compileTemplates(array $templates, array &$errors, ?array &$alr if ($this->maxTemplateVariants > 0) { $variants = $this->templateVariantCounts[$templatePath] ?? 0; if ($variants >= $this->maxTemplateVariants) { + $this->skippedTemplateVariants[$templatePath] = ($this->skippedTemplateVariants[$templatePath] ?? 0) + 1; continue; } $this->templateVariantCounts[$templatePath] = $variants + 1; @@ -251,6 +262,7 @@ private function compileTemplates(array $templates, array &$errors, ?array &$alr } else { $includedTemplatePath = realpath($includedTemplatePath) ?: $includedTemplatePath; if ($this->breakIncludeCycles && ($includedTemplatePath === $template->getPath() || in_array($includedTemplatePath, $template->getParentTemplatePaths(), true))) { + $this->skippedIncludeCycles[$includedTemplatePath] = ($this->skippedIncludeCycles[$includedTemplatePath] ?? 0) + 1; continue; // include cycle: contexts degrade and their number explodes } $includeTemplate = new Template( @@ -284,6 +296,29 @@ private function compileTemplates(array $templates, array &$errors, ?array &$alr return $compiledTemplates; } + /** + * @return IdentifierRuleError[] + */ + private function buildSkippedVariantWarnings(): array + { + $warnings = []; + foreach ($this->skippedTemplateVariants as $path => $count) { + $warnings[] = RuleErrorBuilder::message(sprintf('Skipped %d variant(s) of template %s because of latte.maxTemplateVariants.', $count, $path)) + ->identifier('latte.skippedVariant') + ->file($path) + ->metadata(['is_warning' => true]) + ->build(); + } + foreach ($this->skippedIncludeCycles as $path => $count) { + $warnings[] = RuleErrorBuilder::message(sprintf('Skipped %d recursive include(s) of template %s because of latte.breakIncludeCycles.', $count, $path)) + ->identifier('latte.skippedIncludeCycle') + ->file($path) + ->metadata(['is_warning' => true]) + ->build(); + } + return $warnings; + } + /** * @param array $templates * @return IdentifierRuleError[] diff --git a/tests/Resolver/LayoutResolver/Fixtures/main b/tests/Resolver/LayoutResolver/Fixtures/main new file mode 100644 index 00000000..f4fe3e29 --- /dev/null +++ b/tests/Resolver/LayoutResolver/Fixtures/main @@ -0,0 +1 @@ +main layout diff --git a/tests/Resolver/LayoutResolver/Fixtures/with-extensionless.latte b/tests/Resolver/LayoutResolver/Fixtures/with-extensionless.latte new file mode 100644 index 00000000..dc1d4884 --- /dev/null +++ b/tests/Resolver/LayoutResolver/Fixtures/with-extensionless.latte @@ -0,0 +1 @@ +{layout main} diff --git a/tests/Resolver/LayoutResolver/LayoutPathResolverTest.php b/tests/Resolver/LayoutResolver/LayoutPathResolverTest.php index aaa5c568..1b3eb208 100644 --- a/tests/Resolver/LayoutResolver/LayoutPathResolverTest.php +++ b/tests/Resolver/LayoutResolver/LayoutPathResolverTest.php @@ -47,6 +47,14 @@ public function testResolvesBareLayout(): void ); } + public function testResolvesExtensionlessBareLayout(): void + { + self::assertSame( + realpath(__DIR__ . '/Fixtures/main'), + $this->resolver->resolve(__DIR__ . '/Fixtures/with-extensionless.latte') + ); + } + public function testLayoutNoneDisablesLayout(): void { self::assertNull($this->resolver->resolve(__DIR__ . '/Fixtures/with-none.latte')); diff --git a/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/Fixtures/SetFileMissingPresenter.php b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/Fixtures/SetFileMissingPresenter.php new file mode 100644 index 00000000..87c457d9 --- /dev/null +++ b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/Fixtures/SetFileMissingPresenter.php @@ -0,0 +1,15 @@ +template->setFile(__DIR__ . '/templates/SetFileMissing/missing.latte'); + } +} diff --git a/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/Fixtures/templates/SetFileMissing/default.latte b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/Fixtures/templates/SetFileMissing/default.latte new file mode 100644 index 00000000..ee2d0082 --- /dev/null +++ b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/Fixtures/templates/SetFileMissing/default.latte @@ -0,0 +1,2 @@ +{layout none} +ok diff --git a/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterBreakIncludeCyclesTest.php b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterBreakIncludeCyclesTest.php index 0b584b51..faea579a 100644 --- a/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterBreakIncludeCyclesTest.php +++ b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterBreakIncludeCyclesTest.php @@ -22,6 +22,16 @@ protected static function additionalConfigFiles(): array public function testRecursion(): void { $this->analyse([__DIR__ . '/Fixtures/RecursionPresenter.php'], [ + [ + 'Skipped 1 recursive include(s) of template ' . __DIR__ . '/Fixtures/templates/Recursion/indirectRecursion.latte because of latte.breakIncludeCycles.', + -1, + 'indirectRecursion.latte', + ], + [ + 'Skipped 1 recursive include(s) of template ' . __DIR__ . '/Fixtures/templates/Recursion/recursion.latte because of latte.breakIncludeCycles.', + -1, + 'recursion.latte', + ], [ 'Dumped type: 9', 1, diff --git a/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterMaxTemplateVariantsTest.php b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterMaxTemplateVariantsTest.php index 69c04143..c42fecf5 100644 --- a/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterMaxTemplateVariantsTest.php +++ b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterMaxTemplateVariantsTest.php @@ -22,6 +22,21 @@ protected static function additionalConfigFiles(): array public function testRecursion(): void { $this->analyse([__DIR__ . '/Fixtures/RecursionPresenter.php'], [ + [ + 'Skipped 2 variant(s) of template ' . __DIR__ . '/Fixtures/templates/@layout.latte because of latte.maxTemplateVariants.', + -1, + '@layout.latte', + ], + [ + 'Skipped 1 variant(s) of template ' . __DIR__ . '/Fixtures/templates/Recursion/indirectRecursion.latte because of latte.maxTemplateVariants.', + -1, + 'indirectRecursion.latte', + ], + [ + 'Skipped 1 variant(s) of template ' . __DIR__ . '/Fixtures/templates/Recursion/recursion.latte because of latte.maxTemplateVariants.', + -1, + 'recursion.latte', + ], [ 'Dumped type: 9', 1, diff --git a/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterTemplateTypesTest.php b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterTemplateTypesTest.php index b75ea042..30b52921 100644 --- a/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterTemplateTypesTest.php +++ b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterTemplateTypesTest.php @@ -22,6 +22,11 @@ protected static function additionalConfigFiles(): array public function testCustomTemplateIsAnalysed(): void { $this->analyse([__DIR__ . '/Fixtures/TemplateTypesPresenter.php'], [ + [ + 'Skipped 1 variant(s) of template ' . __DIR__ . '/Fixtures/templates/TemplateTypes/other.latte because of latte.maxTemplateVariants.', + -1, + 'other.latte', + ], [ 'Undefined variable: $undefinedVariable', 2, diff --git a/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterTest.php b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterTest.php index 3439a533..7ca3780f 100644 --- a/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterTest.php +++ b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterTest.php @@ -3090,6 +3090,17 @@ public function testMissingLayout(): void ]); } + public function testMissingSetFile(): void + { + $this->analyse([__DIR__ . '/Fixtures/SetFileMissingPresenter.php'], [ + [ + 'Template ' . __DIR__ . '/Fixtures/templates/SetFileMissing/missing.latte does not exist.', + -1, + 'SetFileMissingPresenter.php', + ], + ]); + } + public function testEscapes(): void { if (!LatteVersion::isLatte31()) {