diff --git a/composer.json b/composer.json index 0866db90..0179a662 100644 --- a/composer.json +++ b/composer.json @@ -1,5 +1,6 @@ { "name": "efabrica/phpstan-latte", + "version": "0.20.1", "license": ["MIT"], "keywords": [ "static analysis" @@ -7,12 +8,14 @@ "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", - "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" + "nette/finder": "^2.5|^3.0", + "symfony/polyfill-php80": "^1.16" }, "require-dev": { "phpunit/phpunit": "^9.6.31", diff --git a/docs/configuration.md b/docs/configuration.md index 36109829..a2582ea1 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. Skipped variants are reported as warnings so gaps stay visible. + +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. Skipped recursive includes are reported as warnings. + +Default: +```neon +parameters: + latte: + breakIncludeCycles: false +``` + ## Errors ### errorPatternsToIgnore diff --git a/docs/features.md b/docs/features.md index e96d07df..876f7bf6 100644 --- a/docs/features.md +++ b/docs/features.md @@ -55,3 +55,27 @@ 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 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; form-specific visitors in particular need inferred types and +will skip their transformations: + +```neon +parameters: + latte: + resolveTypes: false +``` + diff --git a/extension.neon b/extension.neon index f61977ba..99eda472 100644 --- a/extension.neon +++ b/extension.neon @@ -6,6 +6,8 @@ parameters: - stubs/Latte/Runtime/FilterInfo.stub - stubs/Latte/Runtime/Filters.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 @@ -31,7 +33,11 @@ parameters: extensions: [] resolveAllPossiblePaths: false reportUnanalysedTemplates: false + maxTemplateVariants: 0 + breakIncludeCycles: false + resolveTypes: true collectedPaths: [] + templateTypes: [] features: transformDynamicFormControlNamesToString: false phpstanCommand: null @@ -62,7 +68,11 @@ parametersSchema: extensions: arrayOf(object()) resolveAllPossiblePaths: bool() reportUnanalysedTemplates: bool() + maxTemplateVariants: int() + breakIncludeCycles: bool() + resolveTypes: bool() collectedPaths: arrayOf(string()) + templateTypes: arrayOf(string()) features: structure([ transformDynamicFormControlNamesToString: bool() phpstanCommand: schema(string(), nullable()) @@ -118,6 +128,7 @@ services: factory: Efabrica\PHPStanLatte\Compiler\Postprocessor arguments: parser: @latteCurrentPhpVersionRichParser + resolveTypes: %latte.resolveTypes% - Efabrica\PHPStanLatte\Compiler\TypeToPhpDoc - Efabrica\PHPStanLatte\Compiler\Helper\TemplateContextHelper @@ -127,6 +138,7 @@ services: arguments: parser: @latteCurrentPhpVersionRichParser debugMode: %latte.debugMode% + cacheKey: ::md5(::json_encode(%latte%)) # Latte template resolvers - Efabrica\PHPStanLatte\LatteTemplateResolver\Nette\NetteApplicationUIPresenter @@ -135,7 +147,21 @@ services: # Type resolvers - Efabrica\PHPStanLatte\Resolver\TypeResolver\TypeResolver - - Efabrica\PHPStanLatte\Resolver\TypeResolver\TemplateTypeResolver + - 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() diff --git a/phpstan.neon b/phpstan.neon index 240bac69..f591e133 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\.$#' @@ -158,6 +154,19 @@ 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\(\)\.$#' + - '#^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\.$#' + - '#^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\.$#' @@ -165,7 +174,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 @@ -199,6 +208,14 @@ parameters: paths: - 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/rules.neon b/rules.neon index 731666e3..f552477a 100644 --- a/rules.neon +++ b/rules.neon @@ -6,5 +6,7 @@ services: factory: Efabrica\PHPStanLatte\Rule\LatteTemplatesRule() arguments: phpstanCommand: %latte.features.phpstanCommand% + maxTemplateVariants: %latte.maxTemplateVariants% + breakIncludeCycles: %latte.breakIncludeCycles% tags: - phpstan.rules.rule diff --git a/src/Analyser/LatteContextAnalyser.php b/src/Analyser/LatteContextAnalyser.php index dd0b2e3b..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'; @@ -226,7 +231,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 +248,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 +271,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 +291,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 +309,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/Compiler/Latte3Compiler.php b/src/Compiler/Compiler/Latte3Compiler.php index df299994..651fba9f 100644 --- a/src/Compiler/Compiler/Latte3Compiler.php +++ b/src/Compiler/Compiler/Latte3Compiler.php @@ -4,12 +4,15 @@ namespace Efabrica\PHPStanLatte\Compiler\Compiler; +use Efabrica\PHPStanLatte\Compiler\LatteVersion; use Latte\Compiler\TemplateGenerator; 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; use function array_keys; use function array_map; use function array_merge; @@ -20,6 +23,9 @@ use function is_object; 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 { @@ -44,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 ); } @@ -88,12 +101,32 @@ 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 (LatteVersion::isLatte31()) { + // Latte 3.1+ (TemplateGenerator::generate() was split into buildClass() + generateCode()) + $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 { + /** @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. + $classHeader = "final class {$className} extends"; + $classComment = str_replace('*/', '* /', $this->generateClassComment($className, $context)); + $phpContent = str_replace( + $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; @@ -111,6 +144,9 @@ private function installExtensions(array $extensions): void private function fixLines(string $phpContent): string { + // Latte 3.1+ emits "/* pos [:] */" instead of "/* line */" + $phpContent = $this->normalizePosComments($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) ?: ''; @@ -119,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/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/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/Compiler/NodeVisitor/ChangeFiltersNodeVisitor.php b/src/Compiler/NodeVisitor/ChangeFiltersNodeVisitor.php index 047a8261..92a00cf3 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,39 @@ 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[0] === '{' || !$scopeClass->hasMethod($methodName)) { + 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(); + $className = $closureThis !== null ? get_class($closureThis) : $scopeClass->getName(); + + return [$className, $methodName]; + } + private function addFilterVariables(ClassMethod $node): void { $class = $node->getAttribute('parent'); @@ -142,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; } @@ -155,7 +194,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 === []) { @@ -182,6 +221,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 */ @@ -239,6 +282,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 6920eca2..78c66bc9 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,39 @@ 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[0] === '{' || !$scopeClass->hasMethod($methodName)) { + 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(); + $className = $closureThis !== null ? get_class($closureThis) : $scopeClass->getName(); + + return [$className, $methodName]; + } + private function addFunctionVariables(ClassMethod $node): void { $class = $node->getAttribute('parent'); @@ -151,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; } @@ -164,7 +203,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 === []) { @@ -191,6 +230,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 */ @@ -248,6 +291,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/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 18d3e9b6..b4dc22a4 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 @@ -102,23 +106,30 @@ 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)); $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 3417518d..5598f5e8 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; @@ -73,6 +74,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[] */ @@ -139,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, @@ -147,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())); } @@ -178,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/Error/Transformer/EscapeErrorTransformer.php b/src/Error/Transformer/EscapeErrorTransformer.php index 9357cdbb..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::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 = '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/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..35b3e253 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; @@ -145,11 +146,27 @@ 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); 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 +185,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/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/Resolver/LayoutResolver/LayoutPathResolver.php b/src/Resolver/LayoutResolver/LayoutPathResolver.php index e023c5ca..6727aaeb 100644 --- a/src/Resolver/LayoutResolver/LayoutPathResolver.php +++ b/src/Resolver/LayoutResolver/LayoutPathResolver.php @@ -6,9 +6,16 @@ use function dirname; use function file_get_contents; +use function is_file; use function preg_match; use function realpath; +use function str_replace; +use function str_starts_with; +use function stripcslashes; +use function strlen; +use function trim; use const DIRECTORY_SEPARATOR; +use const PREG_OFFSET_CAPTURE; final class LayoutPathResolver { @@ -25,18 +32,27 @@ public function resolve(?string $templatePath): ?string return null; } - if ($templatePath === null) { + if ($templatePath === null || !is_file($templatePath)) { return null; } $templateContent = file_get_contents($templatePath) ?: ''; - preg_match('/{(layout|extend) (?.*?)}/', $templateContent, $match); + $expression = $this->extractLayoutExpression($templateContent); - if (isset($match['layout_name'])) { - $layoutFilePath = dirname($templatePath) . DIRECTORY_SEPARATOR . $match['layout_name']; - return $layoutFilePath; + if ($expression === 'none') { + return null; // {layout none} disables the layout completely + } + 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 expression $layoutFilePath = realpath(dirname($templatePath) . DIRECTORY_SEPARATOR . '@layout.latte') ?: null; if ($layoutFilePath !== null) { return $layoutFilePath; @@ -45,4 +61,70 @@ 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]); + } + // 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; + } + + 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/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 diff --git a/src/Rule/LatteTemplatesRule.php b/src/Rule/LatteTemplatesRule.php index 0156e81e..e5a156d3 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; @@ -45,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; @@ -57,6 +59,15 @@ */ 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; @@ -82,6 +93,10 @@ final class LatteTemplatesRule implements Rule private ?string $phpstanCommand; + private int $maxTemplateVariants; + + private bool $breakIncludeCycles; + /** * @param LatteTemplateResolverInterface[] $latteTemplateResolvers * @param TemplateRenderCollector[] $templateRenderCollectors @@ -98,7 +113,9 @@ public function __construct( array $templateRenderCollectors, TemplateContextHelper $templateContextHelper, TempDirResolver $tempDirResolver, - ?string $phpstanCommand + ?string $phpstanCommand, + int $maxTemplateVariants = 0, + bool $breakIncludeCycles = false ) { $this->latteTemplateResolvers = $latteTemplateResolvers; $this->latteToPhpCompiler = $latteToPhpCompiler; @@ -112,6 +129,8 @@ public function __construct( $this->templateContextHelper = $templateContextHelper; $this->tempDirResolver = $tempDirResolver; $this->phpstanCommand = $phpstanCommand; + $this->maxTemplateVariants = $maxTemplateVariants; + $this->breakIncludeCycles = $breakIncludeCycles; } public function getNodeType(): string @@ -124,6 +143,10 @@ public function getNodeType(): string */ public function processNode(Node $collectedDataNode, Scope $scope): array { + $this->templateVariantCounts = []; + $this->skippedTemplateVariants = []; + $this->skippedIncludeCycles = []; + $resolvedNodeFinder = new ResolvedNodeFinder($collectedDataNode, $this->latteTemplateResolvers); $analysedFiles = $resolvedNodeFinder->getAnalysedFiles(); @@ -141,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) { @@ -173,6 +197,15 @@ 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) { + $this->skippedTemplateVariants[$templatePath] = ($this->skippedTemplateVariants[$templatePath] ?? 0) + 1; + continue; + } + $this->templateVariantCounts[$templatePath] = $variants + 1; + } + if ($this->analysedTemplatesRegistry->isExcludedFromAnalysing($templatePath)) { continue; } @@ -228,6 +261,10 @@ 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( $includedTemplatePath, $template->getActualClass(), @@ -259,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/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()); } } 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/src/Type/TypeHelper.php b/src/Type/TypeHelper.php index a417afc0..9eb669f8 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,62 @@ 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 + { + // 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; + } + + $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; + } + + $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 { diff --git a/stubs/Latte/Runtime/Filters.stub b/stubs/Latte/Runtime/Filters.stub index 704bb8af..6689973a 100644 --- a/stubs/Latte/Runtime/Filters.stub +++ b/stubs/Latte/Runtime/Filters.stub @@ -47,7 +47,8 @@ class Filters } /** - * @param FilterString|array $s + * json_encode() accepts any value, including arrays and objects. + * @param mixed $s */ public static function escapeJs($s): string { diff --git a/stubs/Latte/Runtime/Helpers.stub b/stubs/Latte/Runtime/Helpers.stub index 3480a090..1bc40d40 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 + { + } + + /** + * json_encode() accepts any value, including arrays and objects. + */ + 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 new file mode 100644 index 00000000..5d2fa79b --- /dev/null +++ b/stubs/Latte/Runtime/HtmlHelpers.stub @@ -0,0 +1,56 @@ +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/DependencyVersion.php b/tests/DependencyVersion.php new file mode 100644 index 00000000..49576e7a --- /dev/null +++ b/tests/DependencyVersion.php @@ -0,0 +1,32 @@ +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 testResolvesBareLayout(): void + { + self::assertSame( + realpath(__DIR__ . '/Fixtures/layout.latte'), + $this->resolver->resolve(__DIR__ . '/Fixtures/with-bare.latte') + ); + } + + 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')); + } + + 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')); + } + + 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/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 @@ + 8.1 + * @requires PHP >= 8.1 */ final class LatteTemplatesRuleForEngineBootstrapFirstClassCallableFilterTest extends LatteTemplatesRuleTest { diff --git a/tests/Rule/LatteTemplatesRule/EngineBootstrap/LatteTemplatesRuleForEngineBootstrapScopedLoopVariablesTest.php b/tests/Rule/LatteTemplatesRule/EngineBootstrap/LatteTemplatesRuleForEngineBootstrapScopedLoopVariablesTest.php new file mode 100644 index 00000000..17d88cc3 --- /dev/null +++ b/tests/Rule/LatteTemplatesRule/EngineBootstrap/LatteTemplatesRuleForEngineBootstrapScopedLoopVariablesTest.php @@ -0,0 +1,34 @@ +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/Source/ParentFirstClassCallableFilters.php b/tests/Rule/LatteTemplatesRule/EngineBootstrap/Source/ParentFirstClassCallableFilters.php new file mode 100644 index 00000000..e4535fad --- /dev/null +++ b/tests/Rule/LatteTemplatesRule/EngineBootstrap/Source/ParentFirstClassCallableFilters.php @@ -0,0 +1,16 @@ +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/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/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/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/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/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/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/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/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/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..faea579a --- /dev/null +++ b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterBreakIncludeCyclesTest.php @@ -0,0 +1,102 @@ +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, + '@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..c42fecf5 --- /dev/null +++ b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterMaxTemplateVariantsTest.php @@ -0,0 +1,112 @@ +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, + '@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..30b52921 --- /dev/null +++ b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterTemplateTypesTest.php @@ -0,0 +1,37 @@ +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, + 'other.latte', + ], + ]); + } +} diff --git a/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterTest.php b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterTest.php index b221fd4b..7ca3780f 100644 --- a/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterTest.php +++ b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterTest.php @@ -5,6 +5,7 @@ namespace Efabrica\PHPStanLatte\Tests\Rule\LatteTemplatesRule\PresenterWithoutModule; use Efabrica\PHPStanLatte\Compiler\LatteVersion; +use Efabrica\PHPStanLatte\Tests\DependencyVersion; use Efabrica\PHPStanLatte\Tests\Rule\LatteTemplatesRule\LatteTemplatesRuleTest; use Efabrica\PHPStanLatte\Tests\Rule\LatteTemplatesRule\PresenterWithoutModule\Fixtures\LinksPresenter; use Efabrica\PHPStanLatte\Tests\Rule\LatteTemplatesRule\PresenterWithoutModule\Fixtures\VariablesPresenter; @@ -26,7 +27,11 @@ protected static function additionalConfigFiles(): array public function testVariables(): void { - $this->analyse([__DIR__ . '/Fixtures/VariablesPresenter.php'], [ + // 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', 5, @@ -163,12 +168,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, // Latte array_slice() preserves the item type 92, 'default.latte', ], [ - 'Dumped type: string', + $sliceStringType, 94, 'default.latte', ], @@ -532,12 +537,167 @@ public function testVariables(): void 7, '@layoutOther.latte', ], - ]); + ]; + if (DependencyVersion::phpstanAtLeast23()) { + // PHPStan 2.3 dead-code and implicit-array checks on generated template code + 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', + ]; + 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', + ]; + 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, @@ -575,100 +735,1013 @@ public function testComponents(): void 'create.latte', ], [ - 'Component with name "someControl" probably doesn\'t exist.', - 13, - 'create.latte', + 'Component with name "someControl" probably doesn\'t exist.', + 13, + 'create.latte', + ], + [ + 'Component with name "someControl-header" probably doesn\'t exist.', + 14, + 'create.latte', + ], + [ + 'Component with name "someControl-body" probably doesn\'t exist.', + 15, + 'create.latte', + ], + [ + 'Component with name "someControl-body-table" probably doesn\'t exist.', + 16, + 'create.latte', + ], + [ + 'Component with name "someControl-footer" probably doesn\'t exist.', + 17, + 'create.latte', + ], + [ + 'Component with name "someControl-nonexisting" probably doesn\'t exist.', + 18, + 'create.latte', + ], + [ + 'Component with name "nonExistingControl" probably doesn\'t exist.', + 3, + 'parent.latte', + ], + [ + 'Component with name "onlyParentDefaultForm" probably doesn\'t exist.', + 7, + 'noAction.latte', + ], + [ + 'Component with name "onlyCreateForm" probably doesn\'t exist.', + 9, + 'noAction.latte', + ], + [ + 'Component with name "nonExistingControl" probably doesn\'t exist.', + 11, + 'noAction.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', + ], + ]; + 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() && 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'], [ + [ + '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; + } + 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', + ], + [ + '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', + ], + [ + 'Form control with name "1" probably does not exist.', + 105, + 'default.latte', + ], + [ + 'Value assigned to variable $formContainer is never read before being overwritten.', + 106, + 'default.latte', + ], + [ + 'Form control with name "1" probably does not exist.', + 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 $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', + ], + [ + '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', + ], + [ + 'Value assigned to variable $ʟ_label is never read before being overwritten.', + 134, + 'default.latte', + ], + [ + 'Value assigned to variable $ʟ_label is never read before being overwritten.', + 135, + 'default.latte', + ], + [ + 'Value assigned to variable $ʟ_label is never read before being overwritten.', + 136, + 'default.latte', + ], + [ + 'Option "item4" for control "checkbox_list" probably doesn\'t exist.', + 137, + 'default.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', ], [ - 'Component with name "someControl-header" probably doesn\'t exist.', - 14, - 'create.latte', + 'Form with name "notExisting" probably does not exist.', + 48, + 'default.latte', ], [ - 'Component with name "someControl-body" probably doesn\'t exist.', - 15, - 'create.latte', + 'Form control with name "username" probably does not exist.', + 49, + 'default.latte', ], [ - 'Component with name "someControl-body-table" probably doesn\'t exist.', - 16, - 'create.latte', + 'PHPDoc tag @var with type Nette\\Forms\\Controls\\BaseControl is not subtype of type Nette\\Forms\\Controls\\TextArea.', + 55, + 'default.latte', ], [ - 'Component with name "someControl-footer" probably doesn\'t exist.', - 17, - 'create.latte', + 'Value assigned to variable $formContainer is never read before being overwritten.', + 71, + 'default.latte', ], [ - 'Component with name "someControl-nonexisting" probably doesn\'t exist.', - 18, - 'create.latte', + 'Value assigned to variable $formContainer is never read before being overwritten.', + 72, + 'default.latte', ], [ - 'Component with name "nonExistingControl" probably doesn\'t exist.', - 3, - 'parent.latte', + 'Value assigned to variable $formContainer is never read before being overwritten.', + 75, + 'default.latte', ], [ - 'Component with name "onlyParentDefaultForm" probably doesn\'t exist.', - 7, - 'noAction.latte', + 'Value assigned to variable $formContainer is never read before being overwritten.', + 76, + 'default.latte', ], [ - 'Component with name "onlyCreateForm" probably doesn\'t exist.', - 9, - 'noAction.latte', + 'Value assigned to variable $formContainer is never read before being overwritten.', + 79, + 'default.latte', ], [ - 'Component with name "nonExistingControl" probably doesn\'t exist.', - 11, - 'noAction.latte', + 'Value assigned to variable $formContainer is never read before being overwritten.', + 80, + 'default.latte', ], [ - 'Undefined variable: $title', - 7, - '@layout.latte', + 'Value assigned to variable $formContainer is never read before being overwritten.', + 83, + 'default.latte', ], [ - 'Undefined variable: $title', - 7, - '@layout.latte', + 'Value assigned to variable $formContainer is never read before being overwritten.', + 84, + 'default.latte', ], [ - 'Undefined variable: $title', - 7, - '@layout.latte', + 'Form control with name "5" probably does not exist.', + 87, + 'default.latte', ], [ - 'Undefined variable: $title', - 7, - '@layout.latte', + 'Value assigned to variable $formContainer is never read before being overwritten.', + 88, + 'default.latte', ], [ - 'Undefined variable: $neverDefined', - 10, - '@layout.latte', + 'Form control with name "5" probably does not exist.', + 91, + 'default.latte', ], [ - 'Undefined variable: $neverDefined', - 10, - '@layout.latte', + 'Value assigned to variable $formContainer is never read before being overwritten.', + 92, + 'default.latte', ], [ - 'Undefined variable: $neverDefined', - 10, - '@layout.latte', + 'Value assigned to variable $formContainer is never read before being overwritten.', + 97, + 'default.latte', ], [ - 'Undefined variable: $neverDefined', - 10, - '@layout.latte', + 'Value assigned to variable $formContainer is never read before being overwritten.', + 98, + 'default.latte', ], - ]); - } - - public function testForms(): void - { + ]); + return; + } $expectedErrors = [ [ 'Form control with name "password" probably does not exist.', @@ -803,6 +1876,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); } @@ -911,11 +2204,6 @@ public function testFilters(): void 10, '@layout.latte', ], - [ - 'Cannot convert array|string to HTML string.', - 22, - 'default.latte', - ], ]; if (LatteVersion::isLatte3()) { @@ -924,18 +2212,31 @@ 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. + $filterStringType = 'string|Stringable|null'; + $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'; + } $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', ]; @@ -1185,6 +2486,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); } @@ -1744,6 +3063,80 @@ 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); } + + 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 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()) { + $this->markTestSkipped('Latte 3.1 moved escaping from Filters to dedicated runtime helpers.'); + } + $this->analyse([__DIR__ . '/Fixtures/EscapesPresenter.php'], [ + [ + '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 CSS string.', + 8, + 'default.latte', + ], + [ + 'Cannot convert stdClass to HTMLCOMMENT string.', + 9, + 'default.latte', + ], + ]); + } } diff --git a/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterWithNoMappingTest.php b/tests/Rule/LatteTemplatesRule/PresenterWithoutModule/LatteTemplatesRuleForPresenterWithNoMappingTest.php index 243b4320..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; @@ -22,7 +23,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 +109,32 @@ public function testLinks(): void 10, '@layout.latte', ], - ]); + ]; + 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[] = [ + '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; + } } 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 @@ +', (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)); + } +}