Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
70 changes: 68 additions & 2 deletions src/Reflection/GenericParametersAcceptorResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<T>) 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<T> $column, T $value) called with IntColumn (Column<int>)
// 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()];
Expand All @@ -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 (
Expand Down Expand Up @@ -165,6 +214,23 @@ public static function resolve(array $argTypes, ParametersAcceptor $parametersAc
return $result;
}

/**
* @return array<string, true> 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();
Expand Down
52 changes: 52 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-14872.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php declare(strict_types = 1);

namespace Bug14872Nsrt;

use function PHPStan\Testing\assertType;

/**
* @template T
*/
class Column
{
}

/**
* @extends Column<int>
*/
class IntColumn extends Column
{
}

/**
* @template T
* @param Column<T> $column
* @param T $value
* @return T
*/
function where($column, $value)
{
}

function test(IntColumn $c): void
{
// T is anchored to int by the invariant Column<int>, not widened to int|'x'.
assertType('int', where($c, 'x'));
assertType('int', where($c, 5));
}

/**
* @template T
* @param iterable<T> $a
* @param T $b
* @return T
*/
function covariantPosition($a, $b)
{
}

function testCovariant(): void
{
// iterable<T> value position is covariant, so T is still widened.
assertType("1|2|'x'", covariantPosition([1, 2], 'x'));
}
4 changes: 3 additions & 1 deletion tests/PHPStan/Analyser/nsrt/generic-object-lower-bound.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<Dog> argument;
// the Cat passed as T $d is a parameter mismatch, not a widening of T.
assertType(Dog::class, $this->doFoo($c, new Cat()));
}

}
Expand Down
22 changes: 19 additions & 3 deletions tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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\Cat|GenericObjectLowerBound\Dog>, GenericObjectLowerBound\Collection<GenericObjectLowerBound\Dog> given.',
48,
'Template type T on class GenericObjectLowerBound\Collection is not covariant. Learn more: <fg=cyan>https://phpstan.org/blog/whats-up-with-template-covariant</>',
'Parameter #2 $d of method GenericObjectLowerBound\Foo::doFoo() expects GenericObjectLowerBound\Dog, GenericObjectLowerBound\Cat given.',
50,
],
]);
}
Expand Down
58 changes: 58 additions & 0 deletions tests/PHPStan/Rules/Methods/data/bug-14872.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php declare(strict_types = 1);

namespace Bug14872;

/**
* @template T
*/
class Column
{
}

/**
* @extends Column<int>
*/
class IntColumn extends Column
{
}

/**
* @extends Column<string>
*/
class StringColumn extends Column
{
}

class Board
{
public IntColumn $id;

public StringColumn $title;
}

class Builder
{

/**
* @template T
* @param Column<T> $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<int>, 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');
}
Loading