diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/AssertionFinder.php b/src/Psalm/Internal/Analyzer/Statements/Expression/AssertionFinder.php index 22323df3a..d4a46964a 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/AssertionFinder.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/AssertionFinder.php @@ -1666,6 +1666,10 @@ class AssertionFinder if ($first_var_name) { $if_types[$first_var_name] = [[$prefix . 'iterable']]; } + } elseif (self::hasCountableCheck($expr)) { + if ($first_var_name) { + $if_types[$first_var_name] = [[$prefix . 'countable']]; + } } elseif ($class_exists_check_type = self::hasClassExistsCheck($expr)) { if ($first_var_name) { if ($class_exists_check_type === 2) { @@ -2298,6 +2302,20 @@ class AssertionFinder return false; } + /** + * @param PhpParser\Node\Expr\FuncCall $stmt + * + * @return bool + */ + protected static function hasCountableCheck(PhpParser\Node\Expr\FuncCall $stmt) + { + if ($stmt->name instanceof PhpParser\Node\Name && strtolower($stmt->name->parts[0]) === 'is_countable') { + return true; + } + + return false; + } + /** * @param PhpParser\Node\Expr\FuncCall $stmt * diff --git a/src/Psalm/Internal/Analyzer/TypeAnalyzer.php b/src/Psalm/Internal/Analyzer/TypeAnalyzer.php index f572fac46..631c73d33 100644 --- a/src/Psalm/Internal/Analyzer/TypeAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/TypeAnalyzer.php @@ -990,7 +990,7 @@ class TypeAnalyzer return false; } - if ($input_type_part->isTraversable($codebase)) { + if ($input_type_part->hasTraversableInterface($codebase)) { return true; } } diff --git a/src/Psalm/Type/Atomic.php b/src/Psalm/Type/Atomic.php index 48a760fb7..bdc22291f 100644 --- a/src/Psalm/Type/Atomic.php +++ b/src/Psalm/Type/Atomic.php @@ -249,7 +249,7 @@ abstract class Atomic public function isIterable(Codebase $codebase) { return $this instanceof TIterable - || $this->isTraversable($codebase) + || $this->hasTraversableInterface($codebase) || $this instanceof TArray || $this instanceof ObjectLike; } @@ -257,7 +257,17 @@ abstract class Atomic /** * @return bool */ - public function isTraversable(Codebase $codebase) + public function isCountable(Codebase $codebase) + { + return $this->hasCountableInterface($codebase) + || $this instanceof TArray + || $this instanceof ObjectLike; + } + + /** + * @return bool + */ + public function hasTraversableInterface(Codebase $codebase) { return $this instanceof TNamedObject && (strtolower($this->value) === 'traversable' @@ -269,6 +279,40 @@ abstract class Atomic $this->value, 'Traversable' ))) + || ($this->extra_types + && array_filter( + $this->extra_types, + function (Atomic $a) use ($codebase) : bool { + return $a->hasTraversableInterface($codebase); + } + ) + ) + ); + } + + /** + * @return bool + */ + public function hasCountableInterface(Codebase $codebase) + { + return $this instanceof TNamedObject + && (strtolower($this->value) === 'countable' + || ($codebase->classOrInterfaceExists($this->value) + && ($codebase->classExtendsOrImplements( + $this->value, + 'Countable' + ) || $codebase->interfaceExtends( + $this->value, + 'Countable' + ))) + || ($this->extra_types + && array_filter( + $this->extra_types, + function (Atomic $a) use ($codebase) : bool { + return $a->hasCountableInterface($codebase); + } + ) + ) ); } diff --git a/src/Psalm/Type/Reconciler.php b/src/Psalm/Type/Reconciler.php index 0b1aeb379..e1933e4c0 100644 --- a/src/Psalm/Type/Reconciler.php +++ b/src/Psalm/Type/Reconciler.php @@ -728,7 +728,11 @@ class Reconciler return Type::getMixed(); } - if ($new_var_type === 'iterable' && !$existing_var_type->hasMixed()) { + if ($new_var_type === 'iterable') { + if ($existing_var_type->hasMixed()) { + return new Type\Union([new Type\Atomic\TIterable]); + } + $iterable_types = []; $did_remove_type = false; @@ -736,7 +740,57 @@ class Reconciler if ($type->isIterable($codebase)) { $iterable_types[] = $type; } elseif ($type instanceof TObject) { - $iterable_types[] = new Type\Atomic\TCallableObject(); + $iterable_types[] = new Type\Atomic\TNamedObject('Traversable'); + $did_remove_type = true; + } else { + $did_remove_type = true; + } + } + + if ((!$iterable_types || !$did_remove_type) && !$is_equality) { + if ($key && $code_location) { + self::triggerIssueForImpossible( + $existing_var_type, + $old_var_type_string, + $key, + $new_var_type, + !$did_remove_type, + $code_location, + $suppressed_issues + ); + } + } + + if ($iterable_types) { + return new Type\Union($iterable_types); + } + + $failed_reconciliation = 2; + + return Type::getMixed(); + } + + if ($new_var_type === 'countable') { + if ($existing_var_type->hasMixed()) { + return new Type\Union([ + new Type\Atomic\TArray([Type::getArrayKey(), Type::getMixed()]), + new Type\Atomic\TNamedObject('Countable') + ]); + } + + $iterable_types = []; + $did_remove_type = false; + + foreach ($existing_var_atomic_types as $type) { + if ($type->isCountable($codebase)) { + $iterable_types[] = $type; + } elseif ($type instanceof TObject) { + $iterable_types[] = new TNamedObject('Countable'); + $did_remove_type = true; + } elseif ($type instanceof TNamedObject || $type instanceof Type\Atomic\TIterable) { + $countable = new TNamedObject('Countable'); + $type->extra_types[$countable->getKey()] = $countable; + $iterable_types[] = $type; $did_remove_type = true; } else { $did_remove_type = true; @@ -1850,7 +1904,7 @@ class Reconciler return $existing_var_type; } - if ($new_var_type === 'iterable') { + if ($new_var_type === 'iterable' || $new_var_type === 'countable') { $existing_var_type->removeType('array'); } diff --git a/tests/FunctionCallTest.php b/tests/FunctionCallTest.php index 58280178a..8ed1397bb 100644 --- a/tests/FunctionCallTest.php +++ b/tests/FunctionCallTest.php @@ -1713,6 +1713,18 @@ class FunctionCallTest extends TestCase /** @psalm-suppress TooFewArguments */ min(0);', ], + 'PHP73-allowIsCountableToInformType' => [ + '