Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
fa3bc07
Support Latte 3.1: constraint bump, TemplateGenerator API split, pos-…
WerySkok Oct 6, 2026
dcfb94d
Declare fork version 0.20.1 for path repositories
WerySkok Oct 6, 2026
32400d8
feat: add latte.templateTypes option for custom template objects
WerySkok Oct 6, 2026
622b893
fix: resolve {extends}/{layout} layouts with quoted paths
WerySkok Oct 6, 2026
4db6184
fix: require compiled templates only once per process
WerySkok Oct 6, 2026
e0d5bb2
feat: add latte.maxTemplateVariants to bound include-heavy template a…
WerySkok Oct 6, 2026
2772df2
feat: add latte.breakIncludeCycles to stop cyclic include recursion
WerySkok Oct 6, 2026
8fb4675
feat: add latte.resolveTypes to optionally skip the type resolution pass
WerySkok Oct 6, 2026
6dc37b2
fix(phpstan): version-gate TemplateGenerator split and narrow layout …
WerySkok Oct 8, 2026
ffd7d3a
fix(compiler): expand bare iterable types in generated filter/functio…
WerySkok Oct 8, 2026
c930071
fix(stubs): restore HTML escape checks for Latte 3.1 HtmlHelpers
WerySkok Oct 8, 2026
43030b5
fix(phpstan): ignore unknown TemplateGenerator methods on Latte 2 runs
WerySkok Oct 8, 2026
1b32cb9
fix(compat): replace match expression with switch for PHP 7.4 support
WerySkok Oct 8, 2026
8f0ffc2
fix(compiler): route Latte 3.1 first-class callable filters/functions…
WerySkok Oct 8, 2026
6c09885
fix(phpstan): resolve level=max errors against Latte 3.1 and PHPStan 2.3
WerySkok Oct 8, 2026
2a32ed0
test: align expectations with PHPStan 2.3 and nette/forms 3.3
WerySkok Oct 8, 2026
0d83dfd
fix(phpstan): polyfill str_contains/str_starts_with for PHP 7.4 runs
WerySkok Oct 9, 2026
0cca651
fix(phpstan): narrow mixed values under Latte <3.1 and PHPStan 2.1
WerySkok Oct 9, 2026
97c3304
test: version-gate expectations for the dependency matrix
WerySkok Oct 9, 2026
b92ab8c
fix(type): expand only bare iterables in filter/function PHPDoc
WerySkok Oct 22, 2026
a48e9e4
fix(compiler): keep generated class comment on Latte 3.0.27
WerySkok Oct 27, 2026
449d928
fix: restore Latte 3.1 escape checks and harden analysis
WerySkok Oct 27, 2026
38d56cf
fix: honor Latte 3.1 features and improve slice/escape typing
WerySkok Oct 27, 2026
a60b838
fix(phpstan): ignore Latte 3.1 feature API on older Latte versions
WerySkok Oct 27, 2026
d50d6d0
fix: harden layout resolution, resolveTypes and 3.1 compiler paths
WerySkok Oct 27, 2026
0ad4ef2
fix: green CI on older Latte and tokenizer dependency
WerySkok Oct 28, 2026
2221f7a
fix: validate template paths and report skipped variants
WerySkok Oct 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
{
"name": "efabrica/phpstan-latte",
"version": "0.20.1",
"license": ["MIT"],
"keywords": [
"static analysis"
],
"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",
Expand Down
49 changes: 49 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

28 changes: 27 additions & 1 deletion extension.neon
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -31,7 +33,11 @@ parameters:
extensions: []
resolveAllPossiblePaths: false
reportUnanalysedTemplates: false
maxTemplateVariants: 0
breakIncludeCycles: false
resolveTypes: true
collectedPaths: []
templateTypes: []
features:
transformDynamicFormControlNamesToString: false
phpstanCommand: null
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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
Expand All @@ -127,6 +138,7 @@ services:
arguments:
parser: @latteCurrentPhpVersionRichParser
debugMode: %latte.debugMode%
cacheKey: ::md5(::json_encode(%latte%))

# Latte template resolvers
- Efabrica\PHPStanLatte\LatteTemplateResolver\Nette\NetteApplicationUIPresenter
Expand All @@ -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()
Expand Down
27 changes: 22 additions & 5 deletions phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -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\.$#'
Expand Down Expand Up @@ -158,14 +154,27 @@ 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\.$#'
- '#^Call to an undefined method Latte\\Engine\:\:addExtension\(\)\.$#'
- '#^Parameter \#1 \$phpContent of method Efabrica\\PHPStanLatte\\Compiler\\Compiler\\Latte3Compiler::fixLines\(\) expects string, mixed given\.#'
- '#^PHPDoc tag @var with type array<string, callable> is not subtype of type array<string>\.#'
- '#^Parameter \#1 \$object of function get_class expects object, mixed given\.#'
- '#^Call to function is_array\(\) with array<Latte\\Extension> will always evaluate to true\.#'
- '#^Call to function is_array\(\) with (array|list)<Latte\\Extension> 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
Expand Down Expand Up @@ -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\.$#'
Expand Down
2 changes: 2 additions & 0 deletions rules.neon
Original file line number Diff line number Diff line change
Expand Up @@ -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
21 changes: 13 additions & 8 deletions src/Analyser/LatteContextAnalyser.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ final class LatteContextAnalyser

private string $tmpDir;

private string $cacheKey;

/**
* @param AbstractLatteContextCollector[] $collectors
*/
Expand All @@ -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;
Expand All @@ -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);
}
Expand Down Expand Up @@ -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';
Expand All @@ -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);
Expand All @@ -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)
);
Expand All @@ -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;
Expand All @@ -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;
}

Expand All @@ -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;
}
}
Expand Down
Loading
Loading