diff --git a/src/Psalm/Codebase.php b/src/Psalm/Codebase.php index e96fb08ed19..9aff04e1081 100644 --- a/src/Psalm/Codebase.php +++ b/src/Psalm/Codebase.php @@ -684,6 +684,8 @@ public function classOrInterfaceExists( /** * Check whether a class/interface exists + * + * @psalm-assert-if-true class-string|interface-string|enum-string $fq_class_name */ public function classOrInterfaceOrEnumExists( string $fq_class_name, diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/Method/ExistingAtomicMethodCallAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/Method/ExistingAtomicMethodCallAnalyzer.php index 980043f1ce7..35fda473155 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/Method/ExistingAtomicMethodCallAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/Method/ExistingAtomicMethodCallAnalyzer.php @@ -15,6 +15,7 @@ use Psalm\Internal\Analyzer\Statements\Expression\ExpressionIdentifier; use Psalm\Internal\Analyzer\StatementsAnalyzer; use Psalm\Internal\Analyzer\TraitAnalyzer; +use Psalm\Internal\Codebase\AssertionsFromInheritanceResolver; use Psalm\Internal\Codebase\InternalCallMapHandler; use Psalm\Internal\FileManipulation\FileManipulationBuffer; use Psalm\Internal\MethodIdentifier; @@ -415,11 +416,14 @@ public static function analyze( } } - if ($method_storage->assertions) { + $assertionsResolver = new AssertionsFromInheritanceResolver($codebase); + $assertions = $assertionsResolver->resolve($method_storage, $class_storage); + + if ($assertions) { self::applyAssertionsToContext( $stmt_name, ExpressionIdentifier::getExtendedVarId($stmt->var, null, $statements_analyzer), - $method_storage->assertions, + $assertions, $args, $template_result, $context, diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/StaticMethod/ExistingAtomicStaticCallAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/StaticMethod/ExistingAtomicStaticCallAnalyzer.php index 607f521881a..a8c916534ea 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/StaticMethod/ExistingAtomicStaticCallAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/StaticMethod/ExistingAtomicStaticCallAnalyzer.php @@ -15,6 +15,7 @@ use Psalm\Internal\Analyzer\Statements\Expression\Call\StaticCallAnalyzer; use Psalm\Internal\Analyzer\Statements\Expression\CallAnalyzer; use Psalm\Internal\Analyzer\StatementsAnalyzer; +use Psalm\Internal\Codebase\AssertionsFromInheritanceResolver; use Psalm\Internal\FileManipulation\FileManipulationBuffer; use Psalm\Internal\MethodIdentifier; use Psalm\Internal\Type\Comparator\UnionTypeComparator; @@ -317,11 +318,17 @@ public static function analyze( } } - if ($method_storage->assertions) { + $assertionsResolver = new AssertionsFromInheritanceResolver($codebase); + $assertions = $assertionsResolver->resolve( + $method_storage, + $class_storage, + ); + + if ($assertions) { CallAnalyzer::applyAssertionsToContext( $stmt_name, null, - $method_storage->assertions, + $assertions, $stmt->getArgs(), $template_result, $context, diff --git a/src/Psalm/Internal/Codebase/AssertionsFromInheritanceResolver.php b/src/Psalm/Internal/Codebase/AssertionsFromInheritanceResolver.php new file mode 100644 index 00000000000..489c88f3644 --- /dev/null +++ b/src/Psalm/Internal/Codebase/AssertionsFromInheritanceResolver.php @@ -0,0 +1,140 @@ +codebase = $codebase; + } + + /** + * @return array + */ + public function resolve( + MethodStorage $method_storage, + ClassLikeStorage $called_class + ): array { + $method_name_lc = strtolower($method_storage->cased_name ?? ''); + + $assertions = $method_storage->assertions; + $inherited_classes_and_interfaces = array_values(array_filter(array_merge( + $called_class->parent_classes, + $called_class->class_implements, + ), fn(string $classOrInterface) => $this->codebase->classOrInterfaceOrEnumExists($classOrInterface))); + + foreach ($inherited_classes_and_interfaces as $potential_assertion_providing_class) { + $potential_assertion_providing_classlike_storage = $this->codebase->classlike_storage_provider->get( + $potential_assertion_providing_class, + ); + if (!isset($potential_assertion_providing_classlike_storage->methods[$method_name_lc])) { + continue; + } + + $potential_assertion_providing_method_storage = $potential_assertion_providing_classlike_storage + ->methods[$method_name_lc]; + + /** + * Since the inheritance does not provide its own assertions, we have to detect those + * from inherited classes + */ + $assertions += array_map( + fn(Possibilities $possibilities) => $this->modifyAssertionsForInheritance( + $possibilities, + $this->codebase, + $called_class, + $inherited_classes_and_interfaces, + ), + $potential_assertion_providing_method_storage->assertions, + ); + } + + return $assertions; + } + + /** + * In case the called class is either implementing or extending a class/interface which does also has the + * template we are searching for, we assume that the called method has the same assertions. + * + * @param list $potential_assertion_providing_classes + */ + private function modifyAssertionsForInheritance( + Possibilities $possibilities, + Codebase $codebase, + ClassLikeStorage $called_class, + array $potential_assertion_providing_classes + ): Possibilities { + $replacement = new Possibilities($possibilities->var_id, []); + $extended_params = $called_class->template_extended_params; + foreach ($possibilities->rule as $assertion) { + if (!$assertion instanceof IsType + || !$assertion->type instanceof TTemplateParam) { + $replacement->rule[] = $assertion; + continue; + } + + /** Called class does not extend the template parameter */ + $extended_templates = $called_class->template_extended_params; + if (!isset($extended_templates[$assertion->type->defining_class][$assertion->type->param_name])) { + $replacement->rule[] = $assertion; + continue; + } + + foreach ($potential_assertion_providing_classes as $potential_assertion_providing_class) { + if (!isset($extended_params[$potential_assertion_providing_class][$assertion->type->param_name])) { + continue; + } + + if (!$codebase->classlike_storage_provider->has($potential_assertion_providing_class)) { + continue; + } + + $potential_assertion_providing_classlike_storage = $codebase->classlike_storage_provider->get( + $potential_assertion_providing_class, + ); + if (!isset( + $potential_assertion_providing_classlike_storage->template_types[$assertion->type->param_name], + )) { + continue; + } + + $replacement->rule[] = new IsType(new TTemplateParam( + $assertion->type->param_name, + reset( + $potential_assertion_providing_classlike_storage->template_types[$assertion->type->param_name], + ), + $potential_assertion_providing_class, + )); + + continue 2; + } + + $replacement->rule[] = $assertion; + } + + return $replacement; + } +} diff --git a/tests/AssertAnnotationTest.php b/tests/AssertAnnotationTest.php index 16cdf6d5244..7bba9297f2c 100644 --- a/tests/AssertAnnotationTest.php +++ b/tests/AssertAnnotationTest.php @@ -2883,6 +2883,90 @@ public static function doAssert($value): void '$iterable===' => 'non-empty-list', ], ], + 'assertFromInheritedDocBlock' => [ + 'code' => ' + */ + abstract class AbstractPluginManager implements PluginManagerInterface + { + /** @param InstanceType $value */ + public function __construct(private readonly mixed $value) + {} + + /** {@inheritDoc} */ + public function get(): mixed + { + return $this->value; + } + } + + /** + * @template InstanceType of object + * @template-extends AbstractPluginManager + */ + abstract class AbstractSingleInstancePluginManager extends AbstractPluginManager + { + /** + * An object type that the created instance must be instanced of + * + * @var class-string + */ + protected string $instanceOf; + + /** {@inheritDoc} */ + public function get(): object + { + return parent::get(); + } + + + /** {@inheritDoc} */ + public function validate(mixed $value): void + { + } + } + } + + namespace Namespace2 { + use Namespace1\AbstractSingleInstancePluginManager; + use stdClass; + + /** @template-extends AbstractSingleInstancePluginManager */ + final class Qoo extends AbstractSingleInstancePluginManager + { + /** @var class-string */ + protected string $instanceOf = stdClass::class; + } + } + + namespace { + $baz = new \Namespace2\Qoo(new stdClass); + + /** @var mixed $object */ + $object = null; + $baz->validate($object); + } + ', + 'assertions' => [ + '$object===' => 'stdClass', + ], + 'ignored_issues' => [], + 'php_version' => '8.1', + ], ]; }