diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Fetch/AtomicPropertyFetchAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Fetch/AtomicPropertyFetchAnalyzer.php index 585d7ca1d..dce178f32 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Fetch/AtomicPropertyFetchAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Fetch/AtomicPropertyFetchAnalyzer.php @@ -125,7 +125,19 @@ class AtomicPropertyFetchAnalyzer $statements_analyzer->node_data->setType( $stmt, Type::combineUnionTypes( - $lhs_type_part->properties[$prop_name], + TypeExpander::expandUnion( + $statements_analyzer->getCodebase(), + $lhs_type_part->properties[$prop_name], + null, + null, + null, + true, + true, + false, + true, + true, + true, + ), $stmt_type, ), ); @@ -133,11 +145,19 @@ class AtomicPropertyFetchAnalyzer return; } + $intersection_types = []; + if (!$lhs_type_part instanceof TObject) { + $intersection_types = $lhs_type_part->getIntersectionTypes(); + } + // stdClass and SimpleXMLElement are special cases where we cannot infer the return types // but we don't want to throw an error // Hack has a similar issue: https://github.com/facebook/hhvm/issues/5164 if ($lhs_type_part instanceof TObject - || in_array(strtolower($lhs_type_part->value), Config::getInstance()->getUniversalObjectCrates(), true) + || ( + in_array(strtolower($lhs_type_part->value), Config::getInstance()->getUniversalObjectCrates(), true) + && $intersection_types === [] + ) ) { $statements_analyzer->node_data->setType($stmt, Type::getMixed()); @@ -149,8 +169,6 @@ class AtomicPropertyFetchAnalyzer return; } - $intersection_types = $lhs_type_part->getIntersectionTypes() ?: []; - $fq_class_name = $lhs_type_part->value; $override_property_visibility = false; @@ -237,39 +255,60 @@ class AtomicPropertyFetchAnalyzer // add method before changing fq_class_name $get_method_id = new MethodIdentifier($fq_class_name, '__get'); - if (!$naive_property_exists - && $class_storage->namedMixins - ) { - foreach ($class_storage->namedMixins as $mixin) { - $new_property_id = $mixin->value . '::$' . $prop_name; + if (!$naive_property_exists) { + if ($class_storage->namedMixins) { + foreach ($class_storage->namedMixins as $mixin) { + $new_property_id = $mixin->value . '::$' . $prop_name; - try { - $new_class_storage = $codebase->classlike_storage_provider->get($mixin->value); - } catch (InvalidArgumentException $e) { - $new_class_storage = null; - } - - if ($new_class_storage - && ($codebase->properties->propertyExists( - $new_property_id, - !$in_assignment, - $statements_analyzer, - $context, - $codebase->collect_locations - ? new CodeLocation($statements_analyzer->getSource(), $stmt) - : null, - ) - || isset($new_class_storage->pseudo_property_get_types['$' . $prop_name])) - ) { - $fq_class_name = $mixin->value; - $lhs_type_part = $mixin; - $class_storage = $new_class_storage; - - if (!isset($new_class_storage->pseudo_property_get_types['$' . $prop_name])) { - $naive_property_exists = true; + try { + $new_class_storage = $codebase->classlike_storage_provider->get($mixin->value); + } catch (InvalidArgumentException $e) { + $new_class_storage = null; } - $property_id = $new_property_id; + if ($new_class_storage + && ($codebase->properties->propertyExists( + $new_property_id, + !$in_assignment, + $statements_analyzer, + $context, + $codebase->collect_locations + ? new CodeLocation($statements_analyzer->getSource(), $stmt) + : null, + ) + || isset($new_class_storage->pseudo_property_get_types['$' . $prop_name])) + ) { + $fq_class_name = $mixin->value; + $lhs_type_part = $mixin; + $class_storage = $new_class_storage; + + if (!isset($new_class_storage->pseudo_property_get_types['$' . $prop_name])) { + $naive_property_exists = true; + } + + $property_id = $new_property_id; + } + } + } elseif ($intersection_types !== [] && !$class_storage->final) { + foreach ($intersection_types as $intersection_type) { + self::analyze( + $statements_analyzer, + $stmt, + $context, + $in_assignment, + $var_id, + $stmt_var_id, + $stmt_var_type, + $intersection_type, + $prop_name, + $has_valid_fetch_type, + $invalid_fetch_types, + $is_static_access, + ); + + if ($has_valid_fetch_type) { + return; + } } } } diff --git a/src/Psalm/Internal/Type/SimpleAssertionReconciler.php b/src/Psalm/Internal/Type/SimpleAssertionReconciler.php index 6307f0ab2..51d94cb34 100644 --- a/src/Psalm/Internal/Type/SimpleAssertionReconciler.php +++ b/src/Psalm/Internal/Type/SimpleAssertionReconciler.php @@ -28,6 +28,7 @@ use Psalm\Storage\Assertion\NonEmpty; use Psalm\Storage\Assertion\NonEmptyCountable; use Psalm\Storage\Assertion\Truthy; use Psalm\Type; +use Psalm\Type\Atomic; use Psalm\Type\Atomic\Scalar; use Psalm\Type\Atomic\TArray; use Psalm\Type\Atomic\TArrayKey; @@ -292,7 +293,9 @@ class SimpleAssertionReconciler extends Reconciler if ($assertion_type instanceof TObject) { return self::reconcileObject( + $codebase, $assertion, + $assertion_type, $existing_var_type, $key, $negated, @@ -1580,7 +1583,9 @@ class SimpleAssertionReconciler extends Reconciler * @param Reconciler::RECONCILIATION_* $failed_reconciliation */ private static function reconcileObject( + Codebase $codebase, Assertion $assertion, + TObject $assertion_type, Union $existing_var_type, ?string $key, bool $negated, @@ -1600,7 +1605,17 @@ class SimpleAssertionReconciler extends Reconciler $redundant = true; foreach ($existing_var_atomic_types as $type) { - if ($type->isObjectType()) { + if (Type::isIntersectionType($assertion_type) + && self::areIntersectionTypesAllowed($codebase, $type) + ) { + $object_types[] = $type->addIntersectionType($assertion_type); + $redundant = false; + } elseif ($type instanceof TNamedObject + && $codebase->classlike_storage_provider->has($type->value) + && $codebase->classlike_storage_provider->get($type->value)->final + ) { + $redundant = false; + } elseif ($type->isObjectType()) { $object_types[] = $type; } elseif ($type instanceof TCallable) { $callable_object = new TCallableObject($type->from_docblock, $type); @@ -1614,8 +1629,17 @@ class SimpleAssertionReconciler extends Reconciler $redundant = false; } elseif ($type instanceof TTemplateParam) { if ($type->as->hasObject() || $type->as->hasMixed()) { - $type = $type->replaceAs(self::reconcileObject( + /** + * @psalm-suppress PossiblyInvalidArgument This looks wrong, psalm assumes that $assertion_type + * can contain TNamedObject due to the reconciliation above + * regarding {@see Type::isIntersectionType}. Due to the + * native argument type `TObject`, the variable object will + * never be `TNamedObject`. + */ + $reconciled_type = self::reconcileObject( + $codebase, $assertion, + $assertion_type, $type->as, null, false, @@ -1623,7 +1647,8 @@ class SimpleAssertionReconciler extends Reconciler $suppressed_issues, $failed_reconciliation, $is_equality, - )); + ); + $type = $type->replaceAs($reconciled_type); $object_types[] = $type; } @@ -2920,4 +2945,22 @@ class SimpleAssertionReconciler extends Reconciler return TypeCombiner::combine(array_values($matched_class_constant_types), $codebase); } + + /** + * @psalm-assert-if-true TCallableObject|TObjectWithProperties|TNamedObject $type + */ + private static function areIntersectionTypesAllowed(Codebase $codebase, Atomic $type): bool + { + if ($type instanceof TObjectWithProperties || $type instanceof TCallableObject) { + return true; + } + + if (!$type instanceof TNamedObject || !$codebase->classlike_storage_provider->has($type->value)) { + return false; + } + + $class_storage = $codebase->classlike_storage_provider->get($type->value); + + return !$class_storage->final; + } } diff --git a/src/Psalm/Type.php b/src/Psalm/Type.php index 4a066707b..314e3e129 100644 --- a/src/Psalm/Type.php +++ b/src/Psalm/Type.php @@ -15,6 +15,7 @@ use Psalm\Type\Atomic; use Psalm\Type\Atomic\TArray; use Psalm\Type\Atomic\TArrayKey; use Psalm\Type\Atomic\TBool; +use Psalm\Type\Atomic\TCallableObject; use Psalm\Type\Atomic\TClassString; use Psalm\Type\Atomic\TClosure; use Psalm\Type\Atomic\TFalse; @@ -962,10 +963,18 @@ abstract class Type private static function hasIntersection(Atomic $type): bool { - return ($type instanceof TIterable - || $type instanceof TNamedObject - || $type instanceof TTemplateParam - || $type instanceof TObjectWithProperties - ) && $type->extra_types; + return self::isIntersectionType($type) && $type->extra_types; + } + + /** + * @psalm-assert-if-true TNamedObject|TTemplateParam|TIterable|TObjectWithProperties|TCallableObject $type + */ + public static function isIntersectionType(Atomic $type): bool + { + return $type instanceof TNamedObject + || $type instanceof TTemplateParam + || $type instanceof TIterable + || $type instanceof TObjectWithProperties + || $type instanceof TCallableObject; } } diff --git a/tests/Template/FunctionTemplateAssertTest.php b/tests/Template/FunctionTemplateAssertTest.php index e8f9c855d..94eed8658 100644 --- a/tests/Template/FunctionTemplateAssertTest.php +++ b/tests/Template/FunctionTemplateAssertTest.php @@ -916,6 +916,27 @@ class FunctionTemplateAssertTest extends TestCase $numbersT->assert($mixed); acceptsArray($mixed);', ], + 'assertObjectShape' => [ + 'code' => 'status; + ', + 'assertions' => [ + '$status===' => "'fail'|'ok'", + ], + ], ]; } @@ -1196,6 +1217,23 @@ class FunctionTemplateAssertTest extends TestCase }', 'error_message' => 'InvalidDocblock', ], + 'assertObjectShapeOnFinalClass' => [ + 'code' => 'status; + ', + 'error_message' => 'Type Foo for $foo is never', + ], ]; } }