diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/CallAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/CallAnalyzer.php index 5ec273de0a1..5206ea6e830 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/CallAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/CallAnalyzer.php @@ -770,6 +770,7 @@ public static function applyAssertionsToContext( ) { continue; } + $assertion_rule = clone $assertion_rule; $assertion_rule->setAtomicType($atomic_type); $orred_rules[] = $assertion_rule; @@ -795,6 +796,23 @@ public static function applyAssertionsToContext( ); } } + } elseif (isset($context->vars_in_scope[$assertion_var_id])) { + $other_type = $context->vars_in_scope[$assertion_var_id]; + $union = self::createUnionIntersectionFromOldType($union, $other_type); + + if ($union !== null) { + foreach ($union->getAtomicTypes() as $atomic_type) { + if ($assertion_type instanceof TTemplateParam + && $assertion_type->as->getId() === $atomic_type->getId() + ) { + continue; + } + + $assertion_rule = clone $assertion_rule; + $assertion_rule->setAtomicType($atomic_type); + $orred_rules[] = $assertion_rule; + } + } } } else { $orred_rules[] = $assertion_rule; @@ -1116,4 +1134,82 @@ public static function checkTemplateResult( } } } + + /** + * This method should detect if the new type narrows down the old type. + */ + private static function isNewTypeNarrowingDownOldType(Union $old_type, Union $new_type): bool + { + if ($new_type->isSingle()) { + return true; + } + + // non-mixed is always better than mixed + if ($old_type->isMixed() && !$new_type->hasMixed()) { + return true; + } + + // non-nullable is always better than nullable + if ($old_type->isNullable() && !$new_type->isNullable()) { + return true; + } + + // Do not hassle around with non-single old types if they are not nullable + if (!$old_type->isSingle()) { + 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->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 (!self::isNewTypeNarrowingDownOldType($old_type, $new_type)) { + return null; + } + + 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; + } + } + + if ($equal_atomic_types === []) { + return null; + } + + return new Union($equal_atomic_types); + } } diff --git a/src/Psalm/Type/Atomic/TLiteralFloat.php b/src/Psalm/Type/Atomic/TLiteralFloat.php index 4e11468304f..2113c39a7bc 100644 --- a/src/Psalm/Type/Atomic/TLiteralFloat.php +++ b/src/Psalm/Type/Atomic/TLiteralFloat.php @@ -2,6 +2,10 @@ namespace Psalm\Type\Atomic; +use Psalm\Type\Atomic; + +use function get_class; + /** * Denotes a floating point value where the exact numeric value is known. */ @@ -41,4 +45,13 @@ public function toNamespacedString( ): string { return 'float'; } + + public function equals(Atomic $other_type, bool $ensure_source_equality): bool + { + if (get_class($other_type) !== static::class) { + return false; + } + + return $this->value === $other_type->value; + } } diff --git a/src/Psalm/Type/Atomic/TLiteralInt.php b/src/Psalm/Type/Atomic/TLiteralInt.php index 390606ebb1b..472ce674c96 100644 --- a/src/Psalm/Type/Atomic/TLiteralInt.php +++ b/src/Psalm/Type/Atomic/TLiteralInt.php @@ -2,6 +2,10 @@ namespace Psalm\Type\Atomic; +use Psalm\Type\Atomic; + +use function get_class; + /** * Denotes an integer value where the exact numeric value is known. */ @@ -46,4 +50,19 @@ public function toNamespacedString( ): string { return $use_phpdoc_format ? 'int' : (string) $this->value; } + + public function equals(Atomic $other_type, bool $ensure_source_equality): bool + { + if (get_class($other_type) !== static::class) { + return false; + } + + if (($this->from_docblock && $ensure_source_equality) + || ($other_type->from_docblock && $ensure_source_equality) + ) { + return false; + } + + return $this->value === $other_type->value; + } } diff --git a/src/Psalm/Type/Atomic/TLiteralString.php b/src/Psalm/Type/Atomic/TLiteralString.php index 71ae7f73551..afbaef2f5d1 100644 --- a/src/Psalm/Type/Atomic/TLiteralString.php +++ b/src/Psalm/Type/Atomic/TLiteralString.php @@ -2,7 +2,10 @@ namespace Psalm\Type\Atomic; +use Psalm\Type\Atomic; + use function addcslashes; +use function get_class; use function mb_strlen; use function mb_substr; @@ -55,4 +58,19 @@ public function toNamespacedString( ): string { return $use_phpdoc_format ? 'string' : "'" . $this->value . "'"; } + + public function equals(Atomic $other_type, bool $ensure_source_equality): bool + { + if (get_class($other_type) !== static::class) { + return false; + } + + if (($this->from_docblock && $ensure_source_equality) + || ($other_type->from_docblock && $ensure_source_equality) + ) { + return false; + } + + return $this->value === $other_type->value; + } } diff --git a/src/Psalm/Type/Union.php b/src/Psalm/Type/Union.php index 1adc2717c2e..8262e14f272 100644 --- a/src/Psalm/Type/Union.php +++ b/src/Psalm/Type/Union.php @@ -1267,6 +1267,17 @@ public function allIntLiterals(): bool return true; } + public function allFloatLiterals(): bool + { + foreach ($this->types as $atomic_key_type) { + if (!$atomic_key_type instanceof TLiteralFloat) { + return false; + } + } + + return true; + } + public function allLiterals(): bool { foreach ($this->types as $atomic_key_type) { diff --git a/tests/AssertAnnotationTest.php b/tests/AssertAnnotationTest.php index e32297b4f3d..35b0cb909d5 100644 --- a/tests/AssertAnnotationTest.php +++ b/tests/AssertAnnotationTest.php @@ -90,7 +90,6 @@ function requiresString(string $_str): void {} $this->analyzeFile('somefile.php', new Context()); } - /** * @return iterable,ignored_issues?:list}> */ @@ -2055,6 +2054,65 @@ function makeLowerNonEmpty(string $input): string return $input; }', ], + 'assertOneOfValuesWithinArray' => [ + 'code' => ' $values + * @psalm-assert T $input + */ + function assertOneOf($input, array $values): void {} + + /** @param "a" $value */ + function consumeSpecificStringValue(string $value): void {} + + /** @param literal-string $value */ + function consumeLiteralStringValue(string $value): void {} + + function consumeAnyIntegerValue(int $value): void {} + + function consumeAnyFloatValue(float $value): void {} + + /** @var string $string */ + $string; + + /** @var string $anotherString */ + $anotherString; + + /** @var null|string $nullableString */ + $nullableString; + + /** @var mixed $maybeInt */ + $maybeInt; + /** @var mixed $maybeFloat */ + $maybeFloat; + + assertOneOf($string, ["a"]); + consumeSpecificStringValue($string); + + 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); + ' + ], ]; }