diff --git a/core/lib/Drupal/Component/Annotation/Doctrine/StaticReflectionClass.php b/core/lib/Drupal/Component/Annotation/Doctrine/StaticReflectionClass.php index ff8d67f062d..b0e60706962 100644 --- a/core/lib/Drupal/Component/Annotation/Doctrine/StaticReflectionClass.php +++ b/core/lib/Drupal/Component/Annotation/Doctrine/StaticReflectionClass.php @@ -79,6 +79,18 @@ class StaticReflectionClass extends ReflectionClass return $this->staticReflectionParser->getUseStatements(); } + /** + * Determines if the class has the provided class attribute. + * + * @param string $attribute The attribute to check for. + * + * @return bool + */ + public function hasClassAttribute(string $attribute) + { + return $this->staticReflectionParser->hasClassAttribute($attribute); + } + /** * {@inheritDoc} */ diff --git a/core/lib/Drupal/Component/Annotation/Doctrine/StaticReflectionParser.php b/core/lib/Drupal/Component/Annotation/Doctrine/StaticReflectionParser.php index 78fdbebe585..10b9441c1a2 100644 --- a/core/lib/Drupal/Component/Annotation/Doctrine/StaticReflectionParser.php +++ b/core/lib/Drupal/Component/Annotation/Doctrine/StaticReflectionParser.php @@ -132,6 +132,13 @@ class StaticReflectionParser */ protected $parentStaticReflectionParser; + /** + * The class attributes. + * + * @var string[] + */ + protected array $classAttributes = []; + /** * Parses a class residing in a PSR-0 hierarchy. * @@ -178,6 +185,7 @@ class StaticReflectionParser $tokenParser = new TokenParser($contents); $docComment = ''; $last_token = false; + $attributeNames = []; while ($token = $tokenParser->next(false)) { switch ($token[0]) { @@ -187,7 +195,17 @@ class StaticReflectionParser case T_DOC_COMMENT: $docComment = $token[1]; break; + case T_ATTRIBUTE: + while ($token = $tokenParser->next()) { + if ($token[0] === T_NAME_FULLY_QUALIFIED || $token[0] === T_NAME_QUALIFIED || $token[0] === T_NAME_RELATIVE || $token[0] === T_STRING) { + $attributeNames[] = $token[1]; + break 2; + } + } + break; case T_CLASS: + // Convert the attributes to fully qualified names. + $this->classAttributes = array_map(fn($name) => strtolower($this->fullySpecifyName($name)), $attributeNames); if ($last_token !== T_PAAMAYIM_NEKUDOTAYIM && $last_token !== T_NEW) { $this->docComment['class'] = $docComment; $docComment = ''; @@ -223,31 +241,7 @@ class StaticReflectionParser $docComment = ''; break; case T_EXTENDS: - $this->parentClassName = $tokenParser->parseClass(); - $nsPos = strpos($this->parentClassName, '\\'); - $fullySpecified = false; - if ($nsPos === 0) { - $fullySpecified = true; - } else { - if ($nsPos) { - $prefix = strtolower(substr($this->parentClassName, 0, $nsPos)); - $postfix = substr($this->parentClassName, $nsPos); - } else { - $prefix = strtolower($this->parentClassName); - $postfix = ''; - } - foreach ($this->useStatements as $alias => $use) { - if ($alias !== $prefix) { - continue; - } - - $this->parentClassName = '\\' . $use . $postfix; - $fullySpecified = true; - } - } - if (! $fullySpecified) { - $this->parentClassName = '\\' . $this->namespace . '\\' . $this->parentClassName; - } + $this->parentClassName = $this->fullySpecifyName($tokenParser->parseClass()); break; } @@ -341,4 +335,53 @@ class StaticReflectionParser } throw new ReflectionException('Invalid ' . $type . ' "' . $name . '"'); } + + /** + * Determines if the class has the provided class attribute. + * + * @param string $attribute The fully qualified attribute to check for. + * + * @return bool + */ + public function hasClassAttribute(string $attribute): bool + { + $this->parse(); + return in_array('\\' . ltrim(strtolower($attribute), '\\'), $this->classAttributes, TRUE); + } + + /** + * Converts a name into a fully specified name. + * + * @param string $name The name to convert. + * + * @return string + */ + private function fullySpecifyName(string $name): string + { + $nsPos = strpos($name, '\\'); + $fullySpecified = false; + if ($nsPos === 0) { + $fullySpecified = true; + } else { + if ($nsPos) { + $prefix = strtolower(substr($name, 0, $nsPos)); + $postfix = substr($name, $nsPos); + } else { + $prefix = strtolower($name); + $postfix = ''; + } + foreach ($this->useStatements as $alias => $use) { + if ($alias !== $prefix) { + continue; + } + + $name = '\\' . $use . $postfix; + $fullySpecified = true; + } + } + if (! $fullySpecified) { + $name = '\\' . $this->namespace . '\\' . $name; + } + return $name; + } } diff --git a/core/lib/Drupal/Core/Plugin/Discovery/AttributeDiscoveryWithAnnotations.php b/core/lib/Drupal/Core/Plugin/Discovery/AttributeDiscoveryWithAnnotations.php index f0ddae33932..8bae6730629 100644 --- a/core/lib/Drupal/Core/Plugin/Discovery/AttributeDiscoveryWithAnnotations.php +++ b/core/lib/Drupal/Core/Plugin/Discovery/AttributeDiscoveryWithAnnotations.php @@ -84,15 +84,23 @@ class AttributeDiscoveryWithAnnotations extends AttributeClassDiscovery { $finder = MockFileFinder::create($fileinfo->getPathName()); $parser = new StaticReflectionParser($class, $finder, TRUE); + $reflection_class = $parser->getReflectionClass(); // @todo Handle deprecating definitions discovery via annotations in // https://www.drupal.org/project/drupal/issues/3265945. /** @var \Drupal\Component\Annotation\AnnotationInterface $annotation */ - if ($annotation = $this->getAnnotationReader()->getClassAnnotation($parser->getReflectionClass(), $this->pluginDefinitionAnnotationName)) { + if ($annotation = $this->getAnnotationReader()->getClassAnnotation($reflection_class, $this->pluginDefinitionAnnotationName)) { $this->prepareAnnotationDefinition($annotation, $class); return ['id' => $annotation->getId(), 'content' => $annotation->get()]; } - return parent::parseClass($class, $fileinfo); + // Annotations use static reflection and are able to analyze a class that + // extends classes or uses traits that do not exist. Attribute discovery + // will trigger a fatal error with such classes, so only call it if the + // class has a class attribute. + if ($reflection_class->hasClassAttribute($this->pluginDefinitionAttributeName)) { + return parent::parseClass($class, $fileinfo); + } + return ['id' => NULL, 'content' => NULL]; } /** diff --git a/core/modules/system/tests/modules/plugin_test/src/Plugin/plugin_test/custom_annotation/Example3.php b/core/modules/system/tests/modules/plugin_test/src/Plugin/plugin_test/custom_annotation/Example3.php index 2394b85c2de..ac8b6d770a3 100644 --- a/core/modules/system/tests/modules/plugin_test/src/Plugin/plugin_test/custom_annotation/Example3.php +++ b/core/modules/system/tests/modules/plugin_test/src/Plugin/plugin_test/custom_annotation/Example3.php @@ -7,7 +7,7 @@ use Drupal\plugin_test\Plugin\Attribute\PluginExample; /** * Provides a test plugin with a custom attribute. */ -#[PluginExample( +#[/* comment */PluginExample( id: "example_3", custom: "George" )] diff --git a/core/modules/system/tests/modules/plugin_test/src/Plugin/plugin_test/custom_annotation/Example4.php b/core/modules/system/tests/modules/plugin_test/src/Plugin/plugin_test/custom_annotation/Example4.php new file mode 100644 index 00000000000..b20dd0873e7 --- /dev/null +++ b/core/modules/system/tests/modules/plugin_test/src/Plugin/plugin_test/custom_annotation/Example4.php @@ -0,0 +1,20 @@ + $base_directory]); $module_handler = $this->container->get('module_handler'); + // Ensure broken files exist as expected. + try { + $e = NULL; + new \ReflectionClass('\Drupal\plugin_test\Plugin\plugin_test\custom_annotation\ExtendingNonInstalledClass'); + } + catch (\Throwable $e) { + } finally { + $this->assertInstanceOf(\Throwable::class, $e); + $this->assertSame('Class "Drupal\non_installed_module\NonExisting" not found', $e->getMessage()); + } + // Ensure there is a class with the expected name. We cannot reflect on this + // as it triggers a fatal error. + $this->assertFileExists($base_directory . '/' . $subdir . '/UsingNonInstalledTraitClass.php'); + // Annotation only. $manager = new DefaultPluginManager($subdir, $namespaces, $module_handler, NULL, AnnotationPluginExample::class); $definitions = $manager->getDefinitions(); $this->assertArrayHasKey('example_1', $definitions); $this->assertArrayHasKey('example_2', $definitions); $this->assertArrayNotHasKey('example_3', $definitions); + $this->assertArrayNotHasKey('example_4', $definitions); + $this->assertArrayNotHasKey('example_5', $definitions); // Annotations and attributes together. $manager = new DefaultPluginManager($subdir, $namespaces, $module_handler, NULL, AttributePluginExample::class, AnnotationPluginExample::class); @@ -41,13 +58,31 @@ class DefaultPluginManagerTest extends KernelTestBase { $this->assertArrayHasKey('example_1', $definitions); $this->assertArrayHasKey('example_2', $definitions); $this->assertArrayHasKey('example_3', $definitions); + $this->assertArrayHasKey('example_4', $definitions); + $this->assertArrayHasKey('example_5', $definitions); // Attributes only. + // \Drupal\Component\Plugin\Discovery\AttributeClassDiscovery does not + // support parsing classes that cannot be reflected. Therefore, we use VFS + // to create a directory remove plugin_test's plugins and remove the broken + // plugins. + vfsStream::setup('plugin_test'); + $dir = vfsStream::create(['src' => ['Plugin' => ['plugin_test' => ['custom_annotation' => []]]]]); + $plugin_directory = $dir->getChild('src/' . $subdir); + vfsStream::copyFromFileSystem($base_directory . '/' . $subdir, $plugin_directory); + $plugin_directory->removeChild('ExtendingNonInstalledClass.php'); + $plugin_directory->removeChild('UsingNonInstalledTraitClass.php'); + + $namespaces = new \ArrayObject(['Drupal\plugin_test' => vfsStream::url('plugin_test/src')]); $manager = new DefaultPluginManager($subdir, $namespaces, $module_handler, NULL, AttributePluginExample::class); $definitions = $manager->getDefinitions(); $this->assertArrayNotHasKey('example_1', $definitions); $this->assertArrayNotHasKey('example_2', $definitions); $this->assertArrayHasKey('example_3', $definitions); + $this->assertArrayHasKey('example_4', $definitions); + $this->assertArrayHasKey('example_5', $definitions); + $this->assertArrayNotHasKey('extending_non_installed_class', $definitions); + $this->assertArrayNotHasKey('using_non_installed_trait', $definitions); } } diff --git a/core/tests/Drupal/Tests/Component/Annotation/Doctrine/Fixtures/Attribute/AttributeClass.php b/core/tests/Drupal/Tests/Component/Annotation/Doctrine/Fixtures/Attribute/AttributeClass.php new file mode 100644 index 00000000000..6df7baeedb3 --- /dev/null +++ b/core/tests/Drupal/Tests/Component/Annotation/Doctrine/Fixtures/Attribute/AttributeClass.php @@ -0,0 +1,8 @@ +assertSame($expected, $parser->hasClassAttribute($attribute_class), "'$class' has '$attribute_class'"); + // Attribute names and namespaces are case-insensitive in PHP. Practically + // Composer autoloading makes this untrue but builtins like \Attribute are + // case-insensitive so we should support that. + $this->assertSame($expected, $parser->hasClassAttribute(strtoupper($attribute_class)), "'$class' has '$attribute_class'"); + } + +}