Skip to content

Commit cc71563

Browse files
committed
Fix #11470
1 parent 8dca4e2 commit cc71563

6 files changed

Lines changed: 182 additions & 21 deletions

File tree

CLAUDE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,10 @@ PHPStan maintains its own signature map for built-in PHP functions in `functionM
281281
- Marking functions as impure (e.g. `time()`, Redis methods)
282282
- PHP-version-specific signatures (e.g. `bcround` only in PHP 8.4+)
283283

284+
### PHP-parser name resolution and `originalName` attribute
285+
286+
PHP-parser's `NameResolver` resolves names through `use` statements. When `preserveOriginalNames: true` is configured (as PHPStan does in `conf/services.neon`), the original unresolved Name node is preserved as an `originalName` attribute on the resolved `FullyQualified` node. This matters for case-sensitivity checking: when `use DateTimeImmutable;` is followed by `dateTimeImmutable` in a typehint, the resolved node has the case from the `use` statement (`DateTimeImmutable`), losing the wrong case from the source. The `originalName` attribute preserves the source-code case (`dateTimeImmutable`). Rules that check class name case (like `class.nameCase` via `ClassCaseSensitivityCheck`) must use this attribute rather than relying on `Type::getReferencedClasses()` which returns already-resolved names. The fix pattern is in `FunctionDefinitionCheck::getOriginalClassNamePairsFromTypeNode()` which extracts original-case class names from AST type nodes.
287+
284288
### Impure points and side effects
285289

286290
PHPStan tracks whether expressions/statements have side effects ("impure points"). This enables:

src/Rules/FunctionDefinitionCheck.php

Lines changed: 102 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,9 @@
4141
use function array_keys;
4242
use function array_map;
4343
use function array_merge;
44+
use function array_slice;
4445
use function count;
46+
use function implode;
4547
use function in_array;
4648
use function is_string;
4749
use function sprintf;
@@ -183,17 +185,23 @@ public function checkAnonymousFunction(
183185
->build();
184186
continue;
185187
}
186-
187-
$errors = array_merge(
188-
$errors,
189-
$this->classCheck->checkClassNames($scope, [
190-
new ClassNameNodePair($class, $param->type),
191-
], ClassNameUsageLocation::from(ClassNameUsageLocation::PARAMETER_TYPE, [
192-
'parameterName' => $param->var->name,
193-
'isInAnonymousFunction' => true,
194-
]), $this->checkClassCaseSensitivity),
195-
);
196188
}
189+
190+
$anonParamOriginalCasePairs = $this->getOriginalClassNamePairsFromTypeNode($param->type);
191+
192+
$errors = array_merge(
193+
$errors,
194+
$this->classCheck->checkClassNames($scope, array_map(static function (string $class) use ($param, $anonParamOriginalCasePairs): ClassNameNodePair {
195+
$lowerClass = strtolower($class);
196+
if (isset($anonParamOriginalCasePairs[$lowerClass])) {
197+
return $anonParamOriginalCasePairs[$lowerClass];
198+
}
199+
return new ClassNameNodePair($class, $param->type);
200+
}, $type->getReferencedClasses()), ClassNameUsageLocation::from(ClassNameUsageLocation::PARAMETER_TYPE, [
201+
'parameterName' => $param->var->name,
202+
'isInAnonymousFunction' => true,
203+
]), $this->checkClassCaseSensitivity),
204+
);
197205
}
198206

199207
if ($this->phpVersion->deprecatesRequiredParameterAfterOptional()) {
@@ -260,17 +268,23 @@ public function checkAnonymousFunction(
260268
->build();
261269
continue;
262270
}
263-
264-
$errors = array_merge(
265-
$errors,
266-
$this->classCheck->checkClassNames($scope, [
267-
new ClassNameNodePair($returnTypeClass, $returnTypeNode),
268-
], ClassNameUsageLocation::from(ClassNameUsageLocation::RETURN_TYPE, [
269-
'isInAnonymousFunction' => true,
270-
]), $this->checkClassCaseSensitivity),
271-
);
272271
}
273272

273+
$anonReturnOriginalCasePairs = $this->getOriginalClassNamePairsFromTypeNode($returnTypeNode);
274+
275+
$errors = array_merge(
276+
$errors,
277+
$this->classCheck->checkClassNames($scope, array_map(static function (string $class) use ($returnTypeNode, $anonReturnOriginalCasePairs): ClassNameNodePair {
278+
$lowerClass = strtolower($class);
279+
if (isset($anonReturnOriginalCasePairs[$lowerClass])) {
280+
return $anonReturnOriginalCasePairs[$lowerClass];
281+
}
282+
return new ClassNameNodePair($class, $returnTypeNode);
283+
}, $returnType->getReferencedClasses()), ClassNameUsageLocation::from(ClassNameUsageLocation::RETURN_TYPE, [
284+
'isInAnonymousFunction' => true,
285+
]), $this->checkClassCaseSensitivity),
286+
);
287+
274288
return $errors;
275289
}
276290

@@ -469,11 +483,19 @@ private function checkParametersAcceptor(
469483
$locationData['function'] = $parametersAcceptor;
470484
}
471485

486+
$originalCasePairs = $this->getOriginalClassNamePairsFromTypeNode($parameterNodeCallback()->type);
487+
472488
$errors = array_merge(
473489
$errors,
474490
$this->classCheck->checkClassNames(
475491
$scope,
476-
array_map(static fn (string $class): ClassNameNodePair => new ClassNameNodePair($class, $parameterNodeCallback()), $referencedClasses),
492+
array_map(static function (string $class) use ($parameterNodeCallback, $originalCasePairs): ClassNameNodePair {
493+
$lowerClass = strtolower($class);
494+
if (isset($originalCasePairs[$lowerClass])) {
495+
return $originalCasePairs[$lowerClass];
496+
}
497+
return new ClassNameNodePair($class, $parameterNodeCallback());
498+
}, $referencedClasses),
477499
ClassNameUsageLocation::from(ClassNameUsageLocation::PARAMETER_TYPE, $locationData),
478500
$this->checkClassCaseSensitivity,
479501
),
@@ -541,11 +563,19 @@ private function checkParametersAcceptor(
541563
$locationData['function'] = $parametersAcceptor;
542564
}
543565

566+
$returnOriginalCasePairs = $this->getOriginalClassNamePairsFromTypeNode($functionNode->getReturnType());
567+
544568
$errors = array_merge(
545569
$errors,
546570
$this->classCheck->checkClassNames(
547571
$scope,
548-
array_map(static fn (string $class): ClassNameNodePair => new ClassNameNodePair($class, $returnTypeNode), $returnTypeReferencedClasses),
572+
array_map(static function (string $class) use ($returnTypeNode, $returnOriginalCasePairs): ClassNameNodePair {
573+
$lowerClass = strtolower($class);
574+
if (isset($returnOriginalCasePairs[$lowerClass])) {
575+
return $returnOriginalCasePairs[$lowerClass];
576+
}
577+
return new ClassNameNodePair($class, $returnTypeNode);
578+
}, $returnTypeReferencedClasses),
549579
ClassNameUsageLocation::from(ClassNameUsageLocation::RETURN_TYPE, $locationData),
550580
$this->checkClassCaseSensitivity,
551581
),
@@ -807,4 +837,55 @@ private function checkImplicitlyNullableType(
807837
->build();
808838
}
809839

840+
/**
841+
* @return array<string, ClassNameNodePair>
842+
*/
843+
private function getOriginalClassNamePairsFromTypeNode(Identifier|Name|ComplexType|null $typeNode): array
844+
{
845+
if ($typeNode === null) {
846+
return [];
847+
}
848+
849+
if ($typeNode instanceof Name) {
850+
$originalName = $typeNode->getAttribute('originalName');
851+
if (!$originalName instanceof Name) {
852+
return [];
853+
}
854+
855+
$resolvedName = $typeNode->toString();
856+
$originalParts = $originalName->getParts();
857+
$resolvedParts = $typeNode->getParts();
858+
859+
$originalPartsCount = count($originalParts);
860+
$resolvedPartsCount = count($resolvedParts);
861+
862+
if ($originalPartsCount <= $resolvedPartsCount) {
863+
$prefixParts = array_slice($resolvedParts, 0, $resolvedPartsCount - $originalPartsCount);
864+
$originalCaseClassName = implode('\\', array_merge($prefixParts, $originalParts));
865+
} else {
866+
$originalCaseClassName = $originalName->toString();
867+
}
868+
869+
if ($originalCaseClassName === $resolvedName) {
870+
return [];
871+
}
872+
873+
return [strtolower($resolvedName) => new ClassNameNodePair($originalCaseClassName, $typeNode)];
874+
}
875+
876+
if ($typeNode instanceof NullableType) {
877+
return $this->getOriginalClassNamePairsFromTypeNode($typeNode->type);
878+
}
879+
880+
if ($typeNode instanceof UnionType || $typeNode instanceof IntersectionType) {
881+
$pairs = [];
882+
foreach ($typeNode->types as $innerType) {
883+
$pairs += $this->getOriginalClassNamePairsFromTypeNode($innerType);
884+
}
885+
return $pairs;
886+
}
887+
888+
return [];
889+
}
890+
810891
}

tests/PHPStan/Rules/Functions/ExistingClassesInTypehintsRuleTest.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,25 @@ public function testParamClosureThisClasses(): void
490490
]);
491491
}
492492

493+
public function testBug11470(): void
494+
{
495+
require_once __DIR__ . '/data/bug-11470.php';
496+
$this->analyse([__DIR__ . '/data/bug-11470.php'], [
497+
[
498+
'Class DateTimeImmutable referenced with incorrect case: dateTimeImmutable.',
499+
7,
500+
],
501+
[
502+
'Class DateTimeImmutable referenced with incorrect case: dateTimeImmutable.',
503+
12,
504+
],
505+
[
506+
'Class DateTimeImmutable referenced with incorrect case: dateTimeImmutable.',
507+
16,
508+
],
509+
]);
510+
}
511+
493512
#[RequiresPhp('>= 8.2')]
494513
public function testNoDiscardVoid(): void
495514
{
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug11470Functions;
4+
5+
use DateTimeImmutable;
6+
7+
function sayHello(): dateTimeImmutable
8+
{
9+
return new DateTimeImmutable();
10+
}
11+
12+
function sayHello2(dateTimeImmutable $a): void
13+
{
14+
}
15+
16+
function sayHello3(): ?dateTimeImmutable
17+
{
18+
return null;
19+
}

tests/PHPStan/Rules/Methods/ExistingClassesInTypehintsRuleTest.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -639,4 +639,22 @@ public function testNoDiscardVoid(): void
639639
]);
640640
}
641641

642+
public function testBug11470(): void
643+
{
644+
$this->analyse([__DIR__ . '/data/bug-11470.php'], [
645+
[
646+
'Class DateTimeImmutable referenced with incorrect case: dateTimeImmutable.',
647+
9,
648+
],
649+
[
650+
'Class DateTimeImmutable referenced with incorrect case: dateTimeImmutable.',
651+
14,
652+
],
653+
[
654+
'Class DateTimeImmutable referenced with incorrect case: dateTimeImmutable.',
655+
19,
656+
],
657+
]);
658+
}
659+
642660
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug11470;
4+
5+
use DateTimeImmutable;
6+
7+
interface HelloWorld
8+
{
9+
public function sayHello(): dateTimeImmutable;
10+
}
11+
12+
interface HelloWorld2
13+
{
14+
public function sayHello(dateTimeImmutable $a): void;
15+
}
16+
17+
interface HelloWorld3
18+
{
19+
public function sayHello(): ?dateTimeImmutable;
20+
}

0 commit comments

Comments
 (0)