diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/CallAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/CallAnalyzer.php index ab3e0bb6a..1de531b9b 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/CallAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/CallAnalyzer.php @@ -796,6 +796,7 @@ class CallAnalyzer } elseif (isset($context->vars_in_scope[$assertion_var_id])) { $other_type = $context->vars_in_scope[$assertion_var_id]; if (self::isNewTypeNarrowingDownOldType($other_type, $union)) { + $union = self::createUnionIntersectionFromOldType($union, $other_type); foreach ($union->getAtomicTypes() as $atomic_type) { if ($assertion_type instanceof TTemplateParam && $assertion_type->as->getId() === $atomic_type->getId() @@ -1154,25 +1155,49 @@ class CallAnalyzer return false; } - $old_atomic_type = $old_type->getSingleAtomic(); - foreach ($new_type->getAtomicTypes() as $new_atomic_type) { - if ($new_atomic_type->equals($old_atomic_type, false)) { - // Old type is one of the new types and thus, the old type must not be modified - return false; - } + // Do not hassle around with single literals as they supposed to be more accurate than any new type assertion + if ($old_type->isSingleFloatLiteral() + || $old_type->isSingleIntLiteral() + || $old_type->isSingleStringLiteral() + ) { + return false; } // Literals should always replace non-literals - if (!$old_type->containsAnyLiteral() && - ( - ($old_type->isString() && $new_type->allStringLiterals()) - || ($old_type->isInt() && $new_type->allIntLiterals()) - || ($old_type->isFloat() && $new_type->allFloatLiterals()) - ) + if (($old_type->isString() && $new_type->allStringLiterals()) + || ($old_type->isInt() && $new_type->allIntLiterals()) + || ($old_type->isFloat() && $new_type->allFloatLiterals()) ) { return true; } return false; } + + /** + * This method should kick all literals within `new_type` which are not part of the already known `old_type`. + * So lets say we already know that the old type is one of "a", "b" or "c". + * If another assertion takes place to determine if the value is either "a", "c" or "d", we can kick "d" as that + * won't be possible. + */ + private static function createUnionIntersectionFromOldType(Union $new_type, Union $old_type): Union + { + if (!$new_type->allLiterals() || !$old_type->allLiterals()) { + return $new_type; + } + + $equal_atomic_types = []; + + foreach ($new_type->getAtomicTypes() as $new_atomic_type) { + foreach ($old_type->getAtomicTypes() as $old_atomic_type) { + if (!$new_atomic_type->equals($old_atomic_type, false)) { + continue; + } + + $equal_atomic_types[] = $new_atomic_type; + } + } + + return new Union($equal_atomic_types); + } } diff --git a/tests/AssertAnnotationTest.php b/tests/AssertAnnotationTest.php index 76826645a..35b0cb909 100644 --- a/tests/AssertAnnotationTest.php +++ b/tests/AssertAnnotationTest.php @@ -90,7 +90,6 @@ class AssertAnnotationTest extends TestCase $this->analyzeFile('somefile.php', new Context()); } - /** * @return iterable,ignored_issues?:list}> */ @@ -2082,6 +2081,9 @@ class AssertAnnotationTest extends TestCase /** @var string $anotherString */ $anotherString; + /** @var null|string $nullableString */ + $nullableString; + /** @var mixed $maybeInt */ $maybeInt; /** @var mixed $maybeFloat */ @@ -2093,11 +2095,22 @@ class AssertAnnotationTest extends TestCase assertOneOf($anotherString, ["a", "b", "c"]); consumeLiteralStringValue($anotherString); + assertOneOf($nullableString, ["a", "b", "c"]); + assertOneOf($nullableString, ["a", "c"]); + assertOneOf($maybeInt, [1, 2, 3]); consumeAnyIntegerValue($maybeInt); assertOneOf($maybeFloat, [1.5, 2.5, 3.5]); consumeAnyFloatValue($maybeFloat); + + /** @var "a"|"b"|"c" $abc */ + $abc; + + /** @param "a"|"b" $aOrB */ + function consumeAOrB(string $aOrB): void {} + assertOneOf($abc, ["a", "b"]); + consumeAOrB($abc); ' ], ];