diff --git a/src/Reflection/GenericParametersAcceptorResolver.php b/src/Reflection/GenericParametersAcceptorResolver.php index d38396e952..e3ad4a242c 100644 --- a/src/Reflection/GenericParametersAcceptorResolver.php +++ b/src/Reflection/GenericParametersAcceptorResolver.php @@ -19,6 +19,7 @@ use PHPStan\Type\TypeCombinator; use PHPStan\Type\UnionType; use function array_key_exists; +use function array_keys; use function array_last; use function array_map; use function array_merge; @@ -78,6 +79,45 @@ public static function resolve(array $argTypes, ParametersAcceptor $parametersAc ); } + // template types that appear in an invariant position (e.g. T inside an + // invariant Column) are determined exactly by that argument, so they + // are inferred first and substituted into the remaining parameter types — + // the other occurrences of the same template type are then validated + // against the anchored type instead of widening it into a union. + // e.g. in where(Column $column, T $value) called with IntColumn (Column) + // and 'foo', T becomes int and the error is reported on $value, not $column. + $anchorTypeMap = TemplateTypeMap::createEmpty(); + $invariantNamesByParam = []; + foreach ($parameters as $param) { + if (!isset($namedArgTypes[$param->getName()])) { + continue; + } + + $invariantNames = self::getInvariantTemplateTypeNames($param->getType()); + if (count($invariantNames) === 0) { + continue; + } + + $invariantNamesByParam[$param->getName()] = $invariantNames; + + $paramType = self::resolvePredicateTemplateTypes($param->getType(), $predicateTypeMap); + $inferred = $paramType->inferTemplateTypes($namedArgTypes[$param->getName()]); + $kept = []; + foreach ($inferred->getTypes() as $name => $type) { + if (!array_key_exists($name, $invariantNames)) { + continue; + } + + $kept[$name] = $type; + } + + if (count($kept) === 0) { + continue; + } + + $anchorTypeMap = $anchorTypeMap->union(new TemplateTypeMap($kept)); + } + foreach ($parameters as $param) { if (isset($namedArgTypes[$param->getName()])) { $argType = $namedArgTypes[$param->getName()]; @@ -89,12 +129,21 @@ public static function resolve(array $argTypes, ParametersAcceptor $parametersAc continue; } - $paramType = self::resolvePredicateTemplateTypes($param->getType(), $predicateTypeMap); + // Substitute the anchored types into the consumer parameters so they + // are validated against the anchored type, but keep them out of the + // parameter that anchored them — its own (possibly dependent) template + // types still need to be inferred from the original parameter type. + $paramSubstitutionMap = $predicateTypeMap->union($anchorTypeMap); + foreach (array_keys($invariantNamesByParam[$param->getName()] ?? []) as $name) { + $paramSubstitutionMap = $paramSubstitutionMap->unsetType($name); + } + + $paramType = self::resolvePredicateTemplateTypes($param->getType(), $paramSubstitutionMap); $typeMap = $typeMap->union($paramType->inferTemplateTypes($argType)); $passedArgs['$' . $param->getName()] = $argType; } - $typeMap = $typeMap->union($predicateTypeMap); + $typeMap = $typeMap->union($predicateTypeMap)->union($anchorTypeMap); $returnType = $parametersAcceptor->getReturnType(); if ( @@ -165,6 +214,23 @@ public static function resolve(array $argTypes, ParametersAcceptor $parametersAc return $result; } + /** + * @return array names of template types that occur in an invariant position in $paramType + */ + private static function getInvariantTemplateTypeNames(Type $paramType): array + { + $names = []; + foreach ($paramType->getReferencedTemplateTypes(TemplateTypeVariance::createCovariant()) as $reference) { + if (!$reference->getPositionVariance()->invariant()) { + continue; + } + + $names[$reference->getType()->getName()] = true; + } + + return $names; + } + private static function inferPredicateTemplateTypes(Type $paramType, Type $argType): TemplateTypeMap { $typeMap = TemplateTypeMap::createEmpty(); diff --git a/tests/PHPStan/Analyser/nsrt/bug-14872.php b/tests/PHPStan/Analyser/nsrt/bug-14872.php new file mode 100644 index 0000000000..eb6634fdca --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14872.php @@ -0,0 +1,52 @@ + + */ +class IntColumn extends Column +{ +} + +/** + * @template T + * @param Column $column + * @param T $value + * @return T + */ +function where($column, $value) +{ +} + +function test(IntColumn $c): void +{ + // T is anchored to int by the invariant Column, not widened to int|'x'. + assertType('int', where($c, 'x')); + assertType('int', where($c, 5)); +} + +/** + * @template T + * @param iterable $a + * @param T $b + * @return T + */ +function covariantPosition($a, $b) +{ +} + +function testCovariant(): void +{ + // iterable value position is covariant, so T is still widened. + assertType("1|2|'x'", covariantPosition([1, 2], 'x')); +} diff --git a/tests/PHPStan/Analyser/nsrt/generic-object-lower-bound.php b/tests/PHPStan/Analyser/nsrt/generic-object-lower-bound.php index fe71aab3d6..a9223e2105 100644 --- a/tests/PHPStan/Analyser/nsrt/generic-object-lower-bound.php +++ b/tests/PHPStan/Analyser/nsrt/generic-object-lower-bound.php @@ -45,7 +45,9 @@ function doFoo(Collection $c, object $d) */ function doBar(Collection $c): void { - assertType(Cat::class . '|' . Dog::class, $this->doFoo($c, new Cat())); + // T is anchored to Dog by the invariant Collection argument; + // the Cat passed as T $d is a parameter mismatch, not a widening of T. + assertType(Dog::class, $this->doFoo($c, new Cat())); } } diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php index a6e55ecbd3..f9360912b8 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -987,6 +987,23 @@ public function testBug14720(): void $this->analyse([__DIR__ . '/data/bug-14720.php'], []); } + public function testBug14872(): void + { + $this->checkThisOnly = false; + $this->checkNullables = true; + $this->checkUnionTypes = true; + $this->analyse([__DIR__ . '/data/bug-14872.php'], [ + [ + 'Parameter #3 $value of method Bug14872\Builder::where() expects int, string given.', + 52, + ], + [ + 'Parameter #3 $value of method Bug14872\Builder::where() expects string, int given.', + 53, + ], + ]); + } + public function testClosureBind(): void { $this->checkThisOnly = false; @@ -2287,9 +2304,8 @@ public function testGenericObjectLowerBound(): void $this->checkUnionTypes = true; $this->analyse([__DIR__ . '/../../Analyser/nsrt/generic-object-lower-bound.php'], [ [ - 'Parameter #1 $c of method GenericObjectLowerBound\Foo::doFoo() expects GenericObjectLowerBound\Collection, GenericObjectLowerBound\Collection given.', - 48, - 'Template type T on class GenericObjectLowerBound\Collection is not covariant. Learn more: https://phpstan.org/blog/whats-up-with-template-covariant', + 'Parameter #2 $d of method GenericObjectLowerBound\Foo::doFoo() expects GenericObjectLowerBound\Dog, GenericObjectLowerBound\Cat given.', + 50, ], ]); } diff --git a/tests/PHPStan/Rules/Methods/data/bug-14872.php b/tests/PHPStan/Rules/Methods/data/bug-14872.php new file mode 100644 index 0000000000..76150029c4 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-14872.php @@ -0,0 +1,58 @@ + + */ +class IntColumn extends Column +{ +} + +/** + * @extends Column + */ +class StringColumn extends Column +{ +} + +class Board +{ + public IntColumn $id; + + public StringColumn $title; +} + +class Builder +{ + + /** + * @template T + * @param Column $column + * @param T $value + */ + public function where($column, string $operator, $value): self + { + return $this; + } + +} + +function test(Builder $b, Board $board): void +{ + // T is anchored to int by the invariant Column, so the mismatch + // is reported on the $value argument, not on $column. + $b->where($board->id, '=', 'test_string_value'); + $b->where($board->title, '=', 3); + + // matching values are accepted + $b->where($board->id, '=', 5); + $b->where($board->title, '=', 'ok'); +}