diff --git a/core/core.api.php b/core/core.api.php index 79b03bd85995213e8f6af5730cd8202a61f4a8ea..c6493f35b77e1d19c22ae2a95764a7eea03436a5 100644 --- a/core/core.api.php +++ b/core/core.api.php @@ -1653,6 +1653,9 @@ * The following hooks can not be implemented as a class method, and must be * implemented as procedural: * + * Legacy meta hooks: + * - hook_hook_info() + * * Install hooks: * - hook_install() * - hook_module_preinstall() diff --git a/core/lib/Drupal/Core/Extension/ModuleHandler.php b/core/lib/Drupal/Core/Extension/ModuleHandler.php index aa1882c101e084536adb7bf543c71feef6a54518..8d787a07629195184adb410434c5eff1c6a5847e 100644 --- a/core/lib/Drupal/Core/Extension/ModuleHandler.php +++ b/core/lib/Drupal/Core/Extension/ModuleHandler.php @@ -77,11 +77,13 @@ class ModuleHandler implements ModuleHandlerInterface { * The event dispatcher. * @param array $hookImplementationsMap * An array keyed by hook, classname, method and the value is the module. + * @param array $groupIncludes + * An array of .inc files to get helpers from. * * @see \Drupal\Core\DrupalKernel * @see \Drupal\Core\CoreServiceProvider */ - public function __construct($root, array $module_list, protected EventDispatcherInterface $eventDispatcher, protected array $hookImplementationsMap) { + public function __construct($root, array $module_list, protected EventDispatcherInterface $eventDispatcher, protected array $hookImplementationsMap, protected array $groupIncludes = []) { $this->root = $root; $this->moduleList = []; foreach ($module_list as $name => $module) { @@ -560,6 +562,11 @@ protected function getHookListeners(string $hook): array { } } } + if (isset($this->groupIncludes[$hook])) { + foreach ($this->groupIncludes[$hook] as $include) { + include_once $include; + } + } } return $this->invokeMap[$hook] ?? []; } diff --git a/core/lib/Drupal/Core/Extension/module.api.php b/core/lib/Drupal/Core/Extension/module.api.php index bfe5f5f62ba5625b7e563af71b4b8e6adf6e7241..d2e720c3a6caf4109f810ef8dfc100b044094695 100644 --- a/core/lib/Drupal/Core/Extension/module.api.php +++ b/core/lib/Drupal/Core/Extension/module.api.php @@ -62,6 +62,8 @@ /** * Defines one or more hooks that are exposed by a module. * + * Only procedural implementations are supported for this hook. + * * Normally hooks do not need to be explicitly defined. However, by declaring a * hook explicitly, a module may define a "group" for it. Modules that implement * a hook may then place their implementation in either $module.module or in diff --git a/core/lib/Drupal/Core/Hook/Attribute/Hook.php b/core/lib/Drupal/Core/Hook/Attribute/Hook.php index 0203434f6c23a6c033e49b25f3a3cf4ba5c81230..324d0181382d5407a959b53334919ad8e4a774da 100644 --- a/core/lib/Drupal/Core/Hook/Attribute/Hook.php +++ b/core/lib/Drupal/Core/Hook/Attribute/Hook.php @@ -62,6 +62,9 @@ * * The following hooks can only have procedural hook implementations: * + * Legacy meta hooks: + * - hook_hook_info() + * * Install hooks: * - hook_install() * - hook_module_preinstall() diff --git a/core/lib/Drupal/Core/Hook/HookCollectorPass.php b/core/lib/Drupal/Core/Hook/HookCollectorPass.php index 273922f69f16abe77930de4234313ab5d63e6795..e2e1feb85dbbabda0c554ffadc0201517ad98b42 100644 --- a/core/lib/Drupal/Core/Hook/HookCollectorPass.php +++ b/core/lib/Drupal/Core/Hook/HookCollectorPass.php @@ -67,6 +67,18 @@ class HookCollectorPass implements CompilerPassInterface { */ protected int $priority = 0; + /** + * A list of functions implementing hook_hook_info(). + * + * (This is required only for BC.) + */ + private array $hookInfo = []; + + /** + * A list of .inc files. + */ + private array $groupIncludes = []; + /** * {@inheritdoc} */ @@ -75,6 +87,16 @@ public function process(ContainerBuilder $container): void { $map = []; $container->register(ProceduralCall::class, ProceduralCall::class) ->addArgument($collector->includes); + $groupIncludes = []; + foreach ($collector->hookInfo as $function) { + foreach ($function() as $hook => $info) { + if (isset($collector->groupIncludes[$info['group']])) { + $groupIncludes[$hook] = $collector->groupIncludes[$info['group']]; + } + } + } + $definition = $container->getDefinition('module_handler'); + $definition->setArgument('$groupIncludes', $groupIncludes); foreach ($collector->implementations as $hook => $class_implementations) { foreach ($class_implementations as $class => $method_hooks) { if ($container->has($class)) { @@ -152,7 +174,7 @@ protected function collectModuleHookImplementations($dir, $module, $module_preg) } if ($extension === 'php') { $namespace = preg_replace('#^src/#', "Drupal/$module/", $iterator->getSubPath()); - $class = $namespace . '/' . basename($fileinfo->getFilename(), '.php'); + $class = $namespace . '/' . $fileinfo->getBasename('.php'); $class = str_replace('/', '\\', $class); foreach (static::getHookAttributesInClass($class) as $attribute) { $this->addFromAttribute($attribute, $class, $module); @@ -167,6 +189,12 @@ protected function collectModuleHookImplementations($dir, $module, $module_preg) } } } + if ($extension === 'inc') { + $parts = explode('.', $fileinfo->getFilename()); + if (count($parts) === 3 && $parts[0] === $module) { + $this->groupIncludes[$parts[1]][] = $fileinfo->getPathname(); + } + } } } @@ -266,6 +294,9 @@ protected function addFromAttribute(Hook $hook, $class, $module) { */ protected function addProceduralImplementation(\SplFileInfo $fileinfo, string $hook, string $module, string $function) { $this->proceduralHooks[$hook][$module] = FALSE; + if ($hook === 'hook_info') { + $this->hookInfo[] = $function; + } if ($hook === 'module_implements_alter') { $this->moduleImplementsAlters[] = $function; } @@ -330,6 +361,7 @@ public function getImplementations(): array { */ public static function checkForProceduralOnlyHooks(Hook $hook, string $class): void { $staticDenyHooks = [ + 'hook_info', 'install', 'module_preinstall', 'module_preuninstall', diff --git a/core/tests/Drupal/KernelTests/Core/Hook/HookCollectorPassTest.php b/core/tests/Drupal/KernelTests/Core/Hook/HookCollectorPassTest.php index 0d85e3de88befb9e17932b66dbcced8562bcd55e..809850468d9c706dd13c3b7a0cc8537032ee454a 100644 --- a/core/tests/Drupal/KernelTests/Core/Hook/HookCollectorPassTest.php +++ b/core/tests/Drupal/KernelTests/Core/Hook/HookCollectorPassTest.php @@ -7,6 +7,7 @@ use Drupal\Core\Hook\HookCollectorPass; use Drupal\KernelTests\KernelTestBase; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; /** * @coversDefaultClass \Drupal\Core\Hook\HookCollectorPass @@ -36,6 +37,7 @@ public function testSymlink(): void { 'user_hooks_test' => ['pathname' => "$this->siteDirectory/user_hooks_test.info.yml"], ]; $container->setParameter('container.modules', $module_filenames); + $container->setDefinition('module_handler', new Definition()); (new HookCollectorPass())->process($container); $implementations = [ 'user_format_name_alter' => [ diff --git a/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php b/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php index b72331bf958123418d675d70aaf0e6b1aec1d795..0644f1db5d213484357fc816ba9c27ffc8f0ea85 100644 --- a/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php +++ b/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php @@ -9,6 +9,8 @@ use Drupal\Core\Extension\Exception\UnknownExtensionException; use Drupal\Core\Extension\ProceduralCall; use Drupal\Tests\UnitTestCase; +use Drupal\Tests\Core\GroupIncludesTestTrait; +use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\EventDispatcher\EventDispatcherInterface; /** @@ -19,6 +21,8 @@ */ class ModuleHandlerTest extends UnitTestCase { + use GroupIncludesTestTrait; + /** * @var \PHPUnit\Framework\MockObject\MockObject|\Symfony\Component\EventDispatcher\EventDispatcherInterface */ @@ -378,4 +382,15 @@ public function testGetModuleDirectories(): void { $this->assertEquals(['node' => $this->root . '/core/modules/node'], $module_handler->getModuleDirectories()); } + /** + * @covers ::getHookListeners + */ + public function testGroupIncludes(): void { + self::setupGroupIncludes(); + $moduleHandler = new ModuleHandler('', [], new EventDispatcher(), [], self::GROUP_INCLUDES); + $this->assertFalse(function_exists('_test_module_helper')); + $moduleHandler->invokeAll('token_info'); + $this->assertTrue(function_exists('_test_module_helper')); + } + } diff --git a/core/tests/Drupal/Tests/Core/GroupIncludesTestTrait.php b/core/tests/Drupal/Tests/Core/GroupIncludesTestTrait.php new file mode 100644 index 0000000000000000000000000000000000000000..807ae036e695a2fd411ff8f2b186170309791a05 --- /dev/null +++ b/core/tests/Drupal/Tests/Core/GroupIncludesTestTrait.php @@ -0,0 +1,48 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\Core; + +use org\bovigo\vfs\vfsStream; + +/** + * @coversDefaultClass \Drupal\Core\Hook\HookCollectorPass + * @group Hook + */ +trait GroupIncludesTestTrait { + + const GROUP_INCLUDES = ['token_info' => ['vfs://drupal_root/test_module.tokens.inc']]; + + /** + * @return array[] + */ + public static function setupGroupIncludes(): array { + vfsStream::setup('drupal_root'); + file_put_contents('vfs://drupal_root/test_module_info.yml', ''); + $module_filenames = [ + 'test_module' => ['pathname' => 'vfs://drupal_root/test_module_info.yml'], + ]; + file_put_contents('vfs://drupal_root/test_module.module', <<<'EOD' +<?php + +function test_module_hook_info() { + $hooks['token_info'] = [ + 'group' => 'tokens', + ]; + return $hooks; +} + +EOD + ); + file_put_contents('vfs://drupal_root/test_module.tokens.inc', <<<'EOD' +<?php + +function _test_module_helper() {} + +EOD + ); + return $module_filenames; + } + +} diff --git a/core/tests/Drupal/Tests/Core/Hook/HookCollectorPassTest.php b/core/tests/Drupal/Tests/Core/Hook/HookCollectorPassTest.php index c1fd4ea1c7fe35f80d0acc1f20e0a1e540eb2618..c4559d290f39dc48d9a523234606978f73655490 100644 --- a/core/tests/Drupal/Tests/Core/Hook/HookCollectorPassTest.php +++ b/core/tests/Drupal/Tests/Core/Hook/HookCollectorPassTest.php @@ -9,8 +9,10 @@ use Drupal\Core\Hook\Attribute\Hook; use Drupal\Core\Hook\HookCollectorPass; use Drupal\Tests\UnitTestCase; +use Drupal\Tests\Core\GroupIncludesTestTrait; use org\bovigo\vfs\vfsStream; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; /** * @coversDefaultClass \Drupal\Core\Hook\HookCollectorPass @@ -18,6 +20,8 @@ */ class HookCollectorPassTest extends UnitTestCase { + use GroupIncludesTestTrait; + /** * @covers ::collectAllHookImplementations * @covers ::filterIterator @@ -61,11 +65,26 @@ function test_module_should_be_skipped(); $container = new ContainerBuilder(); $container->setParameter('container.modules', $module_filenames); + $container->setDefinition('module_handler', new Definition()); (new HookCollectorPass())->process($container); $this->assertSame($implementations, $container->getParameter('hook_implementations_map')); $this->assertSame($includes, $container->getDefinition(ProceduralCall::class)->getArguments()[0]); } + /** + * @covers ::process + * @covers ::collectModuleHookImplementations + */ + public function testGroupIncludes(): void { + $module_filenames = self::setupGroupIncludes(); + $container = new ContainerBuilder(); + $container->setParameter('container.modules', $module_filenames); + $container->setDefinition('module_handler', new Definition()); + (new HookCollectorPass())->process($container); + $argument = $container->getDefinition('module_handler')->getArgument('$groupIncludes'); + $this->assertSame(self::GROUP_INCLUDES, $argument); + } + /** * @covers ::getHookAttributesInClass */