diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/CallAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/CallAnalyzer.php index bd086f1921d..e4756e86d05 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/CallAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/CallAnalyzer.php @@ -753,20 +753,20 @@ public static function applyAssertionsToContext( $orred_rules = []; foreach ($var_possibilities->rule as $assertion_rule) { - $assertion_type = $assertion_rule->getAtomicType(); + $assertion_type_atomic = $assertion_rule->getAtomicType(); - if ($assertion_type) { - $union = new Union([clone $assertion_type]); + if ($assertion_type_atomic) { + $assertion_type = new Union([clone $assertion_type_atomic]); TemplateInferredTypeReplacer::replace( - $union, + $assertion_type, $template_result, $codebase ); - if ($union->isSingle()) { - foreach ($union->getAtomicTypes() as $atomic_type) { - if ($assertion_type instanceof TTemplateParam - && $assertion_type->as->getId() === $atomic_type->getId() + if (count($assertion_type->getAtomicTypes()) === 1) { + foreach ($assertion_type->getAtomicTypes() as $atomic_type) { + if ($assertion_type_atomic instanceof TTemplateParam + && $assertion_type_atomic->as->getId() === $atomic_type->getId() ) { continue; } @@ -775,43 +775,46 @@ public static function applyAssertionsToContext( $assertion_rule->setAtomicType($atomic_type); $orred_rules[] = $assertion_rule; } - } elseif (isset($context->vars_in_scope[$var_possibilities->var_id])) { - $other_type = $context->vars_in_scope[$var_possibilities->var_id]; + } elseif (isset($context->vars_in_scope[$assertion_var_id])) { + $asserted_type = $context->vars_in_scope[$assertion_var_id]; + if ($assertion_rule instanceof IsIdentical) { + $intersection = Type::intersectUnionTypes($assertion_type, $asserted_type, $codebase); - if ($assertion_rule instanceof IsIdentical - || $assertion_rule instanceof IsType - ) { + if ($intersection === null) { + IssueBuffer::maybeAdd( + new TypeDoesNotContainType( + $asserted_type->getId() . ' is not contained by ' + . $assertion_type->getId(), + new CodeLocation($statements_analyzer->getSource(), $expr), + $asserted_type->getId() . ' ' . $assertion_type->getId() + ), + $statements_analyzer->getSuppressedIssues() + ); + $intersection = Type::getNever(); + } elseif ($intersection->getId(true) === $asserted_type->getId(true)) { + continue; + } + foreach ($intersection->getAtomicTypes() as $atomic_type) { + $orred_rules[] = new IsIdentical($atomic_type); + } + } elseif ($assertion_rule instanceof IsType) { if (!UnionTypeComparator::canExpressionTypesBeIdentical( $codebase, - $union, - $context->vars_in_scope[$var_possibilities->var_id] + $assertion_type, + $asserted_type )) { IssueBuffer::maybeAdd( new TypeDoesNotContainType( - $union->getId() . ' cannot be identical to ' . $other_type->getId(), + $asserted_type->getId() . ' is not contained by ' + . $assertion_type->getId(), new CodeLocation($statements_analyzer->getSource(), $expr), - $union->getId() . ' ' . $other_type->getId() + $asserted_type->getId() . ' ' . $assertion_type->getId() ), $statements_analyzer->getSuppressedIssues() ); } - } - } 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 { + // Ignore negations and loose assertions with union types } } } else { @@ -1135,82 +1138,4 @@ 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.php b/src/Psalm/Type.php index 88c31bac377..8508caa2c15 100644 --- a/src/Psalm/Type.php +++ b/src/Psalm/Type.php @@ -19,6 +19,7 @@ use Psalm\Type\Atomic\TFalse; use Psalm\Type\Atomic\TFloat; use Psalm\Type\Atomic\TInt; +use Psalm\Type\Atomic\TIntRange; use Psalm\Type\Atomic\TIterable; use Psalm\Type\Atomic\TList; use Psalm\Type\Atomic\TLiteralClassString; @@ -691,6 +692,23 @@ private static function intersectAtomicTypes( $intersection_performed = true; } } + if ($type_1_atomic instanceof TInt && $type_2_atomic instanceof TInt) { + $int_intersection = TIntRange::intersectIntRanges( + TIntRange::convertToIntRange($type_1_atomic), + TIntRange::convertToIntRange($type_2_atomic) + ); + if ($int_intersection + && ($int_intersection->min_bound !== null || $int_intersection->max_bound !== null) + ) { + $intersection_performed = true; + if ($int_intersection->min_bound !== null + && $int_intersection->min_bound === $int_intersection->max_bound + ) { + return new TLiteralInt($int_intersection->min_bound); + } + return $int_intersection; + } + } if (null === $intersection_atomic) { if (AtomicTypeComparator::isContainedBy( @@ -719,9 +737,19 @@ private static function intersectAtomicTypes( } } - if (static::mayHaveIntersection($type_1_atomic) - && static::mayHaveIntersection($type_2_atomic) + if (self::mayHaveIntersection($type_1_atomic, $codebase) + && self::mayHaveIntersection($type_2_atomic, $codebase) ) { + /** @psalm-suppress TypeDoesNotContainType */ + if ($type_1_atomic instanceof TNamedObject && $type_2_atomic instanceof TNamedObject) { + $first = $codebase->classlike_storage_provider->get($type_1_atomic->value); + $second = $codebase->classlike_storage_provider->get($type_2_atomic->value); + $first_is_class = !$first->is_interface && !$first->is_trait; + $second_is_class = !$second->is_interface && !$second->is_trait; + if ($first_is_class && $second_is_class) { + return $intersection_atomic; + } + } if ($intersection_atomic === null && $wider_type === null) { $intersection_atomic = clone $type_1_atomic; $wider_type = $type_2_atomic; @@ -733,8 +761,8 @@ private static function intersectAtomicTypes( .' Did you forget to assign one of the variables?' ); } - if (!static::mayHaveIntersection($intersection_atomic) - || !static::mayHaveIntersection($wider_type) + if (!self::mayHaveIntersection($intersection_atomic, $codebase) + || !self::mayHaveIntersection($wider_type, $codebase) ) { throw new LogicException( '$intersection_atomic and $wider_type should be both support intersection.' @@ -769,12 +797,19 @@ private static function intersectAtomicTypes( /** * @psalm-assert-if-true TIterable|TNamedObject|TTemplateParam|TObjectWithProperties $type */ - private static function mayHaveIntersection(Atomic $type): bool + private static function mayHaveIntersection(Atomic $type, Codebase $codebase): bool { - return $type instanceof TIterable - || $type instanceof TNamedObject + if ($type instanceof TIterable || $type instanceof TTemplateParam - || $type instanceof TObjectWithProperties; + || $type instanceof TObjectWithProperties + ) { + return true; + } + if (!$type instanceof TNamedObject) { + return false; + } + $storage = $codebase->classlike_storage_provider->get($type->value); + return !$storage->final; } private static function hasIntersection(Atomic $type): bool diff --git a/src/Psalm/Type/Atomic/TLiteralFloat.php b/src/Psalm/Type/Atomic/TLiteralFloat.php index 2113c39a7bc..4e11468304f 100644 --- a/src/Psalm/Type/Atomic/TLiteralFloat.php +++ b/src/Psalm/Type/Atomic/TLiteralFloat.php @@ -2,10 +2,6 @@ namespace Psalm\Type\Atomic; -use Psalm\Type\Atomic; - -use function get_class; - /** * Denotes a floating point value where the exact numeric value is known. */ @@ -45,13 +41,4 @@ 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 472ce674c96..390606ebb1b 100644 --- a/src/Psalm/Type/Atomic/TLiteralInt.php +++ b/src/Psalm/Type/Atomic/TLiteralInt.php @@ -2,10 +2,6 @@ namespace Psalm\Type\Atomic; -use Psalm\Type\Atomic; - -use function get_class; - /** * Denotes an integer value where the exact numeric value is known. */ @@ -50,19 +46,4 @@ 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 afbaef2f5d1..71ae7f73551 100644 --- a/src/Psalm/Type/Atomic/TLiteralString.php +++ b/src/Psalm/Type/Atomic/TLiteralString.php @@ -2,10 +2,7 @@ namespace Psalm\Type\Atomic; -use Psalm\Type\Atomic; - use function addcslashes; -use function get_class; use function mb_strlen; use function mb_substr; @@ -58,19 +55,4 @@ 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 6036ab10acf..fb0f7d351c0 100644 --- a/src/Psalm/Type/Union.php +++ b/src/Psalm/Type/Union.php @@ -1268,6 +1268,9 @@ public function allIntLiterals(): bool return true; } + /** + * @psalm-suppress PossiblyUnusedMethod Public API + */ public function allFloatLiterals(): bool { foreach ($this->types as $atomic_key_type) { diff --git a/tests/ArrayAssignmentTest.php b/tests/ArrayAssignmentTest.php index 377886620eb..9bd25756490 100644 --- a/tests/ArrayAssignmentTest.php +++ b/tests/ArrayAssignmentTest.php @@ -1758,6 +1758,40 @@ public function offsetUnset(int $offset) : void { } $c = new C(); $c[] = "hello";' ], + 'conditionalRestrictedDocblockKeyAssignment' => [ + 'code' => ' [ + "active" => false, + "icon" => "phone-tube", + ], + "stat" => [ + "active" => false, + "icon" => "review", + ], + "booking" => [ + "active" => false, + "icon" => "settings", + ], + "support" => [ + "active" => false, + "icon" => "help", + ], + ]; + } + $items = getSections(); + /** @var string */ + $currentAction = ""; + if (\array_key_exists($currentAction, $items)) { + $items[$currentAction]["active"] = true; + }' + ] ]; } diff --git a/tests/AssertAnnotationTest.php b/tests/AssertAnnotationTest.php index 35b0cb909d5..b8b858e5dff 100644 --- a/tests/AssertAnnotationTest.php +++ b/tests/AssertAnnotationTest.php @@ -2061,7 +2061,7 @@ function makeLowerNonEmpty(string $input): string * @template T * @param mixed $input * @param array $values - * @psalm-assert T $input + * @psalm-assert =T $input */ function assertOneOf($input, array $values): void {} diff --git a/tests/InterfaceTest.php b/tests/InterfaceTest.php index cef0d576a1d..106219b762e 100644 --- a/tests/InterfaceTest.php +++ b/tests/InterfaceTest.php @@ -641,8 +641,8 @@ function bar($i) : void { ], 'intersectIterators' => [ 'code' => '&iterable $i */ function takesIntersectionOfIterables(iterable $i): void { diff --git a/tests/Template/FunctionTemplateAssertTest.php b/tests/Template/FunctionTemplateAssertTest.php index 9d2d20988e9..61e9674c561 100644 --- a/tests/Template/FunctionTemplateAssertTest.php +++ b/tests/Template/FunctionTemplateAssertTest.php @@ -167,7 +167,7 @@ interface Foo {} * * @param mixed $value * @param class-string $type - * + * * @psalm-assert T $value */ function assertInstanceOf($value, string $type): void { @@ -1089,6 +1089,23 @@ function assertEqual($expected, $actual) : void {} assertEqual($c, $d);', 'error_message' => 'TypeDoesNotContainType', ], + 'assertTemplateUnionParadox' => [ + 'code' => ' 'TypeDoesNotContainType', + ], 'assertNotSameDifferentTypes' => [ 'code' => ' 'RedundantCondition', ], + 'assertNotSameClasses' => [ + 'code' => ' 'TypeDoesNotContainType', + ], 'assertNotSameDifferentTypesExplicitString' => [ 'code' => 'assertSame('array{a: int}', (string) Type::parseString('array{a: int}&array{a?: int}')); } + + public function testIntersectionOfIntranges(): void + { + $this->assertSame('array{a: int<3, 4>}', (string) Type::parseString('array{a: int<2, 4>}&array{a: int<3, 6>}')); + $this->assertSame('array{a: 4}', Type::parseString('array{a: 4}&array{a: int<3, 6>}')->getId(true)); + } + public function testIntersectionOfTKeyedArrayWithConflictingProperties(): void { $this->expectException(TypeParseTreeException::class);