diff --git a/src/Psalm/Codebase.php b/src/Psalm/Codebase.php index 2e51cd4975a..a470bb17be5 100644 --- a/src/Psalm/Codebase.php +++ b/src/Psalm/Codebase.php @@ -806,6 +806,22 @@ public function getFunctionLikeStorage( return $this->functions->getStorage($statements_analyzer, strtolower($function_id)); } + /** + * Whether or not a given property exists + */ + public function propertyExists( + string $property_id, + ?CodeLocation $code_location = null + ): bool { + return $this->properties->propertyExists( + $property_id, + true, // $read_mode, not sure about this + null, + null, + $code_location, + ); + } + /** * Whether or not a given method exists * diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/AssertionFinder.php b/src/Psalm/Internal/Analyzer/Statements/Expression/AssertionFinder.php index 352382720b0..f955f320944 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/AssertionFinder.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/AssertionFinder.php @@ -47,6 +47,7 @@ use Psalm\Storage\Assertion\HasAtLeastCount; use Psalm\Storage\Assertion\HasExactCount; use Psalm\Storage\Assertion\HasMethod; +use Psalm\Storage\Assertion\HasProperty; use Psalm\Storage\Assertion\InArray; use Psalm\Storage\Assertion\IsAClass; use Psalm\Storage\Assertion\IsClassEqual; @@ -948,6 +949,14 @@ public static function processFunctionCall( if ($first_var_name) { $if_types[$first_var_name] = [[new HasMethod($expr->getArgs()[1]->value->value)]]; } + } elseif ($expr->name instanceof PhpParser\Node\Name + && strtolower($expr->name->parts[0]) === 'property_exists' + && isset($expr->getArgs()[1]) + && $expr->getArgs()[1]->value instanceof PhpParser\Node\Scalar\String_ + ) { + if ($first_var_name) { + $if_types[$first_var_name] = [[new HasProperty($expr->getArgs()[1]->value->value)]]; + } } elseif (self::hasInArrayCheck($expr) && $source instanceof StatementsAnalyzer) { return self::getInarrayAssertions($expr, $source, $first_var_name); } elseif (self::hasArrayKeyExistsCheck($expr)) { diff --git a/src/Psalm/Internal/Type/SimpleAssertionReconciler.php b/src/Psalm/Internal/Type/SimpleAssertionReconciler.php index 44adb58dfb2..e619d284ab9 100644 --- a/src/Psalm/Internal/Type/SimpleAssertionReconciler.php +++ b/src/Psalm/Internal/Type/SimpleAssertionReconciler.php @@ -15,6 +15,7 @@ use Psalm\Storage\Assertion\HasExactCount; use Psalm\Storage\Assertion\HasIntOrStringArrayAccess; use Psalm\Storage\Assertion\HasMethod; +use Psalm\Storage\Assertion\HasProperty; use Psalm\Storage\Assertion\HasStringArrayAccess; use Psalm\Storage\Assertion\InArray; use Psalm\Storage\Assertion\IsCountable; @@ -286,6 +287,19 @@ public static function reconcile( ); } + if ($assertion instanceof HasProperty) { + return self::reconcileHasProperty( + $assertion, + $codebase, + $existing_var_type, + $key, + $negated, + $code_location, + $suppressed_issues, + $failed_reconciliation, + ); + } + $assertion_type = $assertion->getAtomicType(); if ($assertion_type instanceof TObject) { @@ -967,6 +981,120 @@ private static function reconcileHasMethod( : Type::getNever(); } + /** + * @param string[] $suppressed_issues + * @param Reconciler::RECONCILIATION_* $failed_reconciliation + */ + private static function reconcileHasProperty( + HasProperty $assertion, + Codebase $codebase, + Union $existing_var_type, + ?string $key, + bool $negated, + ?CodeLocation $code_location, + array $suppressed_issues, + int &$failed_reconciliation + ): Union { + $property_name = $assertion->property_name; + + $old_var_type_string = $existing_var_type->getId(); + $existing_var_atomic_types = $existing_var_type->getAtomicTypes(); + + $object_types = []; + $redundant = true; + + foreach ($existing_var_atomic_types as $type) { + if ($type instanceof TNamedObject + && $codebase->classOrInterfaceExists($type->value) + ) { + if (!$codebase->propertyExists($type->value . '::$' . $property_name)) { + $match_found = false; + + $extra_types = $type->extra_types; + foreach ($type->extra_types as $k => $extra_type) { + if ($extra_type instanceof TNamedObject + && $codebase->classOrInterfaceExists($extra_type->value) + && $codebase->propertyExists($extra_type->value . '::$' . $property_name) + ) { + $match_found = true; + } elseif ($extra_type instanceof TObjectWithProperties) { + $match_found = true; + + if (!isset($extra_type->properties[$property_name])) { + unset($extra_types[$k]); + $extra_type = $extra_type->setProperties(array_merge( + $extra_type->properties, + [$property_name => Type::getMixed()], + )); + $extra_types[$extra_type->getKey()] = $extra_type; + $redundant = false; + } + } + } + + if (!$match_found) { + $extra_type = new TObjectWithProperties( + [$property_name => Type::getMixed()], + ); + $extra_types[$extra_type->getKey()] = $extra_type; + $redundant = false; + } + + $type = $type->setIntersectionTypes($extra_types); + } + $object_types[] = $type; + } elseif ($type instanceof TObjectWithProperties) { + if (!isset($type->properties[$property_name])) { + $type = $type->setProperties(array_merge( + $type->properties, + [$property_name => Type::getMixed()], + )); + $redundant = false; + } + $object_types[] = $type; + } elseif ($type instanceof TObject || $type instanceof TMixed) { + $object_types[] = new TObjectWithProperties( + [$property_name => Type::getMixed()], + ); + $redundant = false; + } elseif ($type instanceof TString) { + // we don’t know + $object_types[] = $type; + $redundant = false; + } elseif ($type instanceof TTemplateParam) { + $object_types[] = $type; + $redundant = false; + } else { + $redundant = false; + } + } + + if (!$object_types || $redundant) { + if ($key && $code_location) { + self::triggerIssueForImpossible( + $existing_var_type, + $old_var_type_string, + $key, + $assertion, + $redundant, + $negated, + $code_location, + $suppressed_issues, + ); + } + } + + if ($object_types) { + return new Union($object_types); + } + + $failed_reconciliation = Reconciler::RECONCILIATION_EMPTY; + + return $existing_var_type->from_docblock + ? new Union([new TEmptyMixed()]) + : Type::getNever(); + } + /** * @param string[] $suppressed_issues * @param Reconciler::RECONCILIATION_* $failed_reconciliation diff --git a/src/Psalm/Storage/Assertion/DoesNotHaveProperty.php b/src/Psalm/Storage/Assertion/DoesNotHaveProperty.php new file mode 100644 index 00000000000..c6293963626 --- /dev/null +++ b/src/Psalm/Storage/Assertion/DoesNotHaveProperty.php @@ -0,0 +1,33 @@ +property_name = $property_name; + } + + public function getNegation(): Assertion + { + return new HasProperty($this->property_name); + } + + public function __toString(): string + { + return '!has-property-' . $this->property_name; + } + + public function isNegationOf(Assertion $assertion): bool + { + return $assertion instanceof HasProperty && $this->property_name === $assertion->property_name; + } +} diff --git a/src/Psalm/Storage/Assertion/HasProperty.php b/src/Psalm/Storage/Assertion/HasProperty.php new file mode 100644 index 00000000000..e03d04d1571 --- /dev/null +++ b/src/Psalm/Storage/Assertion/HasProperty.php @@ -0,0 +1,33 @@ +property_name = $property_name; + } + + public function getNegation(): Assertion + { + return new DoesNotHaveProperty($this->property_name); + } + + public function __toString(): string + { + return 'has-property-' . $this->property_name; + } + + public function isNegationOf(Assertion $assertion): bool + { + return $assertion instanceof DoesNotHaveProperty && $this->property_name === $assertion->property_name; + } +} diff --git a/tests/PropertyTypeTest.php b/tests/PropertyTypeTest.php index ab8a6325add..0dfe7daf881 100644 --- a/tests/PropertyTypeTest.php +++ b/tests/PropertyTypeTest.php @@ -2687,6 +2687,21 @@ class Baz 'ignored_issues' => [], 'php_version' => '8.1', ], + 'propertyExists' => [ + 'code' => <<<'PHP' + [ + '$z===' => 'A&object{foo:mixed}|null', + ], + ], ]; }