Thanks to visit codestin.com
Credit goes to github.com

Skip to content
10 changes: 10 additions & 0 deletions config/phpstan.analyze-twig-templates.neon
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
includes:
- phpstan.neon
- phpstan.extensions.neon

parameters:
ignoreErrors:
# Twig's Template class is marked as @internal, but compiled templates must extend it
- identifier: class.extendsInternalClass
- identifier: property.internalClass
- identifier: method.internalClass
- identifier: method.internal
- identifier: staticMethod.internal
- identifier: return.internalClass
services:
-
class: Twig\Environment
Expand Down
44 changes: 41 additions & 3 deletions src/PHPStan/GetAttributeCheck.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
use PHPStan\Type\TypeCombinator;
use PHPStan\Type\UnionType;
use PHPStan\Type\VerbosityLevel;
use ReflectionMethod;
use Twig\Template;

final readonly class GetAttributeCheck
Expand Down Expand Up @@ -246,7 +247,7 @@ public function checkSingleType(

$methodName = $prefix . $propertyOrMethod;
// @phpstan-ignore phpstanApi.method
[, $methodReflection] = $this->methodCallCheck->check($scope, $methodName, new TypeExpr($objectType));
[, $methodReflection] = $this->callMethodCallCheck($scope, $methodName, new TypeExpr($objectType));

if ($methodReflection === null) {
continue;
Expand All @@ -255,13 +256,23 @@ public function checkSingleType(
$declaringClass = $methodReflection->getDeclaringClass();
$messagesMethodName = sprintf($declaringClass->getDisplayName() . '::' . $methodReflection->getName() . '()');

// @phpstan-ignore method.notFound
$namedArgumentsVariants = method_exists($methodReflection, 'getNamedArgumentsVariants')
? $methodReflection->getNamedArgumentsVariants()
: null;

$parametersAcceptor = ParametersAcceptorSelector::selectFromArgs(
$scope,
$args,
$methodReflection->getVariants(),
$methodReflection->getNamedArgumentsVariants(),
$namedArgumentsVariants,
);

// @phpstan-ignore method.notFound
$acceptsNamedArguments = method_exists($methodReflection, 'acceptsNamedArguments')
? $methodReflection->acceptsNamedArguments()
: true;

return [
$parametersAcceptor->getReturnType(),
[
Expand All @@ -276,7 +287,7 @@ public function checkSingleType(
$args,
),
'method',
$methodReflection->acceptsNamedArguments(),
$acceptsNamedArguments,
'Method ' . $messagesMethodName . ' invoked with %d parameter, %d required.',
'Method ' . $messagesMethodName . ' invoked with %d parameters, %d required.',
'Method ' . $messagesMethodName . ' invoked with %d parameter, at least %d required.',
Expand Down Expand Up @@ -425,4 +436,31 @@ private function prefixErrorIdentifier(array $errors): array

return $errorFormatted;
}

/**
* Calls MethodCallCheck::check() with version-appropriate parameters.
* PHPStan 2.1+ requires 4 parameters, earlier versions require 3.
*
* @return array{list<IdentifierRuleError>, null|\PHPStan\Reflection\MethodReflection}
*/
private function callMethodCallCheck(Scope $scope, string $methodName, Expr $var): array
{
static $requiresFourParams = null;

if ($requiresFourParams === null) {
// @phpstan-ignore phpstanApi.classConstant
$reflection = new ReflectionMethod(MethodCallCheck::class, 'check');
$requiresFourParams = $reflection->getNumberOfRequiredParameters() >= 4;
}

if ($requiresFourParams) {
// PHPStan 2.1+: check(Scope, string, Expr, Identifier|null)
// @phpstan-ignore phpstanApi.method, arguments.count
return $this->methodCallCheck->check($scope, $methodName, $var, new Identifier($methodName));
}

// PHPStan 2.0.x: check(Scope, string, Expr)
// @phpstan-ignore phpstanApi.method, arguments.count
return $this->methodCallCheck->check($scope, $methodName, $var);
}
}
86 changes: 61 additions & 25 deletions src/Processing/ScopeInjection/TwigScopeInjector.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,33 +67,69 @@ public function inject(array $collectedData, FlatteningResultCollection $collect

foreach ($collectedData as $data) {
if ($data->collecterType === MacroCollector::class) {
$macros[$data->filePath] = $data->data['macros'];
} elseif ($data->collecterType === BlockContextCollector::class) {
$phpDocNode = $this->phpDocParser->parseTagValue(
new TokenIterator($this->lexer->tokenize($data->data['context'])),
'@var',
);

if ( ! $phpDocNode instanceof VarTagValueNode) {
throw new LogicException('Invalid @var tag.');
// PHPStan aggregates collector results per file
// Handle both single and double-nested structures for version compatibility
foreach ($data->data as $item) {
// Check if this is MacroData directly or needs another level of iteration
if (isset($item['macros'])) {
// Single-nested: $item is MacroData
$macros[$data->filePath] = $item['macros'];
} elseif (is_array($item)) {
// Double-nested: $item is list<MacroData>
foreach ($item as $macroData) {
if (is_array($macroData) && isset($macroData['macros'])) {
$macros[$data->filePath] = $macroData['macros'];
}
}
}
}

$context = $phpDocNode->type;

if ( ! $context instanceof ArrayShapeNode) {
$context = ArrayShapeNode::createSealed([]);
} elseif ($data->collecterType === BlockContextCollector::class) {
// PHPStan aggregates collector results per file
// Handle both single and double-nested structures for version compatibility
foreach ($data->data as $item) {
// Check if this is BlockData directly or needs another level of iteration
if (isset($item['context'])) {
// Single-nested: $item is BlockData
$blockDataList = [$item];
} elseif (is_array($item)) {
// Double-nested: $item is list<BlockData>
$blockDataList = $item;
} else {
continue;
}

foreach ($blockDataList as $blockData) {
if ( ! is_array($blockData) || ! isset($blockData['context'])) {
continue;
}

$phpDocNode = $this->phpDocParser->parseTagValue(
new TokenIterator($this->lexer->tokenize($blockData['context'])),
'@var',
);

if ( ! $phpDocNode instanceof VarTagValueNode) {
throw new LogicException('Invalid @var tag.');
}

$context = $phpDocNode->type;

if ( ! $context instanceof ArrayShapeNode) {
$context = ArrayShapeNode::createSealed([]);
}

$sourceLocation = SourceLocation::decode($blockData['sourceLocation']);

$contextBeforeBlockByFilename[$sourceLocation->last()->fileName][] = [
'blockName' => $blockData['blockName'],
'sourceLocation' => $sourceLocation,
'context' => $context,
'parent' => $blockData['parent'],
'relatedBlockName' => $blockData['relatedBlockName'],
'relatedParent' => $blockData['relatedParent'],
];
}
}

$sourceLocation = SourceLocation::decode($data->data['sourceLocation']);

$contextBeforeBlockByFilename[$sourceLocation->last()->fileName][] = [
'blockName' => $data->data['blockName'],
'sourceLocation' => $sourceLocation,
'context' => $context,
'parent' => $data->data['parent'],
'relatedBlockName' => $data->data['relatedBlockName'],
'relatedParent' => $data->data['relatedParent'],
];
}
}

Expand Down
26 changes: 22 additions & 4 deletions src/Processing/TemplateContextFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,29 @@ public function create(PHPStanAnalysisResult $analysisResult): TemplateContext
$templateContext = [];
foreach ($analysisResult->collectedData as $data) {
if (is_a($data->collecterType, TemplateContextCollector::class, true)) {
foreach ($data->data as $renderData) {
$template = $this->twigFileCanonicalizer->absolute($renderData['template']);
$sourceLocation = SourceLocation::decode($renderData['sourceLocation']);
// PHPStan aggregates collector results per file
// In PHPStan 2.1+, data is list<list<TemplateData>>
// In earlier versions, data might be list<TemplateData>
foreach ($data->data as $nodeResults) {
// Check if this is a TemplateData directly or a list of TemplateData
if (isset($nodeResults['template'])) {
// Single-nested: $nodeResults is TemplateData
$renderDataList = [$nodeResults];
} else {
// Double-nested: $nodeResults is list<TemplateData>
$renderDataList = $nodeResults;
}

$templateContext[$template][$sourceLocation->getHash()] = [$sourceLocation, $renderData['context']];
foreach ($renderDataList as $renderData) {
if ( ! is_array($renderData) || ! isset($renderData['template'])) {
continue;
}

$template = $this->twigFileCanonicalizer->absolute($renderData['template']);
$sourceLocation = SourceLocation::decode($renderData['sourceLocation']);

$templateContext[$template][$sourceLocation->getHash()] = [$sourceLocation, $renderData['context']];
}
}
}
}
Expand Down
16 changes: 14 additions & 2 deletions tests/Baseline/baseline.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,20 @@

return [
new BaselineError(
'If condition is always false.',
'if.alwaysFalse',
'Casting to *NEVER* something that\'s already *NEVER*.',
'cast.useless',
__DIR__ . '/homepage.html.twig',
1,
),
new BaselineError(
'Instanceof between *NEVER* and Twig\\Markup will always evaluate to false.',
'instanceof.alwaysFalse',
__DIR__ . '/homepage.html.twig',
1,
),
new BaselineError(
'Left side of && is always false.',
'booleanAnd.leftAlwaysFalse',
__DIR__ . '/homepage.html.twig',
1,
),
Expand Down
18 changes: 16 additions & 2 deletions tests/Baseline/errors.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,22 @@
{
"errors": [
{
"message": "If condition is always false.",
"identifier": "if.alwaysFalse",
"message": "Casting to *NEVER* something that's already *NEVER*.",
"identifier": "cast.useless",
"tip": null,
"twigSourceLocation": "homepage.html.twig:3",
"renderPoints": []
},
{
"message": "Instanceof between *NEVER* and Twig\\Markup will always evaluate to false.",
"identifier": "instanceof.alwaysFalse",
"tip": null,
"twigSourceLocation": "homepage.html.twig:3",
"renderPoints": []
},
{
"message": "Left side of && is always false.",
"identifier": "booleanAnd.leftAlwaysFalse",
"tip": null,
"twigSourceLocation": "homepage.html.twig:3",
"renderPoints": []
Expand Down
16 changes: 14 additions & 2 deletions tests/Baseline/expected.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,20 @@

return [
new BaselineError(
'If condition is always false.',
'if.alwaysFalse',
'Casting to *NEVER* something that\'s already *NEVER*.',
'cast.useless',
__DIR__ . '/homepage.html.twig',
1,
),
new BaselineError(
'Instanceof between *NEVER* and Twig\\Markup will always evaluate to false.',
'instanceof.alwaysFalse',
__DIR__ . '/homepage.html.twig',
1,
),
new BaselineError(
'Left side of && is always false.',
'booleanAnd.leftAlwaysFalse',
__DIR__ . '/homepage.html.twig',
1,
),
Expand Down
2 changes: 1 addition & 1 deletion tests/EndToEnd/AbstractTemplate/context.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"footer.twig": [
[
"child.twig:6",
"array{app: Symfony\\Bridge\\Twig\\AppVariable, title: 'RenderAction'|'RenderViewAction', description: 'Description', year: 2024}"
"array{year: 2024}"
]
]
}
20 changes: 20 additions & 0 deletions tests/EndToEnd/AbstractTemplate/errors.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
{
"errors": [
{
"message": "Variable 'title' does not exist.",
"identifier": "offsetAccess.notFound",
"tip": null,
"twigSourceLocation": "base.twig:4, child.twig:1",
"renderPoints": [
"Controller.php:16",
"Controller.php:24"
]
},
{
"message": "Variable 'description' does not exist.",
"identifier": "offsetAccess.notFound",
"tip": null,
"twigSourceLocation": "child.twig:4",
"renderPoints": [
"Controller.php:16",
"Controller.php:24"
]
},
{
"message": "Template is marked as abstract but is rendered directly.",
"identifier": null,
Expand Down
4 changes: 2 additions & 2 deletions tests/EndToEnd/Bug154/context.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@
"sub-include.twig": [
[
"include.twig:3",
"array{app: Symfony\\Bridge\\Twig\\AppVariable, title: 'Welcome'}"
"array{app: Symfony\\Bridge\\Twig\\AppVariable}"
]
],
"include.twig": [
[
"page.twig:5",
"array{app: Symfony\\Bridge\\Twig\\AppVariable, title: 'Welcome'}"
"array{}"
]
]
}
Loading
Loading