diff --git a/README.md b/README.md index efdc031d..32cc0fd8 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,13 @@ parameters: # Flags plugin managers that do not set a cache backend. pluginManagerSetsCacheBackendRule: false + # Flags plugin managers that pass a non-attribute (annotation) class as + # the plugin definition attribute name to DefaultPluginManager::__construct(). + # Annotation-only discovery is deprecated in drupal:11.2.0 and removed in + # drupal:12.0.0. Also reports static property and constructor parameter + # defaults. Does nothing when AttributeInterface is unavailable (Drupal < 10.2). + pluginManagerAttributeDiscoveryRule: false + # Validates module_load_include() calls and loads the include for # the rest of the analysis. moduleLoadInclude: false diff --git a/extension.neon b/extension.neon index e83dfaed..ccb17a20 100644 --- a/extension.neon +++ b/extension.neon @@ -62,6 +62,7 @@ parameters: accessResultConditionRule: true classExtendsInternalClassRule: true pluginManagerInspectionRule: false + pluginManagerAttributeDiscoveryRule: false cacheableDependencyRule: true hookFormAlterRule: true loggerFromFactoryPropertyAssignmentRule: true @@ -311,6 +312,7 @@ parametersSchema: accessResultConditionRule: boolean() classExtendsInternalClassRule: boolean() pluginManagerInspectionRule: boolean() + pluginManagerAttributeDiscoveryRule: boolean() cacheableDependencyRule: boolean() hookFormAlterRule: boolean() loggerFromFactoryPropertyAssignmentRule: boolean() diff --git a/rules.neon b/rules.neon index de3d6b8d..393bc181 100644 --- a/rules.neon +++ b/rules.neon @@ -33,6 +33,10 @@ conditionalTags: phpstan.rules.rule: %drupal.rules.classExtendsInternalClassRule% mglaman\PHPStanDrupal\Rules\Classes\PluginManagerInspectionRule: phpstan.rules.rule: %drupal.rules.pluginManagerInspectionRule% + mglaman\PHPStanDrupal\Rules\Drupal\PluginManager\PluginManagerAttributeDiscoveryRule: + phpstan.rules.rule: %drupal.rules.pluginManagerAttributeDiscoveryRule% + mglaman\PHPStanDrupal\Rules\Drupal\PluginManager\PluginManagerAttributeDiscoveryDefaultRule: + phpstan.rules.rule: %drupal.rules.pluginManagerAttributeDiscoveryRule% mglaman\PHPStanDrupal\Rules\Drupal\CacheableDependencyRule: phpstan.rules.rule: %drupal.rules.cacheableDependencyRule% mglaman\PHPStanDrupal\Rules\Drupal\LoggerFromFactoryPropertyAssignmentRule: @@ -77,6 +81,10 @@ services: class: mglaman\PHPStanDrupal\Rules\Classes\ClassExtendsInternalClassRule - class: mglaman\PHPStanDrupal\Rules\Classes\PluginManagerInspectionRule + - + class: mglaman\PHPStanDrupal\Rules\Drupal\PluginManager\PluginManagerAttributeDiscoveryRule + - + class: mglaman\PHPStanDrupal\Rules\Drupal\PluginManager\PluginManagerAttributeDiscoveryDefaultRule - class: mglaman\PHPStanDrupal\Rules\Drupal\CacheableDependencyRule - diff --git a/src/Rules/Drupal/PluginManager/AbstractPluginManagerAttributeDiscoveryRule.php b/src/Rules/Drupal/PluginManager/AbstractPluginManagerAttributeDiscoveryRule.php new file mode 100644 index 00000000..cbb02bab --- /dev/null +++ b/src/Rules/Drupal/PluginManager/AbstractPluginManagerAttributeDiscoveryRule.php @@ -0,0 +1,99 @@ + + */ +abstract class AbstractPluginManagerAttributeDiscoveryRule implements Rule +{ + protected const DEFAULT_PLUGIN_MANAGER = 'Drupal\Core\Plugin\DefaultPluginManager'; + + protected const ATTRIBUTE_INTERFACE = 'Drupal\Component\Plugin\Attribute\AttributeInterface'; + + private const ATTRIBUTE_ARGUMENT_NAME = 'plugin_definition_attribute_name'; + + private const ATTRIBUTE_ARGUMENT_POSITION = 4; + + protected ReflectionProvider $reflectionProvider; + + public function __construct(ReflectionProvider $reflectionProvider) + { + $this->reflectionProvider = $reflectionProvider; + } + + /** + * Whether the class directly extends DefaultPluginManager's constructor. + * + * Only the constructor declared by DefaultPluginManager itself has the + * attribute argument. An intermediate parent may have its own signature. + */ + protected function callsDefaultPluginManagerConstructor(ClassReflection $classReflection): bool + { + if ($classReflection->isAnonymous() || $classReflection->isTrait() || !$classReflection->is(self::DEFAULT_PLUGIN_MANAGER)) { + return false; + } + $parentClass = $classReflection->getParentClass(); + if ($parentClass === null || !$parentClass->hasConstructor()) { + return false; + } + return $parentClass->getConstructor()->getDeclaringClass()->getName() === self::DEFAULT_PLUGIN_MANAGER; + } + + protected function isParentConstructorCall(StaticCall $node): bool + { + return $node->class instanceof Node\Name + && $node->class->toLowerString() === 'parent' + && $node->name instanceof Node\Identifier + && $node->name->toLowerString() === '__construct'; + } + + /** + * Returns the class when it resolves and is not an attribute class. + */ + protected function resolveNonAttributeClass(string $className): ?ClassReflection + { + // Drupal < 10.2 has no attribute interface. Without it nothing can be + // told apart from an attribute class, so never report. + if (!$this->reflectionProvider->hasClass(self::ATTRIBUTE_INTERFACE)) { + return null; + } + $className = ltrim($className, '\\'); + // Only report when certain: the class must be resolvable. + if ($className === '' || !$this->reflectionProvider->hasClass($className)) { + return null; + } + $class = $this->reflectionProvider->getClass($className); + return $class->is(self::ATTRIBUTE_INTERFACE) ? null : $class; + } + + protected function findAttributeArgument(StaticCall $node): ?Node\Arg + { + $position = 0; + foreach ($node->getArgs() as $arg) { + if ($arg->unpack) { + return null; + } + if ($arg->name !== null) { + if ($arg->name->toString() === self::ATTRIBUTE_ARGUMENT_NAME) { + return $arg; + } + continue; + } + if ($position === self::ATTRIBUTE_ARGUMENT_POSITION) { + return $arg; + } + $position++; + } + return null; + } +} diff --git a/src/Rules/Drupal/PluginManager/PluginManagerAttributeDiscoveryDefaultRule.php b/src/Rules/Drupal/PluginManager/PluginManagerAttributeDiscoveryDefaultRule.php new file mode 100644 index 00000000..1c9533a8 --- /dev/null +++ b/src/Rules/Drupal/PluginManager/PluginManagerAttributeDiscoveryDefaultRule.php @@ -0,0 +1,293 @@ + + */ +final class PluginManagerAttributeDiscoveryDefaultRule extends AbstractPluginManagerAttributeDiscoveryRule +{ + private const TIP = 'Provide an Attribute class and keep the Annotation class for BC. See https://www.drupal.org/node/3522776 and https://www.drupal.org/node/3395582'; + + public function getNodeType(): string + { + return InClassNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $classReflection = $node->getClassReflection(); + if (!$this->callsDefaultPluginManagerConstructor($classReflection)) { + return []; + } + + $classNode = $node->getOriginalNode(); + // Own methods only, so constructors of anonymous classes are never found. + $constructor = $classNode->getMethod('__construct'); + if ($constructor === null) { + return []; + } + + $errors = []; + /** @var list $calls */ + $calls = $this->findInScope( + (array) $constructor->stmts, + fn (Node $n): bool => $n instanceof Node\Expr\StaticCall && $this->isParentConstructorCall($n) + ); + foreach ($calls as $call) { + $argument = $this->findAttributeArgument($call); + if ($argument === null) { + continue; + } + $value = $argument->value; + $error = null; + if ($value instanceof Node\Expr\StaticPropertyFetch) { + $error = $this->checkStaticProperty($value, $classReflection, $classNode); + } elseif ($value instanceof Node\Expr\Variable) { + $error = $this->checkParameter($value, $classReflection, $constructor); + } + if ($error === null) { + continue; + } + $errors[] = RuleErrorBuilder::message($error) + ->identifier('pluginManager.attributeDiscoveryMissingDefault') + ->line($argument->getStartLine()) + ->tip(self::TIP) + ->build(); + } + + return $errors; + } + + private function checkStaticProperty(Node\Expr\StaticPropertyFetch $fetch, ClassReflection $classReflection, Node\Stmt\ClassLike $classNode): ?string + { + if (!$fetch->name instanceof Node\VarLikeIdentifier || !$fetch->class instanceof Node\Name) { + return null; + } + // Only the class being analysed: static:: (late static binding resolves + // to it or a subclass), self:: or the class's own name. + $className = $fetch->class->toLowerString(); + if (!in_array($className, ['static', 'self', strtolower($classReflection->getName())], true)) { + return null; + } + $propertyName = $fetch->name->toString(); + if (!$classReflection->hasNativeProperty($propertyName)) { + return null; + } + $property = $classReflection->getNativeProperty($propertyName); + if (!$property->isStatic()) { + return null; + } + + // The property must not be written to anywhere in the analysed class. + $writes = (new NodeFinder())->find( + $classNode->stmts, + static fn (Node $n): bool => self::isWriteTo($n, static fn (Node $target): bool => $target instanceof Node\Expr\StaticPropertyFetch + && $target->name instanceof Node\VarLikeIdentifier + && $target->name->toString() === $propertyName) + ); + if ($writes !== []) { + return null; + } + + try { + $default = $property->getNativeReflection()->getDefaultValue(); + } catch (Throwable $e) { + return null; + } + if (!is_string($default)) { + return null; + } + $attributeClass = $this->resolveNonAttributeClass($default); + if ($attributeClass === null) { + return null; + } + + return sprintf( + 'Plugin manager %s passes the value of static property $%s, which defaults to %s, as the plugin definition attribute name to DefaultPluginManager::__construct(), but that is not an attribute class implementing %s. Unless a subclass overrides the property with an attribute class, not supporting attribute discovery is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0.', + $classReflection->getName(), + $propertyName, + $attributeClass->getName(), + self::ATTRIBUTE_INTERFACE + ); + } + + private function checkParameter(Node\Expr\Variable $variable, ClassReflection $classReflection, Node\Stmt\ClassMethod $constructor): ?string + { + if (!is_string($variable->name)) { + return null; + } + $name = $variable->name; + + $default = null; + foreach ($constructor->params as $param) { + if ($param->var instanceof Node\Expr\Variable && $param->var->name === $name) { + if ($param->byRef || $param->variadic) { + return null; + } + $default = $param->default; + break; + } + } + if ($default === null) { + return null; + } + if ($default instanceof Node\Scalar\String_) { + $defaultClassName = $default->value; + } elseif ($default instanceof Node\Expr\ClassConstFetch + && $default->class instanceof Node\Name + && $default->name instanceof Node\Identifier + && $default->name->toLowerString() === 'class' + ) { + $resolved = $default->class->getAttribute('resolvedName'); + $defaultClassName = $resolved instanceof Node\Name ? $resolved->toString() : $default->class->toString(); + } else { + return null; + } + + // The parameter must not be reassigned in the constructor. + $writes = $this->findInScope( + (array) $constructor->stmts, + static fn (Node $n): bool => self::isWriteTo($n, static fn (Node $target): bool => $target instanceof Node\Expr\Variable && $target->name === $name) + ); + if ($writes !== []) { + return null; + } + + $attributeClass = $this->resolveNonAttributeClass($defaultClassName); + if ($attributeClass === null) { + return null; + } + + return sprintf( + 'Plugin manager %s forwards constructor parameter $%s, which defaults to %s, as the plugin definition attribute name to DefaultPluginManager::__construct(), but that is not an attribute class implementing %s. Unless callers pass an attribute class, not supporting attribute discovery is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0.', + $classReflection->getName(), + $name, + $attributeClass->getName(), + self::ATTRIBUTE_INTERFACE + ); + } + + /** + * Finds matching nodes without leaving the current variable scope. + * + * Anonymous classes, nested functions, closures and arrow functions have + * their own scope, so their bodies are skipped. A closure's `use` list is + * still inspected because `use (&$x)` can write to the outer variable. + * + * @param array $nodes + * @param callable(Node): bool $matches + * @return list + */ + private function findInScope(array $nodes, callable $matches): array + { + $visitor = new class ($matches) extends NodeVisitorAbstract { + + /** @var list */ + public array $found = []; + + /** @var callable(Node): bool */ + private $matches; + + /** @param callable(Node): bool $matches */ + public function __construct(callable $matches) + { + $this->matches = $matches; + } + + public function enterNode(Node $node): ?int + { + if (($this->matches)($node)) { + $this->found[] = $node; + } + if ($node instanceof Node\Expr\Closure) { + foreach ($node->uses as $use) { + if (($this->matches)($use)) { + $this->found[] = $use; + } + } + } + if ($node instanceof Node\Stmt\ClassLike + || $node instanceof Node\FunctionLike + ) { + return NodeVisitor::DONT_TRAVERSE_CHILDREN; + } + return null; + } + + }; + $traverser = new NodeTraverser($visitor); + $traverser->traverse($nodes); + + return $visitor->found; + } + + /** + * Whether the node writes to (or takes a reference of) a matching target. + * + * @param callable(Node): bool $matches + */ + private static function isWriteTo(Node $node, callable $matches): bool + { + $targets = []; + if ($node instanceof Node\Expr\Assign || $node instanceof Node\Expr\AssignRef || $node instanceof Node\Expr\AssignOp) { + $targets[] = $node->var; + if ($node instanceof Node\Expr\AssignRef) { + $targets[] = $node->expr; + } + } elseif ($node instanceof Node\Stmt\Foreach_) { + $targets[] = $node->valueVar; + if ($node->keyVar !== null) { + $targets[] = $node->keyVar; + } + } elseif ($node instanceof Node\Stmt\Unset_) { + $targets = $node->vars; + } elseif ($node instanceof Node\Stmt\Global_) { + $targets = $node->vars; + } elseif ($node instanceof Node\Stmt\StaticVar) { + $targets[] = $node->var; + } elseif ($node instanceof Node\ClosureUse && $node->byRef) { + $targets[] = $node->var; + } elseif ($node instanceof Node\Expr\PreInc || $node instanceof Node\Expr\PostInc || $node instanceof Node\Expr\PreDec || $node instanceof Node\Expr\PostDec) { + $targets[] = $node->var; + } + foreach ($targets as $target) { + $found = (new NodeFinder())->findFirst($target, $matches); + if ($found !== null) { + return true; + } + } + return false; + } +} diff --git a/src/Rules/Drupal/PluginManager/PluginManagerAttributeDiscoveryRule.php b/src/Rules/Drupal/PluginManager/PluginManagerAttributeDiscoveryRule.php new file mode 100644 index 00000000..d5e5485d --- /dev/null +++ b/src/Rules/Drupal/PluginManager/PluginManagerAttributeDiscoveryRule.php @@ -0,0 +1,73 @@ + + */ +final class PluginManagerAttributeDiscoveryRule extends AbstractPluginManagerAttributeDiscoveryRule +{ + public function getNodeType(): string + { + return StaticCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$this->isParentConstructorCall($node)) { + return []; + } + if (!$scope->isInClass() || !$this->callsDefaultPluginManagerConstructor($scope->getClassReflection())) { + return []; + } + $classReflection = $scope->getClassReflection(); + + $argument = $this->findAttributeArgument($node); + if ($argument === null) { + // Omitted: core falls back to annotations without a deprecation. + return []; + } + + $errors = []; + foreach ($scope->getType($argument->value)->getConstantStrings() as $constantString) { + $attributeClass = $this->resolveNonAttributeClass($constantString->getValue()); + if ($attributeClass === null) { + continue; + } + $errors[] = RuleErrorBuilder::message(sprintf( + 'Plugin manager %s passes %s as the plugin definition attribute name to DefaultPluginManager::__construct(), but it is not an attribute class implementing %s. Not supporting attribute discovery is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0.', + $classReflection->getName(), + $attributeClass->getName(), + self::ATTRIBUTE_INTERFACE + )) + ->identifier('pluginManager.attributeDiscoveryMissing') + ->line($argument->getStartLine()) + ->tip('Provide an Attribute class and keep the Annotation class for BC. See https://www.drupal.org/node/3522776 and https://www.drupal.org/node/3395582') + ->build(); + } + + return $errors; + } +} diff --git a/tests/src/Rules/PluginManagerAttributeDiscoveryDefaultRuleTest.php b/tests/src/Rules/PluginManagerAttributeDiscoveryDefaultRuleTest.php new file mode 100644 index 00000000..dfcdbaca --- /dev/null +++ b/tests/src/Rules/PluginManagerAttributeDiscoveryDefaultRuleTest.php @@ -0,0 +1,63 @@ +attributeInterfaceAvailable) { + return new PluginManagerAttributeDiscoveryDefaultRule($reflectionProvider); + } + + // Simulate Drupal < 10.2, where AttributeInterface does not exist. + $withoutInterface = $this->createMock(ReflectionProvider::class); + $withoutInterface->method('hasClass')->willReturnCallback( + static fn (string $name): bool => ltrim($name, '\\') !== 'Drupal\Component\Plugin\Attribute\AttributeInterface' + && $reflectionProvider->hasClass($name) + ); + $withoutInterface->method('getClass')->willReturnCallback( + static fn (string $name) => $reflectionProvider->getClass($name) + ); + return new PluginManagerAttributeDiscoveryDefaultRule($withoutInterface); + } + + /** + * Without AttributeInterface nothing can be told apart, so nothing is reported. + */ + public function testNoopWithoutAttributeInterface(): void + { + $this->attributeInterfaceAvailable = false; + $this->analyse([__DIR__ . '/data/plugin-manager-attribute-discovery-default.php'], []); + } + + public function testRule(): void + { + $tip = 'Provide an Attribute class and keep the Annotation class for BC. See https://www.drupal.org/node/3522776 and https://www.drupal.org/node/3395582'; + $property = 'Plugin manager PluginManagerAttributeDiscoveryDefault\%s passes the value of static property $annotation, which defaults to Drupal\Component\Annotation\Plugin, as the plugin definition attribute name to DefaultPluginManager::__construct(), but that is not an attribute class implementing Drupal\Component\Plugin\Attribute\AttributeInterface. Unless a subclass overrides the property with an attribute class, not supporting attribute discovery is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0.'; + $parameter = 'Plugin manager PluginManagerAttributeDiscoveryDefault\%s forwards constructor parameter $%s, which defaults to Drupal\Component\Annotation\Plugin, as the plugin definition attribute name to DefaultPluginManager::__construct(), but that is not an attribute class implementing Drupal\Component\Plugin\Attribute\AttributeInterface. Unless callers pass an attribute class, not supporting attribute discovery is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0.'; + $this->analyse( + [__DIR__ . '/data/plugin-manager-attribute-discovery-default.php'], + [ + [sprintf($property, 'StaticPropertyAnnotation'), 18, $tip], + [sprintf($property, 'SelfPropertyAnnotation'), 31, $tip], + [sprintf($property, 'RedeclaredToAnnotation'), 51, $tip], + [sprintf($parameter, 'ParameterDefaultAnnotation', 'plugin_definition_annotation_name'), 128, $tip], + [sprintf($parameter, 'ParameterDefaultClassConstNamed', 'annotation'), 139, $tip], + [sprintf($property, 'AnonymousClassBeforeConstructorReports'), 223, $tip], + [sprintf($parameter, 'NestedScopesInConstructorReports', 'annotation'), 296, $tip], + [sprintf($parameter, 'ParameterShadowedInNestedScopes', 'annotation'), 358, $tip], + ] + ); + } + +} diff --git a/tests/src/Rules/PluginManagerAttributeDiscoveryRuleTest.php b/tests/src/Rules/PluginManagerAttributeDiscoveryRuleTest.php new file mode 100644 index 00000000..00129e94 --- /dev/null +++ b/tests/src/Rules/PluginManagerAttributeDiscoveryRuleTest.php @@ -0,0 +1,57 @@ +attributeInterfaceAvailable) { + return new PluginManagerAttributeDiscoveryRule($reflectionProvider); + } + + // Simulate Drupal < 10.2, where AttributeInterface does not exist. + $withoutInterface = $this->createMock(ReflectionProvider::class); + $withoutInterface->method('hasClass')->willReturnCallback( + static fn (string $name): bool => ltrim($name, '\\') !== 'Drupal\Component\Plugin\Attribute\AttributeInterface' + && $reflectionProvider->hasClass($name) + ); + $withoutInterface->method('getClass')->willReturnCallback( + static fn (string $name) => $reflectionProvider->getClass($name) + ); + return new PluginManagerAttributeDiscoveryRule($withoutInterface); + } + + /** + * Without AttributeInterface nothing can be told apart, so nothing is reported. + */ + public function testNoopWithoutAttributeInterface(): void + { + $this->attributeInterfaceAvailable = false; + $this->analyse([__DIR__ . '/data/plugin-manager-attribute-discovery.php'], []); + } + + public function testRule(): void + { + $tip = 'Provide an Attribute class and keep the Annotation class for BC. See https://www.drupal.org/node/3522776 and https://www.drupal.org/node/3395582'; + $message = 'Plugin manager PluginManagerAttributeDiscovery\%s passes Drupal\Component\Annotation\Plugin as the plugin definition attribute name to DefaultPluginManager::__construct(), but it is not an attribute class implementing Drupal\Component\Plugin\Attribute\AttributeInterface. Not supporting attribute discovery is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0.'; + $this->analyse( + [__DIR__ . '/data/plugin-manager-attribute-discovery.php'], + [ + [sprintf($message, 'AnnotationOnly'), 15, $tip], + [sprintf($message, 'AnnotationOnlyAsString'), 25, $tip], + [sprintf($message, 'AnnotationOnlyNamedArgs'), 39, $tip], + ] + ); + } + +} diff --git a/tests/src/Rules/data/plugin-manager-attribute-discovery-default.php b/tests/src/Rules/data/plugin-manager-attribute-discovery-default.php new file mode 100644 index 00000000..5a3e7468 --- /dev/null +++ b/tests/src/Rules/data/plugin-manager-attribute-discovery-default.php @@ -0,0 +1,361 @@ +annotation); + } + +} + +// Positive: constructor parameter defaults to an annotation class. +class ParameterDefaultAnnotation extends DefaultPluginManager +{ + + public function __construct(\Traversable $namespaces, ModuleHandlerInterface $module_handler, $plugin_interface = null, $plugin_definition_annotation_name = 'Drupal\Component\Annotation\Plugin') + { + parent::__construct('Plugin/Foo', $namespaces, $module_handler, $plugin_interface, $plugin_definition_annotation_name); + } + +} + +// Positive: ::class default and named argument. +class ParameterDefaultClassConstNamed extends DefaultPluginManager +{ + + public function __construct(\Traversable $namespaces, ModuleHandlerInterface $module_handler, string $annotation = AnnotationPlugin::class) + { + parent::__construct('Plugin/Foo', $namespaces, $module_handler, plugin_definition_attribute_name: $annotation); + } + +} + +// Negative: parameter defaults to an attribute class. +class ParameterDefaultAttribute extends DefaultPluginManager +{ + + public function __construct(\Traversable $namespaces, ModuleHandlerInterface $module_handler, $attribute = AttributePlugin::class) + { + parent::__construct('Plugin/Foo', $namespaces, $module_handler, 'FooInterface', $attribute); + } + +} + +// Negative: parameter is reassigned. +class ParameterReassigned extends DefaultPluginManager +{ + + public function __construct(\Traversable $namespaces, ModuleHandlerInterface $module_handler, $annotation = 'Drupal\Component\Annotation\Plugin') + { + $annotation = AttributePlugin::class; + parent::__construct('Plugin/Foo', $namespaces, $module_handler, 'FooInterface', $annotation); + } + +} + +// Negative: parameter without a default. +class ParameterNoDefault extends DefaultPluginManager +{ + + public function __construct(\Traversable $namespaces, ModuleHandlerInterface $module_handler, $annotation) + { + parent::__construct('Plugin/Foo', $namespaces, $module_handler, 'FooInterface', $annotation); + } + +} + +// Negative: null default. +class ParameterNullDefault extends DefaultPluginManager +{ + + public function __construct(\Traversable $namespaces, ModuleHandlerInterface $module_handler, ?string $annotation = null) + { + parent::__construct('Plugin/Foo', $namespaces, $module_handler, 'FooInterface', $annotation); + } + +} + +// Negative: not a plugin manager. +class NotAPluginManager +{ + + protected static $annotation = 'Drupal\Component\Annotation\Plugin'; + + public function __construct() + { + parent::__construct(static::$annotation); + } + +} + +// An anonymous class with its own constructor in an earlier method must not be +// mistaken for the manager's constructor. Positive: real constructor reports. +class AnonymousClassBeforeConstructorReports extends DefaultPluginManager +{ + + protected static $annotation = 'Drupal\Component\Annotation\Plugin'; + + public function helper(): object + { + return new class (AttributePlugin::class) extends \ArrayObject { + + public function __construct(string $input = AttributePlugin::class) + { + parent::__construct([$input]); + } + + }; + } + + public function __construct(\Traversable $namespaces, ModuleHandlerInterface $module_handler) + { + parent::__construct('Plugin/Foo', $namespaces, $module_handler, 'FooInterface', static::$annotation); + } + +} + +// Negative: the real constructor uses an attribute class. +class AnonymousClassBeforeConstructorClean extends DefaultPluginManager +{ + + protected static $annotation = AttributePlugin::class; + + public function helper(): object + { + return new class (AnnotationPlugin::class) extends \ArrayObject { + + public function __construct(string $input = AnnotationPlugin::class) + { + parent::__construct([$input]); + } + + }; + } + + public function __construct(\Traversable $namespaces, ModuleHandlerInterface $module_handler) + { + parent::__construct('Plugin/Foo', $namespaces, $module_handler, 'FooInterface', static::$annotation); + } + +} + +// Negative: no real constructor, only an anonymous class with one. +class NoConstructorAnonymousClassOnly extends DefaultPluginManager +{ + + public function helper(): object + { + return new class extends \ArrayObject { + + public function __construct($annotation = 'Drupal\Component\Annotation\Plugin') + { + parent::__construct([], 0, $annotation, $annotation, $annotation); + } + + }; + } + +} + +// The call inside the anonymous class and closure belongs to them. Only the +// manager's own call is reported. +class NestedScopesInConstructorReports extends DefaultPluginManager +{ + + public function __construct(\Traversable $namespaces, ModuleHandlerInterface $module_handler, $annotation = 'Drupal\Component\Annotation\Plugin') + { + $anonymous = new class ($annotation) extends \ArrayObject { + + public function __construct($annotation) + { + parent::__construct([], 0, $annotation, $annotation, $annotation); + } + + }; + $closure = static function () use ($annotation) { + return new class ($annotation) extends \ArrayObject { + + public function __construct($annotation) + { + parent::__construct([], 0, $annotation, $annotation, $annotation); + } + + }; + }; + parent::__construct('Plugin/Foo', $namespaces, $module_handler, 'FooInterface', $annotation); + } + +} + +// Negative: only a nested scope calls parent::__construct() with an annotation +// default; the manager's own call passes an attribute class. +class NestedScopesInConstructorClean extends DefaultPluginManager +{ + + protected static $annotation = 'Drupal\Component\Annotation\Plugin'; + + public function __construct(\Traversable $namespaces, ModuleHandlerInterface $module_handler) + { + $anonymous = new class extends \ArrayObject { + + public function __construct() + { + parent::__construct([], 0, 'x', 'y', static::$annotation); + } + + }; + parent::__construct('Plugin/Foo', $namespaces, $module_handler, 'FooInterface', AttributePlugin::class); + } + +} + +// Negative: a by-reference closure use is a real write to the parameter. +class ParameterWrittenByClosureReference extends DefaultPluginManager +{ + + public function __construct(\Traversable $namespaces, ModuleHandlerInterface $module_handler, $annotation = 'Drupal\Component\Annotation\Plugin') + { + $override = static function () use (&$annotation) { + $annotation = AttributePlugin::class; + }; + $override(); + parent::__construct('Plugin/Foo', $namespaces, $module_handler, 'FooInterface', $annotation); + } + +} + +// Positive: assignments inside an anonymous class or closure body are other +// variables, not writes to the parameter. +class ParameterShadowedInNestedScopes extends DefaultPluginManager +{ + + public function __construct(\Traversable $namespaces, ModuleHandlerInterface $module_handler, $annotation = 'Drupal\Component\Annotation\Plugin') + { + $closure = static function () { + $annotation = AttributePlugin::class; + return $annotation; + }; + $arrow = static fn () => $closure(); + $anonymous = new class extends \ArrayObject { + + public function change(): void + { + $annotation = AttributePlugin::class; + } + + }; + parent::__construct('Plugin/Foo', $namespaces, $module_handler, 'FooInterface', $annotation); + } + +} diff --git a/tests/src/Rules/data/plugin-manager-attribute-discovery.php b/tests/src/Rules/data/plugin-manager-attribute-discovery.php new file mode 100644 index 00000000..22592378 --- /dev/null +++ b/tests/src/Rules/data/plugin-manager-attribute-discovery.php @@ -0,0 +1,153 @@ +