diff --git a/src/Psalm/Internal/Analyzer/ClassAnalyzer.php b/src/Psalm/Internal/Analyzer/ClassAnalyzer.php index b09670e0b..41704acbe 100644 --- a/src/Psalm/Internal/Analyzer/ClassAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/ClassAnalyzer.php @@ -51,6 +51,7 @@ use function str_replace; use function count; use function array_search; use function array_keys; +use function array_merge; /** * @internal @@ -486,8 +487,11 @@ class ClassAnalyzer extends ClassLikeAnalyzer } } - if ($storage->mixin && $storage->mixin_declaring_fqcln === $storage->name) { - $union = new Type\Union([$storage->mixin]); + if (($storage->templatedMixins || $storage->namedMixins) + && $storage->mixin_declaring_fqcln === $storage->name) { + /** @var non-empty-array $mixins */ + $mixins = array_merge($storage->templatedMixins, $storage->namedMixins); + $union = new Type\Union($mixins); $union->check( $this, new CodeLocation( diff --git a/src/Psalm/Internal/Analyzer/CommentAnalyzer.php b/src/Psalm/Internal/Analyzer/CommentAnalyzer.php index e9572f413..20e15eef5 100644 --- a/src/Psalm/Internal/Analyzer/CommentAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/CommentAnalyzer.php @@ -924,14 +924,22 @@ class CommentAnalyzer } if (isset($parsed_docblock->tags['mixin'])) { - $mixin = trim(reset($parsed_docblock->tags['mixin'])); - $doc_line_parts = self::splitDocLine($mixin); - $mixin = $doc_line_parts[0]; + foreach ($parsed_docblock->tags['mixin'] as $rawMixin) { + $mixin = trim($rawMixin); + $doc_line_parts = self::splitDocLine($mixin); + $mixin = $doc_line_parts[0]; - if ($mixin) { - $info->mixin = $mixin; - } else { - throw new DocblockParseException('@mixin annotation used without specifying class'); + if ($mixin) { + $info->mixins[] = $mixin; + } else { + throw new DocblockParseException('@mixin annotation used without specifying class'); + } + } + + // backwards compatibility + if ($info->mixins) { + /** @psalm-suppress DeprecatedProperty */ + $info->mixin = reset($info->mixins); } } diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/Method/AtomicMethodCallAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/Method/AtomicMethodCallAnalyzer.php index 97a818728..77ea53d98 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/Method/AtomicMethodCallAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/Method/AtomicMethodCallAnalyzer.php @@ -258,120 +258,128 @@ class AtomicMethodCallAnalyzer extends CallAnalyzer ); if (!$naive_method_exists - && $class_storage->mixin instanceof Type\Atomic\TTemplateParam + && $class_storage->templatedMixins && $lhs_type_part instanceof Type\Atomic\TGenericObject && $class_storage->template_types ) { - $param_position = \array_search( - $class_storage->mixin->param_name, - \array_keys($class_storage->template_types) - ); + foreach ($class_storage->templatedMixins as $mixin) { + $param_position = \array_search( + $mixin->param_name, + \array_keys($class_storage->template_types) + ); - if ($param_position !== false - && isset($lhs_type_part->type_params[$param_position]) - ) { - if ($lhs_type_part->type_params[$param_position]->isSingle()) { - $lhs_type_part_new = array_values( - $lhs_type_part->type_params[$param_position]->getAtomicTypes() - )[0]; + if ($param_position !== false + && isset($lhs_type_part->type_params[$param_position]) + ) { + /** @var Type\Union $current_type_param */ + $current_type_param = $lhs_type_part->type_params[$param_position]; + if ($current_type_param->isSingle()) { + $lhs_type_part_new = array_values( + $current_type_param->getAtomicTypes() + )[0]; - if ($lhs_type_part_new instanceof Type\Atomic\TNamedObject) { - $new_method_id = new MethodIdentifier( - $lhs_type_part_new->value, - $method_name_lc - ); + if ($lhs_type_part_new instanceof Type\Atomic\TNamedObject) { + $new_method_id = new MethodIdentifier( + $lhs_type_part_new->value, + $method_name_lc + ); - $mixin_class_storage = $codebase->classlike_storage_provider->get($lhs_type_part_new->value); + $mixin_class_storage = $codebase->classlike_storage_provider->get( + $lhs_type_part_new->value + ); - if ($codebase->methods->methodExists( - $new_method_id, - $context->calling_method_id, - $codebase->collect_locations - ? new CodeLocation($source, $stmt->name) - : null, - !$context->collect_initializations + if ($codebase->methods->methodExists( + $new_method_id, + $context->calling_method_id, + $codebase->collect_locations + ? new CodeLocation($source, $stmt->name) + : null, + !$context->collect_initializations && !$context->collect_mutations - ? $statements_analyzer - : null, - $statements_analyzer->getFilePath() - )) { - $lhs_type_part = clone $lhs_type_part_new; - $class_storage = $mixin_class_storage; + ? $statements_analyzer + : null, + $statements_analyzer->getFilePath() + )) { + $lhs_type_part = clone $lhs_type_part_new; + $class_storage = $mixin_class_storage; - $naive_method_exists = true; - $method_id = $new_method_id; - } elseif (isset($mixin_class_storage->pseudo_methods[$method_name_lc])) { - $lhs_type_part = clone $lhs_type_part_new; - $class_storage = $mixin_class_storage; - $method_id = $new_method_id; + $naive_method_exists = true; + $method_id = $new_method_id; + } elseif (isset($mixin_class_storage->pseudo_methods[$method_name_lc])) { + $lhs_type_part = clone $lhs_type_part_new; + $class_storage = $mixin_class_storage; + $method_id = $new_method_id; + } } } } } } elseif (!$naive_method_exists && $class_storage->mixin_declaring_fqcln - && $class_storage->mixin instanceof Type\Atomic\TNamedObject + && $class_storage->namedMixins ) { - $new_method_id = new MethodIdentifier( - $class_storage->mixin->value, - $method_name_lc - ); + foreach ($class_storage->namedMixins as $mixin) { + $new_method_id = new MethodIdentifier( + $mixin->value, + $method_name_lc + ); - if ($codebase->methods->methodExists( - $new_method_id, - $context->calling_method_id, - $codebase->collect_locations - ? new CodeLocation($source, $stmt->name) - : null, - !$context->collect_initializations + if ($codebase->methods->methodExists( + $new_method_id, + $context->calling_method_id, + $codebase->collect_locations + ? new CodeLocation($source, $stmt->name) + : null, + !$context->collect_initializations && !$context->collect_mutations - ? $statements_analyzer - : null, - $statements_analyzer->getFilePath() - )) { - $mixin_declaring_class_storage = $codebase->classlike_storage_provider->get( - $class_storage->mixin_declaring_fqcln - ); + ? $statements_analyzer + : null, + $statements_analyzer->getFilePath() + )) { + $mixin_declaring_class_storage = $codebase->classlike_storage_provider->get( + $class_storage->mixin_declaring_fqcln + ); - $mixin_class_template_params = ClassTemplateParamCollector::collect( - $codebase, - $mixin_declaring_class_storage, - $codebase->classlike_storage_provider->get($fq_class_name), - null, - $lhs_type_part, - $lhs_var_id - ); + $mixin_class_template_params = ClassTemplateParamCollector::collect( + $codebase, + $mixin_declaring_class_storage, + $codebase->classlike_storage_provider->get($fq_class_name), + null, + $lhs_type_part, + $lhs_var_id + ); - $lhs_type_part = clone $class_storage->mixin; + $lhs_type_part = clone $mixin; - $lhs_type_part->replaceTemplateTypesWithArgTypes( - new \Psalm\Internal\Type\TemplateResult([], $mixin_class_template_params ?: []), - $codebase - ); + $lhs_type_part->replaceTemplateTypesWithArgTypes( + new \Psalm\Internal\Type\TemplateResult([], $mixin_class_template_params ?: []), + $codebase + ); - $lhs_type_expanded = \Psalm\Internal\Type\TypeExpander::expandUnion( - $codebase, - new Type\Union([$lhs_type_part]), - $mixin_declaring_class_storage->name, - $fq_class_name, - $class_storage->parent_class, - true, - false, - $class_storage->final - ); + $lhs_type_expanded = \Psalm\Internal\Type\TypeExpander::expandUnion( + $codebase, + new Type\Union([$lhs_type_part]), + $mixin_declaring_class_storage->name, + $fq_class_name, + $class_storage->parent_class, + true, + false, + $class_storage->final + ); - $new_lhs_type_part = array_values($lhs_type_expanded->getAtomicTypes())[0]; + $new_lhs_type_part = array_values($lhs_type_expanded->getAtomicTypes())[0]; - if ($new_lhs_type_part instanceof Type\Atomic\TNamedObject) { - $lhs_type_part = $new_lhs_type_part; + if ($new_lhs_type_part instanceof Type\Atomic\TNamedObject) { + $lhs_type_part = $new_lhs_type_part; + } + + $mixin_class_storage = $codebase->classlike_storage_provider->get($mixin->value); + + $fq_class_name = $mixin_class_storage->name; + $class_storage = $mixin_class_storage; + $naive_method_exists = true; + $method_id = $new_method_id; } - - $mixin_class_storage = $codebase->classlike_storage_provider->get($class_storage->mixin->value); - - $fq_class_name = $mixin_class_storage->name; - $class_storage = $mixin_class_storage; - $naive_method_exists = true; - $method_id = $new_method_id; } } diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/StaticCallAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/StaticCallAnalyzer.php index bbe88d8c5..d5593e880 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/StaticCallAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/StaticCallAnalyzer.php @@ -37,6 +37,7 @@ use function strlen; use function substr; use Psalm\Internal\Taint\Source; use Psalm\Internal\Taint\TaintNode; +use function array_filter; /** * @internal @@ -449,83 +450,112 @@ class StaticCallAnalyzer extends \Psalm\Internal\Analyzer\Statements\Expression\ if (!$naive_method_exists && $class_storage->mixin_declaring_fqcln - && $class_storage->mixin instanceof Type\Atomic\TNamedObject + && $class_storage->namedMixins ) { - $new_method_id = new MethodIdentifier( - $class_storage->mixin->value, - $method_name_lc - ); + foreach ($class_storage->namedMixins as $mixin) { + $new_method_id = new MethodIdentifier( + $mixin->value, + $method_name_lc + ); - if ($codebase->methods->methodExists( - $new_method_id, - $context->calling_method_id, - $codebase->collect_locations - ? new CodeLocation($source, $stmt->name) - : null, - !$context->collect_initializations + if ($codebase->methods->methodExists( + $new_method_id, + $context->calling_method_id, + $codebase->collect_locations + ? new CodeLocation($source, $stmt->name) + : null, + !$context->collect_initializations && !$context->collect_mutations - ? $statements_analyzer - : null, - $statements_analyzer->getFilePath() - )) { - $mixin_candidate_type = new Type\Union([clone $class_storage->mixin]); + ? $statements_analyzer + : null, + $statements_analyzer->getFilePath() + )) { + $mixin_candidates = []; + foreach ($class_storage->templatedMixins as $mixin_candidate) { + $mixin_candidates[] = clone $mixin_candidate; + } - if ($class_storage->mixin instanceof Type\Atomic\TGenericObject) { - $mixin_declaring_class_storage = $codebase->classlike_storage_provider->get( - $class_storage->mixin_declaring_fqcln - ); + foreach ($class_storage->namedMixins as $mixin_candidate) { + $mixin_candidates[] = clone $mixin_candidate; + } - $mixin_candidate_type = InstancePropertyFetchAnalyzer::localizePropertyType( + $mixin_candidates_no_generic = array_filter($mixin_candidates, function ($check) { + return !($check instanceof Type\Atomic\TGenericObject); + }); + + // $mixin_candidates_no_generic will only be empty when there are TGenericObject entries. + // In that case, Union will be initialized with an empty array but + // replaced with non-empty types in the following loop. + /** @psalm-suppress ArgumentTypeCoercion */ + $mixin_candidate_type = new Type\Union($mixin_candidates_no_generic); + + foreach ($mixin_candidates as $tGenericMixin) { + if (!($tGenericMixin instanceof Type\Atomic\TGenericObject)) { + continue; + } + + $mixin_declaring_class_storage = $codebase->classlike_storage_provider->get( + $class_storage->mixin_declaring_fqcln + ); + + $new_mixin_candidate_type = InstancePropertyFetchAnalyzer::localizePropertyType( + $codebase, + new Type\Union([$lhs_type_part]), + $tGenericMixin, + $class_storage, + $mixin_declaring_class_storage + ); + + foreach ($mixin_candidate_type->getAtomicTypes() as $type) { + $new_mixin_candidate_type->addType($type); + } + + $mixin_candidate_type = $new_mixin_candidate_type; + } + + $new_lhs_type = \Psalm\Internal\Type\TypeExpander::expandUnion( $codebase, - new Type\Union([$lhs_type_part]), - $class_storage->mixin, - $class_storage, - $mixin_declaring_class_storage + $mixin_candidate_type, + $fq_class_name, + $fq_class_name, + $class_storage->parent_class, + true, + false, + $class_storage->final ); + + $old_data_provider = $statements_analyzer->node_data; + + $statements_analyzer->node_data = clone $statements_analyzer->node_data; + + $context->vars_in_scope['$tmp_mixin_var'] = $new_lhs_type; + + $fake_method_call_expr = new PhpParser\Node\Expr\MethodCall( + new PhpParser\Node\Expr\Variable( + 'tmp_mixin_var', + $stmt->class->getAttributes() + ), + $stmt->name, + $stmt->args, + $stmt->getAttributes() + ); + + if (MethodCallAnalyzer::analyze( + $statements_analyzer, + $fake_method_call_expr, + $context + ) === false) { + return false; + } + + $fake_method_call_type = $statements_analyzer->node_data->getType($fake_method_call_expr); + + $statements_analyzer->node_data = $old_data_provider; + + $statements_analyzer->node_data->setType($stmt, $fake_method_call_type ?: Type::getMixed()); + + return true; } - - $new_lhs_type = \Psalm\Internal\Type\TypeExpander::expandUnion( - $codebase, - $mixin_candidate_type, - $fq_class_name, - $fq_class_name, - $class_storage->parent_class, - true, - false, - $class_storage->final - ); - - $old_data_provider = $statements_analyzer->node_data; - - $statements_analyzer->node_data = clone $statements_analyzer->node_data; - - $context->vars_in_scope['$tmp_mixin_var'] = $new_lhs_type; - - $fake_method_call_expr = new PhpParser\Node\Expr\MethodCall( - new PhpParser\Node\Expr\Variable( - 'tmp_mixin_var', - $stmt->class->getAttributes() - ), - $stmt->name, - $stmt->args, - $stmt->getAttributes() - ); - - if (MethodCallAnalyzer::analyze( - $statements_analyzer, - $fake_method_call_expr, - $context - ) === false) { - return false; - } - - $fake_method_call_type = $statements_analyzer->node_data->getType($fake_method_call_expr); - - $statements_analyzer->node_data = $old_data_provider; - - $statements_analyzer->node_data->setType($stmt, $fake_method_call_type ?: Type::getMixed()); - - return true; } } diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Fetch/InstancePropertyFetchAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Fetch/InstancePropertyFetchAnalyzer.php index 34199f2fa..ba5021908 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Fetch/InstancePropertyFetchAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Fetch/InstancePropertyFetchAnalyzer.php @@ -501,37 +501,39 @@ class InstancePropertyFetchAnalyzer $get_method_id = new \Psalm\Internal\MethodIdentifier($fq_class_name, '__get'); if (!$naive_property_exists - && $class_storage->mixin instanceof Type\Atomic\TNamedObject + && $class_storage->namedMixins ) { - $new_property_id = $class_storage->mixin->value . '::$' . $prop_name; + foreach ($class_storage->namedMixins as $mixin) { + $new_property_id = $mixin->value . '::$' . $prop_name; - try { - $new_class_storage = $codebase->classlike_storage_provider->get($class_storage->mixin->value); - } catch (\InvalidArgumentException $e) { - $new_class_storage = null; - } - - if ($new_class_storage - && ($codebase->properties->propertyExists( - $new_property_id, - true, - $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 = $class_storage->mixin->value; - $lhs_type_part = clone $class_storage->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, + true, + $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 = clone $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; + } } } diff --git a/src/Psalm/Internal/Codebase/Populator.php b/src/Psalm/Internal/Codebase/Populator.php index 33c467426..a2a9bb28b 100644 --- a/src/Psalm/Internal/Codebase/Populator.php +++ b/src/Psalm/Internal/Codebase/Populator.php @@ -594,9 +594,17 @@ class Populator $storage->protected_class_constants ); - if ($parent_storage->mixin && !$storage->mixin) { + if (($parent_storage->namedMixins || $parent_storage->templatedMixins) + && (!$storage->namedMixins || !$storage->templatedMixins)) { $storage->mixin_declaring_fqcln = $parent_storage->mixin_declaring_fqcln; - $storage->mixin = $parent_storage->mixin; + + if (!$storage->namedMixins) { + $storage->namedMixins = $parent_storage->namedMixins; + } + + if (!$storage->templatedMixins) { + $storage->templatedMixins = $parent_storage->templatedMixins; + } } foreach ($parent_storage->public_class_constant_nodes as $name => $_) { diff --git a/src/Psalm/Internal/PhpVisitor/ReflectorVisitor.php b/src/Psalm/Internal/PhpVisitor/ReflectorVisitor.php index 7d9226556..a774e5e44 100644 --- a/src/Psalm/Internal/PhpVisitor/ReflectorVisitor.php +++ b/src/Psalm/Internal/PhpVisitor/ReflectorVisitor.php @@ -1327,10 +1327,10 @@ class ReflectorVisitor extends PhpParser\NodeVisitorAbstract implements PhpParse $storage->final = $storage->final || $docblock_info->final; - if ($docblock_info->mixin) { + foreach ($docblock_info->mixins as $key => $mixin) { $mixin_type = TypeParser::parseTokens( TypeTokenizer::getFullyQualifiedTokens( - $docblock_info->mixin, + $mixin, $this->aliases, $this->class_template_types, $this->type_aliases, @@ -1352,11 +1352,23 @@ class ReflectorVisitor extends PhpParser\NodeVisitorAbstract implements PhpParse if ($mixin_type->isSingle()) { $mixin_type = \array_values($mixin_type->getAtomicTypes())[0]; + if ($mixin_type instanceof Type\Atomic\TNamedObject) { + $storage->namedMixins[] = $mixin_type; + } + + if ($mixin_type instanceof Type\Atomic\TTemplateParam) { + $storage->templatedMixins[] = $mixin_type; + } + } + + if ($key === 0) { + $storage->mixin_declaring_fqcln = $storage->name; + + // backwards compatibility if ($mixin_type instanceof Type\Atomic\TNamedObject - || $mixin_type instanceof Type\Atomic\TTemplateParam - ) { + || $mixin_type instanceof Type\Atomic\TTemplateParam) { + /** @psalm-suppress DeprecatedProperty **/ $storage->mixin = $mixin_type; - $storage->mixin_declaring_fqcln = $storage->name; } } } diff --git a/src/Psalm/Internal/Scanner/ClassLikeDocblockComment.php b/src/Psalm/Internal/Scanner/ClassLikeDocblockComment.php index 1a58fba2c..ce6e23de5 100644 --- a/src/Psalm/Internal/Scanner/ClassLikeDocblockComment.php +++ b/src/Psalm/Internal/Scanner/ClassLikeDocblockComment.php @@ -36,9 +36,15 @@ class ClassLikeDocblockComment /** * @var null|string + * @deprecated */ public $mixin = null; + /** + * @var string[] + */ + public $mixins = []; + /** * @var array */ diff --git a/src/Psalm/Storage/ClassLikeStorage.php b/src/Psalm/Storage/ClassLikeStorage.php index 4525ceaf1..eb925fd9b 100644 --- a/src/Psalm/Storage/ClassLikeStorage.php +++ b/src/Psalm/Storage/ClassLikeStorage.php @@ -94,9 +94,20 @@ class ClassLikeStorage /** * @var null|Type\Atomic\TTemplateParam|Type\Atomic\TNamedObject + * @deprecated */ public $mixin = null; + /** + * @var Type\Atomic\TTemplateParam[] + */ + public $templatedMixins = []; + + /** + * @var Type\Atomic\TNamedObject[] + */ + public $namedMixins = []; + /** * @var ?string */ diff --git a/tests/MixinAnnotationTest.php b/tests/MixinAnnotationTest.php index d0c0977ad..08dba19ae 100644 --- a/tests/MixinAnnotationTest.php +++ b/tests/MixinAnnotationTest.php @@ -400,6 +400,137 @@ class MixinAnnotationTest extends TestCase return $b->active(); }' ], + 'multipleMixins' => [ + 'a(); + $b = $test->b();', + 'assertions' => [ + '$a' => 'string', + '$b' => 'int', + ], + ], + 'inheritMultipleTemplatedMixinsWithStatic' => [ + 'var = $var; + } + + /** + * @psalm-return T + */ + public function type() { + return $this->var; + } + } + + /** + * @template T + */ + class OtherMixin { + /** + * @psalm-var T + */ + private $var; + + /** + * @psalm-param T $var + */ + public function __construct ($var) { + $this->var = $var; + } + + /** + * @psalm-return T + */ + public function other() { + return $this->var; + } + } + + /** + * @template T as object + * @template T2 as string + * @mixin Mixin + * @mixin OtherMixin + */ + abstract class Foo { + /** @var Mixin */ + public object $obj; + + /** @var OtherMixin */ + public object $otherObj; + + public function __call(string $name, array $args) { + if ($name === "test") { + return $this->obj->$name(...$args); + } + + return $this->otherObj->$name(...$args); + } + + public function __callStatic(string $name, array $args) { + if ($name === "test") { + return (new static)->obj->$name(...$args); + } + + return (new static)->otherObj->$name(...$args); + } + } + + /** + * @extends Foo + */ + abstract class FooChild extends Foo{} + + /** + * @psalm-suppress MissingConstructor + */ + final class FooGrandChild extends FooChild {} + + function test() : FooGrandChild { + return FooGrandChild::type(); + } + + function testStatic() : FooGrandChild { + return (new FooGrandChild)->type(); + } + + function other() : string { + return FooGrandChild::other(); + } + + function otherStatic() : string { + return (new FooGrandChild)->other(); + }' + ], ]; }