From 96f3a9369fc9eba81b1ffcea8eb82187ce48a9bf Mon Sep 17 00:00:00 2001 From: Andreas Hennings <andreas@dqxtech.net> Date: Fri, 2 May 2025 01:11:23 +0200 Subject: [PATCH 01/13] Add docs on ModuleHandlerTest::getModuleHandler(). --- .../Drupal/Tests/Core/Extension/ModuleHandlerTest.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php b/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php index 05a064eab7df..fb6b84dc7a39 100644 --- a/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php +++ b/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php @@ -48,6 +48,13 @@ protected function setUp(): void { * ModuleHandler objects as a property in unit tests. They must be generated * by the test method by calling this method. * + * @param list<string, string> $modules + * Module paths by module name. + * @param array<callable-string, string> $implementations + * Module names by function name implementing hook_hook(). + * @param bool $loadAll + * TRUE to call ModuleHandler->loadAll() on the new module handler. + * * @return \Drupal\Core\Extension\ModuleHandler * The module handler to test. */ -- GitLab From faaee229dc2f4300e3e680a58a012d6f38bc64da Mon Sep 17 00:00:00 2001 From: Andreas Hennings <andreas@dqxtech.net> Date: Fri, 2 May 2025 02:32:12 +0200 Subject: [PATCH 02/13] Reorder imports in ModuleHandlerTest.php. --- core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php b/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php index fb6b84dc7a39..3f653e783459 100644 --- a/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php +++ b/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php @@ -4,12 +4,12 @@ namespace Drupal\Tests\Core\Extension; +use Drupal\Core\Extension\Exception\UnknownExtensionException; use Drupal\Core\Extension\Extension; use Drupal\Core\Extension\ModuleHandler; -use Drupal\Core\Extension\Exception\UnknownExtensionException; use Drupal\Core\Extension\ProceduralCall; -use Drupal\Tests\UnitTestCase; use Drupal\Tests\Core\GroupIncludesTestTrait; +use Drupal\Tests\UnitTestCase; use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\EventDispatcher\EventDispatcherInterface; -- GitLab From 3c15bc3f98bfa82d44ce2d3dda0981d61268f266 Mon Sep 17 00:00:00 2001 From: Andreas Hennings <andreas@dqxtech.net> Date: Sat, 29 Mar 2025 15:00:50 +0100 Subject: [PATCH 03/13] Introduce ImplementationList. --- .../Drupal/Core/Extension/ModuleHandler.php | 85 ++++++------- .../Drupal/Core/Hook/ImplementationList.php | 100 +++++++++++++++ .../Core/Hook/ImplementationListTest.php | 119 ++++++++++++++++++ 3 files changed, 257 insertions(+), 47 deletions(-) create mode 100644 core/lib/Drupal/Core/Hook/ImplementationList.php create mode 100644 core/tests/Drupal/Tests/Core/Hook/ImplementationListTest.php diff --git a/core/lib/Drupal/Core/Extension/ModuleHandler.php b/core/lib/Drupal/Core/Extension/ModuleHandler.php index ad361d3fe669..383d83dd1d2b 100644 --- a/core/lib/Drupal/Core/Extension/ModuleHandler.php +++ b/core/lib/Drupal/Core/Extension/ModuleHandler.php @@ -8,6 +8,7 @@ use Drupal\Core\Extension\Exception\UnknownExtensionException; use Drupal\Core\Hook\Attribute\LegacyHook; use Drupal\Core\Hook\HookCollectorPass; +use Drupal\Core\Hook\ImplementationList; use Drupal\Core\Hook\OrderOperation\OrderOperation; use Symfony\Component\EventDispatcher\EventDispatcherInterface; @@ -61,20 +62,11 @@ class ModuleHandler implements ModuleHandlerInterface { protected $includeFileKeys = []; /** - * Lists of implementation callables by hook. + * Implementation lists by hook name. * - * @var array<string, list<callable>> + * @var array<string, \Drupal\Core\Hook\ImplementationList> */ - protected array $listenersByHook = []; - - /** - * Lists of module names by hook. - * - * The indices are exactly the same as in $listenersByHook. - * - * @var array<string, list<string>> - */ - protected array $modulesByHook = []; + protected array $hookImplementationLists = []; /** * Hook and module keyed list of listeners. @@ -244,13 +236,20 @@ protected function add($type, $name, $path) { $hook_collector->loadAllIncludes(); // Register procedural implementations. foreach ($hook_collector->getImplementations() as $hook => $moduleImplements) { + $list = $this->hookImplementationLists[$hook] ?? NULL; + $listeners = $list?->listeners ?? []; + $modules = $list?->modules ?? []; foreach ($moduleImplements as $module => $classImplements) { foreach ($classImplements[ProceduralCall::class] ?? [] as $method) { - $this->listenersByHook[$hook][] = $method; - $this->modulesByHook[$hook][] = $module; + $listeners[] = $method; + $modules[] = $module; $this->invokeMap[$hook][$module][] = $method; } } + // @todo Order the listeners after adding. + if ($listeners) { + $this->hookImplementationLists[$hook] = new ImplementationList($listeners, $modules); + } } } @@ -334,8 +333,7 @@ public function getHookInfo() { public function resetImplementations() { $this->alterEventListeners = []; $this->invokeMap = []; - $this->listenersByHook = []; - $this->modulesByHook = []; + $this->hookImplementationLists = []; } /** @@ -350,8 +348,8 @@ public function hasImplementations(string $hook, $modules = NULL): bool { * {@inheritdoc} */ public function invokeAllWith(string $hook, callable $callback): void { - foreach ($this->getFlatHookListeners($hook) as $index => $listener) { - $module = $this->modulesByHook[$hook][$index]; + $list = $this->getHookImplementationList($hook); + foreach ($list->iterateByModule() as $module => $listener) { $callback($listener, $module); } } @@ -484,25 +482,25 @@ public function alter($type, &$data, &$context1 = NULL, &$context2 = NULL) { */ protected function getCombinedListeners(array $hooks): array { // Get implementation lists for each hook. - $listener_lists = array_map($this->getFlatHookListeners(...), $hooks); + /** @var list<\Drupal\Core\Hook\ImplementationList> $lists */ + $lists = array_map($this->getHookImplementationList(...), $hooks); // Remove empty lists. - $listener_lists = array_filter($listener_lists); - if (!$listener_lists) { + /** @var array<int, \Drupal\Core\Hook\ImplementationList> $lists */ + $lists = array_filter($lists, fn (ImplementationList $list) => $list->hasImplementations()); + if (!$lists) { // No implementations exist. return []; } - if (array_keys($listener_lists) === [0]) { + if (array_keys($lists) === [0]) { // Only the first hook has implementations. - return $listener_lists[0]; + return $lists[0]->listeners; } // Collect the lists from each hook and group the listeners by module. $listeners_by_identifier = []; $modules_by_identifier = []; $identifiers_by_module = []; - foreach ($listener_lists as $i_hook => $listeners) { - $hook = $hooks[$i_hook]; - foreach ($listeners as $i_listener => $listener) { - $module = $this->modulesByHook[$hook][$i_listener]; + foreach ($lists as $list) { + foreach ($list->iterateByModule() as $module => $listener) { $identifier = is_array($listener) ? get_class($listener[0]) . '::' . $listener[1] : ProceduralCall::class . '::' . $listener; @@ -709,31 +707,23 @@ public function writeCache() { * A list of event listeners implementing this hook. */ protected function getHookListeners(string $hook): array { - if (!isset($this->invokeMap[$hook])) { - $this->invokeMap[$hook] = []; - foreach ($this->getFlatHookListeners($hook) as $index => $listener) { - $module = $this->modulesByHook[$hook][$index]; - $this->invokeMap[$hook][$module][] = $listener; - } - } - - return $this->invokeMap[$hook] ?? []; + return $this->invokeMap[$hook] + ??= $this->getHookImplementationList($hook)->groupByModule(); } /** - * Gets a list of hook listener callbacks. + * Gets a hook implementation list for a specific hook. * * @param string $hook * The hook name. * - * @return list<callable> - * A list of hook implementation callables. - * - * @internal + * @return \Drupal\Core\Hook\ImplementationList + * Object with hook implementation callbacks and their modules. */ - protected function getFlatHookListeners(string $hook): array { - if (!isset($this->listenersByHook[$hook])) { - $this->listenersByHook[$hook] = []; + protected function getHookImplementationList(string $hook): ImplementationList { + if (!isset($this->hookImplementationLists[$hook])) { + $listeners = []; + $modules = []; foreach ($this->eventDispatcher->getListeners("drupal_hook.$hook") as $listener) { if (is_array($listener) && is_object($listener[0])) { $module = $this->hookImplementationsMap[$hook][get_class($listener[0])][$listener[1]]; @@ -746,11 +736,12 @@ protected function getFlatHookListeners(string $hook): array { $callable = $listener; } if (isset($this->moduleList[$module])) { - $this->listenersByHook[$hook][] = $callable; - $this->modulesByHook[$hook][] = $module; + $listeners[] = $callable; + $modules[] = $module; } } } + $this->hookImplementationLists[$hook] = new ImplementationList($listeners, $modules); if (isset($this->groupIncludes[$hook])) { foreach ($this->groupIncludes[$hook] as $include) { @trigger_error('Autoloading hooks in the file (' . $include . ') is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Move the functions in this file to either the .module file or other appropriate location. See https://www.drupal.org/node/3489765', E_USER_DEPRECATED); @@ -759,7 +750,7 @@ protected function getFlatHookListeners(string $hook): array { } } - return $this->listenersByHook[$hook]; + return $this->hookImplementationLists[$hook]; } } diff --git a/core/lib/Drupal/Core/Hook/ImplementationList.php b/core/lib/Drupal/Core/Hook/ImplementationList.php new file mode 100644 index 000000000000..ce3e79bd7728 --- /dev/null +++ b/core/lib/Drupal/Core/Hook/ImplementationList.php @@ -0,0 +1,100 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Hook; + +/** + * Contains the ordered list of implementations for a hook. + * + * Also contains information about module names. + * + * @internal + */ +final class ImplementationList { + + /** + * Constructor. + * + * @param list<callable> $listeners + * List of hook implementation callbacks. + * @param list<string> $modules + * The module name associated with each hook implementation. + * This must have the same keys as $listeners. + */ + public function __construct( + public readonly array $listeners, + public readonly array $modules, + ) { + assert(array_is_list($listeners)); + assert(array_is_list($modules)); + assert(count($listeners) === count($modules)); + } + + /** + * Iterates over listeners, using module names as keys. + * + * @return \Iterator<string, callable> + * Iterator of listeners by module. + * This allows the same module to occur more than once. + */ + public function iterateByModule(): \Iterator { + foreach ($this->listeners as $index => $listener) { + yield $this->modules[$index] => $listener; + } + } + + /** + * Gets listeners grouped by module. + * + * @return array<string, list<callable>> + * Listeners grouped by module name. + */ + public function groupByModule(): array { + $listeners_by_module = []; + foreach ($this->listeners as $index => $listener) { + $listeners_by_module[$this->modules[$index]][] = $listener; + } + return $listeners_by_module; + } + + /** + * Gets listeners for a specific module. + * + * @param string $module + * Module name. + * + * @return list<callable> + * Listeners for that module. + */ + public function getForModule(string $module): array { + return array_values(array_intersect_key( + $this->listeners, + array_intersect($this->modules, [$module]), + )); + } + + /** + * Checks whether the list has any implementations. + * + * @return bool + * TRUE if it has implementations, FALSE if it is empty. + */ + public function hasImplementations(): bool { + return $this->listeners !== []; + } + + /** + * Checks whether the list has any implementations for specific modules. + * + * @param list<string> $modules + * Modules for which to check. + * + * @return bool + * TRUE if it has implementations for any of the modules, FALSE if not. + */ + public function hasImplementationsForModules(array $modules): bool { + return (bool) array_intersect($this->modules, $modules); + } + +} diff --git a/core/tests/Drupal/Tests/Core/Hook/ImplementationListTest.php b/core/tests/Drupal/Tests/Core/Hook/ImplementationListTest.php new file mode 100644 index 000000000000..7dc71c44c035 --- /dev/null +++ b/core/tests/Drupal/Tests/Core/Hook/ImplementationListTest.php @@ -0,0 +1,119 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\Core\Hook; + +use Drupal\Core\Hook\ImplementationList; +use Drupal\Tests\UnitTestCase; + +/** + * @coversDefaultClass \Drupal\Core\Hook\ImplementationList + * @group Hook + */ +class ImplementationListTest extends UnitTestCase { + + /** + * Log for include files. + * + * @var list<string> + */ + public static array $log = []; + + /** + * Tests public methods on a common instance. + * + * This is easier than separate test methods. + */ + public function testPublicMethods(): void { + $listeners = [ + fn () => 'a0', + fn () => 'b', + fn () => 'a1', + ]; + $modules = [ + 'module_a', + 'module_b', + // Repeat the same module. + 'module_a', + ]; + $list = new ImplementationList($listeners, $modules); + + // Test public properties. + $this->assertSame($listeners, $list->listeners); + $this->assertSame($modules, $list->modules); + + // Test iterateByModule(). + $i = 0; + foreach ($list->iterateByModule() as $module => $listener) { + $this->assertSame($modules[$i], $module); + $this->assertSame($listeners[$i], $listener); + ++$i; + } + + // Test groupByModule(). + $this->assertSame( + [ + 'module_a' => [$listeners[0], $listeners[2]], + 'module_b' => [$listeners[1]], + ], + $list->groupByModule(), + ); + + // Test getForModule(). + $this->assertSame([], $list->getForModule('other_module')); + $this->assertSame([$listeners[0], $listeners[2]], $list->getForModule('module_a')); + $this->assertSame([$listeners[1]], $list->getForModule('module_b')); + + // Test hasImplementations(). + $this->assertTrue($list->hasImplementations()); + + // Test hasImplementationsForModules(). + $this->assertFalse($list->hasImplementationsForModules(['other_module'])); + $this->assertFalse($list->hasImplementationsForModules([])); + $this->assertTrue($list->hasImplementationsForModules(['other_module', 'module_a'])); + $this->assertTrue($list->hasImplementationsForModules(['module_b'])); + } + + /** + * Tests public methods on an empty list. + */ + public function testEmptyList(): void { + $list = new ImplementationList([], []); + + // Test public properties. + $this->assertSame([], $list->listeners); + $this->assertSame([], $list->modules); + + // Test iterateByModule(). + $iterator = $list->iterateByModule(); + $this->assertFalse($iterator->valid()); + + // Test hasImplementations(). + $this->assertFalse($list->hasImplementations()); + + // Test getForModule(). + $this->assertSame([], $list->getForModule('any_module')); + + // Test hasImplementationsForModules(). + $this->assertFalse($list->hasImplementationsForModules(['any_module'])); + $this->assertFalse($list->hasImplementationsForModules([])); + } + + /** + * Creates a temporary file which writes to self::$log. + * + * @param string $message + * Message to write to self::$log on include. + * + * @return string + * File path. + */ + protected function createTempFile(string $message): string { + $file = tempnam(sys_get_temp_dir(), 'test'); + $export = var_export($message, TRUE); + file_put_contents($file, '<?php ' . self::class . "::\$log[] = $export;"); + return $file; + } + +} -- GitLab From c5277ee5b8c342f33665c13cd1bc9aa5194bc692 Mon Sep 17 00:00:00 2001 From: Andreas Hennings <andreas@dqxtech.net> Date: Thu, 1 May 2025 20:27:37 +0200 Subject: [PATCH 04/13] Assert that listeners in ImplementationList are callable, and update test. --- core/lib/Drupal/Core/Hook/ImplementationList.php | 1 + .../Tests/Core/Extension/ModuleHandlerTest.php | 12 +++++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/core/lib/Drupal/Core/Hook/ImplementationList.php b/core/lib/Drupal/Core/Hook/ImplementationList.php index ce3e79bd7728..a18db487c2b2 100644 --- a/core/lib/Drupal/Core/Hook/ImplementationList.php +++ b/core/lib/Drupal/Core/Hook/ImplementationList.php @@ -29,6 +29,7 @@ public function __construct( assert(array_is_list($listeners)); assert(array_is_list($modules)); assert(count($listeners) === count($modules)); + assert(array_filter($listeners, is_callable(...)) === $listeners); } /** diff --git a/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php b/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php index 3f653e783459..0335cbd39daa 100644 --- a/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php +++ b/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php @@ -52,16 +52,19 @@ protected function setUp(): void { * Module paths by module name. * @param array<callable-string, string> $implementations * Module names by function name implementing hook_hook(). + * @param array<callable-string, string> $includes_per_function + * Include files per function name. * @param bool $loadAll * TRUE to call ModuleHandler->loadAll() on the new module handler. * * @return \Drupal\Core\Extension\ModuleHandler * The module handler to test. */ - protected function getModuleHandler($modules = [], $implementations = [], $loadAll = TRUE) { + protected function getModuleHandler($modules = [], $implementations = [], array $includes_per_function = [], $loadAll = TRUE) { // This only works if there's a single $hook. if ($implementations) { - $listeners = array_map(fn ($function) => [new ProceduralCall([]), $function], array_keys($implementations)); + $procedural_call = new ProceduralCall($includes_per_function); + $listeners = array_map(fn ($function) => [$procedural_call, $function], array_keys($implementations)); $this->eventDispatcher->expects($this->once()) ->method('getListeners') ->with("drupal_hook.hook") @@ -350,7 +353,10 @@ public function testImplementsHookModuleEnabled(): void { 'module_handler_test_added' => 'core/tests/Drupal/Tests/Core/Extension/modules/module_handler_test_added', 'module_handler_test_no_hook' => 'core/tests/Drupal/Tests/Core/Extension/modules/module_handler_test_no_hook', ]; - $module_handler = $this->getModuleHandler($moduleList, $implementations); + $includes_per_function = [ + 'module_handler_test_added_hook' => 'core/tests/Drupal/Tests/Core/Extension/modules/module_handler_test_added/module_handler_test_added.hook.inc', + ]; + $module_handler = $this->getModuleHandler($moduleList, $implementations, $includes_per_function); $this->assertTrue($module_handler->hasImplementations('hook', 'module_handler_test'), 'Installed module implementation found.'); $this->assertTrue($module_handler->hasImplementations('hook', 'module_handler_test_added'), 'Runtime added module with implementation in include found.'); -- GitLab From afefd18d3b517caef2b87cccb7a92920f775392a Mon Sep 17 00:00:00 2001 From: Andreas Hennings <andreas@dqxtech.net> Date: Sun, 4 May 2025 11:28:09 +0200 Subject: [PATCH 05/13] Assert that modules are strings in ImplementationList.php. --- core/lib/Drupal/Core/Hook/ImplementationList.php | 1 + 1 file changed, 1 insertion(+) diff --git a/core/lib/Drupal/Core/Hook/ImplementationList.php b/core/lib/Drupal/Core/Hook/ImplementationList.php index a18db487c2b2..a38e36a36754 100644 --- a/core/lib/Drupal/Core/Hook/ImplementationList.php +++ b/core/lib/Drupal/Core/Hook/ImplementationList.php @@ -30,6 +30,7 @@ public function __construct( assert(array_is_list($modules)); assert(count($listeners) === count($modules)); assert(array_filter($listeners, is_callable(...)) === $listeners); + assert(array_filter($modules, is_string(...)) === $modules, (new \Exception())->getTraceAsString()); } /** -- GitLab From 56877cc22dbecac429811f0b960240fb7440efa5 Mon Sep 17 00:00:00 2001 From: Andreas Hennings <andreas@dqxtech.net> Date: Sat, 29 Mar 2025 13:54:45 +0100 Subject: [PATCH 06/13] Drop usage of ->getHookListeners() in ModuleHandler methods. --- core/lib/Drupal/Core/Extension/ModuleHandler.php | 14 ++++++++++---- .../Tests/Core/Extension/ModuleHandlerTest.php | 16 +++++++--------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/core/lib/Drupal/Core/Extension/ModuleHandler.php b/core/lib/Drupal/Core/Extension/ModuleHandler.php index 383d83dd1d2b..f9031f7fb7db 100644 --- a/core/lib/Drupal/Core/Extension/ModuleHandler.php +++ b/core/lib/Drupal/Core/Extension/ModuleHandler.php @@ -340,8 +340,11 @@ public function resetImplementations() { * {@inheritdoc} */ public function hasImplementations(string $hook, $modules = NULL): bool { - $implementation_modules = array_keys($this->getHookListeners($hook)); - return (bool) (isset($modules) ? array_intersect($implementation_modules, (array) $modules) : $implementation_modules); + $list = $this->getHookImplementationList($hook); + if ($modules === NULL) { + return $list->hasImplementations(); + } + return $list->hasImplementationsForModules((array) $modules); } /** @@ -358,7 +361,9 @@ public function invokeAllWith(string $hook, callable $callback): void { * {@inheritdoc} */ public function invoke($module, $hook, array $args = []) { - if ($listeners = $this->getHookListeners($hook)[$module] ?? []) { + $list = $this->getHookImplementationList($hook); + $listeners = $list->getForModule($module); + if ($listeners) { if (count($listeners) > 1) { throw new \LogicException("Module $module should not implement $hook more than once"); } @@ -435,7 +440,8 @@ public function invokeAllDeprecated($description, $hook, array $args = []) { * The name of the hook. */ private function triggerDeprecationError($description, $hook) { - $modules = array_keys($this->getHookListeners($hook)); + $list = $this->getHookImplementationList($hook); + $modules = array_unique($list->modules); if (!empty($modules)) { $message = 'The deprecated hook hook_' . $hook . '() is implemented in these modules: '; @trigger_error($message . implode(', ', $modules) . '. ' . $description, E_USER_DEPRECATED); diff --git a/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php b/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php index 0335cbd39daa..526ca970bd03 100644 --- a/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php +++ b/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php @@ -8,6 +8,7 @@ use Drupal\Core\Extension\Extension; use Drupal\Core\Extension\ModuleHandler; use Drupal\Core\Extension\ProceduralCall; +use Drupal\Core\Hook\ImplementationList; use Drupal\Tests\Core\GroupIncludesTestTrait; use Drupal\Tests\UnitTestCase; use Symfony\Component\EventDispatcher\EventDispatcher; @@ -402,6 +403,7 @@ function some_method(): void { $module_handler = new ModuleHandler($this->root, [], $this->eventDispatcher, $implementations); $module_handler->setModuleList(['some_module' => TRUE]); $r = new \ReflectionObject($module_handler); + $get_lists = fn () => $r->getProperty('hookImplementationLists')->getValue($module_handler); // Set up some synthetic results. $this->eventDispatcher @@ -411,15 +413,11 @@ function some_method(): void { ->willReturn([ [$c, 'some_method'], ]); - $this->assertNotEmpty($module_handler->hasImplementations('some_hook')); - - $listeners = $r->getProperty('invokeMap')->getValue($module_handler)['some_hook']['some_module']; - // Anonymous class doesn't work with assertSame() so assert it piecemeal. - $this->assertSame(count($listeners), 1); - foreach ($listeners as $listener) { - $this->assertSame(get_class($c), get_class($listener[0])); - $this->assertSame('some_method', $listener[1]); - } + $this->assertSame([], $get_lists()); + $this->assertTrue($module_handler->hasImplementations('some_hook')); + $this->assertEquals([ + 'some_hook' => new ImplementationList([[$c, 'some_method']], ['some_module']), + ], $get_lists()); } /** -- GitLab From cbdef5dce32569b93806577007fbb841828a9240 Mon Sep 17 00:00:00 2001 From: Andreas Hennings <andreas@dqxtech.net> Date: Tue, 29 Apr 2025 18:58:33 +0200 Subject: [PATCH 07/13] Remove ModuleHandler::getHookListeners() and ImplementationList::groupByModule(). --- .../Drupal/Core/Extension/ModuleHandler.php | 23 ------------------- .../Drupal/Core/Hook/ImplementationList.php | 14 ----------- .../Core/Extension/ModuleHandlerTest.php | 3 --- .../Core/Hook/ImplementationListTest.php | 9 -------- 4 files changed, 49 deletions(-) diff --git a/core/lib/Drupal/Core/Extension/ModuleHandler.php b/core/lib/Drupal/Core/Extension/ModuleHandler.php index f9031f7fb7db..5ba5716d730d 100644 --- a/core/lib/Drupal/Core/Extension/ModuleHandler.php +++ b/core/lib/Drupal/Core/Extension/ModuleHandler.php @@ -68,13 +68,6 @@ class ModuleHandler implements ModuleHandlerInterface { */ protected array $hookImplementationLists = []; - /** - * Hook and module keyed list of listeners. - * - * @var array<string, array<string, list<callable>>> - */ - protected array $invokeMap = []; - /** * Ordering rules by hook name. * @@ -243,7 +236,6 @@ protected function add($type, $name, $path) { foreach ($classImplements[ProceduralCall::class] ?? [] as $method) { $listeners[] = $method; $modules[] = $module; - $this->invokeMap[$hook][$module][] = $method; } } // @todo Order the listeners after adding. @@ -332,7 +324,6 @@ public function getHookInfo() { */ public function resetImplementations() { $this->alterEventListeners = []; - $this->invokeMap = []; $this->hookImplementationLists = []; } @@ -703,20 +694,6 @@ public function writeCache() { @trigger_error(__METHOD__ . '() is deprecated in drupal:11.1.0 and is removed from drupal:12.0.0. There is no need to call this method so there is no replacement. See https://www.drupal.org/node/3442349', E_USER_DEPRECATED); } - /** - * Gets hook listeners by module. - * - * @param string $hook - * The name of the hook. - * - * @return array<string, list<callable>> - * A list of event listeners implementing this hook. - */ - protected function getHookListeners(string $hook): array { - return $this->invokeMap[$hook] - ??= $this->getHookImplementationList($hook)->groupByModule(); - } - /** * Gets a hook implementation list for a specific hook. * diff --git a/core/lib/Drupal/Core/Hook/ImplementationList.php b/core/lib/Drupal/Core/Hook/ImplementationList.php index a38e36a36754..9b547df008cf 100644 --- a/core/lib/Drupal/Core/Hook/ImplementationList.php +++ b/core/lib/Drupal/Core/Hook/ImplementationList.php @@ -46,20 +46,6 @@ public function iterateByModule(): \Iterator { } } - /** - * Gets listeners grouped by module. - * - * @return array<string, list<callable>> - * Listeners grouped by module name. - */ - public function groupByModule(): array { - $listeners_by_module = []; - foreach ($this->listeners as $index => $listener) { - $listeners_by_module[$this->modules[$index]][] = $listener; - } - return $listeners_by_module; - } - /** * Gets listeners for a specific module. * diff --git a/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php b/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php index 526ca970bd03..240d91ebe604 100644 --- a/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php +++ b/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php @@ -388,7 +388,6 @@ public function testInvokeAll(): void { /** * Tests hasImplementations. * - * @covers ::getHookListeners * @covers ::hasImplementations */ public function testHasImplementations(): void { @@ -436,8 +435,6 @@ public function testGetModuleDirectories(): void { } /** - * @covers ::getHookListeners - * * @group legacy */ public function testGroupIncludes(): void { diff --git a/core/tests/Drupal/Tests/Core/Hook/ImplementationListTest.php b/core/tests/Drupal/Tests/Core/Hook/ImplementationListTest.php index 7dc71c44c035..632caf6da47b 100644 --- a/core/tests/Drupal/Tests/Core/Hook/ImplementationListTest.php +++ b/core/tests/Drupal/Tests/Core/Hook/ImplementationListTest.php @@ -51,15 +51,6 @@ public function testPublicMethods(): void { ++$i; } - // Test groupByModule(). - $this->assertSame( - [ - 'module_a' => [$listeners[0], $listeners[2]], - 'module_b' => [$listeners[1]], - ], - $list->groupByModule(), - ); - // Test getForModule(). $this->assertSame([], $list->getForModule('other_module')); $this->assertSame([$listeners[0], $listeners[2]], $list->getForModule('module_a')); -- GitLab From 18227d5dc3936e4cad4711ae684c750fc36c0f24 Mon Sep 17 00:00:00 2001 From: Andreas Hennings <andreas@dqxtech.net> Date: Thu, 1 May 2025 16:29:23 +0200 Subject: [PATCH 08/13] Include files before getting the listeners, in getHookImplementationList(). --- core/lib/Drupal/Core/Extension/ModuleHandler.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/core/lib/Drupal/Core/Extension/ModuleHandler.php b/core/lib/Drupal/Core/Extension/ModuleHandler.php index 5ba5716d730d..af276747e4e6 100644 --- a/core/lib/Drupal/Core/Extension/ModuleHandler.php +++ b/core/lib/Drupal/Core/Extension/ModuleHandler.php @@ -705,6 +705,12 @@ public function writeCache() { */ protected function getHookImplementationList(string $hook): ImplementationList { if (!isset($this->hookImplementationLists[$hook])) { + if (isset($this->groupIncludes[$hook])) { + foreach ($this->groupIncludes[$hook] as $include) { + @trigger_error('Autoloading hooks in the file (' . $include . ') is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Move the functions in this file to either the .module file or other appropriate location. See https://www.drupal.org/node/3489765', E_USER_DEPRECATED); + include_once $include; + } + } $listeners = []; $modules = []; foreach ($this->eventDispatcher->getListeners("drupal_hook.$hook") as $listener) { @@ -725,12 +731,6 @@ protected function getHookImplementationList(string $hook): ImplementationList { } } $this->hookImplementationLists[$hook] = new ImplementationList($listeners, $modules); - if (isset($this->groupIncludes[$hook])) { - foreach ($this->groupIncludes[$hook] as $include) { - @trigger_error('Autoloading hooks in the file (' . $include . ') is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Move the functions in this file to either the .module file or other appropriate location. See https://www.drupal.org/node/3489765', E_USER_DEPRECATED); - include_once $include; - } - } } return $this->hookImplementationLists[$hook]; -- GitLab From 44dccd97d36dd7d5515d65ecad3990896417891f Mon Sep 17 00:00:00 2001 From: Andreas Hennings <andreas@dqxtech.net> Date: Fri, 6 Jun 2025 11:22:09 +0200 Subject: [PATCH 09/13] Remove left-over ImplementationListTest::createTempFile(). --- .../Tests/Core/Hook/ImplementationListTest.php | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/core/tests/Drupal/Tests/Core/Hook/ImplementationListTest.php b/core/tests/Drupal/Tests/Core/Hook/ImplementationListTest.php index 632caf6da47b..da4d8ee83eb2 100644 --- a/core/tests/Drupal/Tests/Core/Hook/ImplementationListTest.php +++ b/core/tests/Drupal/Tests/Core/Hook/ImplementationListTest.php @@ -91,20 +91,4 @@ public function testEmptyList(): void { $this->assertFalse($list->hasImplementationsForModules([])); } - /** - * Creates a temporary file which writes to self::$log. - * - * @param string $message - * Message to write to self::$log on include. - * - * @return string - * File path. - */ - protected function createTempFile(string $message): string { - $file = tempnam(sys_get_temp_dir(), 'test'); - $export = var_export($message, TRUE); - file_put_contents($file, '<?php ' . self::class . "::\$log[] = $export;"); - return $file; - } - } -- GitLab From 64abd54b88cf9d014990ce9d1f635824b834abb0 Mon Sep 17 00:00:00 2001 From: Andreas Hennings <andreas@dqxtech.net> Date: Fri, 6 Jun 2025 11:29:42 +0200 Subject: [PATCH 10/13] Make ImplementationList not final, for now. --- core/lib/Drupal/Core/Hook/ImplementationList.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/lib/Drupal/Core/Hook/ImplementationList.php b/core/lib/Drupal/Core/Hook/ImplementationList.php index 9b547df008cf..9d2794661b19 100644 --- a/core/lib/Drupal/Core/Hook/ImplementationList.php +++ b/core/lib/Drupal/Core/Hook/ImplementationList.php @@ -11,7 +11,7 @@ * * @internal */ -final class ImplementationList { +class ImplementationList { /** * Constructor. -- GitLab From bb773c9c69a72eb9d26929f3055c4b59ba9cdb06 Mon Sep 17 00:00:00 2001 From: Andreas Hennings <andreas@dqxtech.net> Date: Fri, 6 Jun 2025 11:33:25 +0200 Subject: [PATCH 11/13] Refine the docs on ImplementationList, a little. --- core/lib/Drupal/Core/Hook/ImplementationList.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/lib/Drupal/Core/Hook/ImplementationList.php b/core/lib/Drupal/Core/Hook/ImplementationList.php index 9d2794661b19..688a54155693 100644 --- a/core/lib/Drupal/Core/Hook/ImplementationList.php +++ b/core/lib/Drupal/Core/Hook/ImplementationList.php @@ -7,7 +7,7 @@ /** * Contains the ordered list of implementations for a hook. * - * Also contains information about module names. + * Also contains a module name for each implementation. * * @internal */ @@ -38,7 +38,7 @@ public function __construct( * * @return \Iterator<string, callable> * Iterator of listeners by module. - * This allows the same module to occur more than once. + * This allows for the same module to occur more than once. */ public function iterateByModule(): \Iterator { foreach ($this->listeners as $index => $listener) { -- GitLab From b39f4987a25c5a4f576487538cec58ec3dcc36b3 Mon Sep 17 00:00:00 2001 From: Andreas Hennings <andreas@dqxtech.net> Date: Fri, 6 Jun 2025 22:02:50 +0200 Subject: [PATCH 12/13] Enhance comments around ModuleHandler::add(), mention how it is destructive. --- .../lib/Drupal/Core/Extension/ModuleHandler.php | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/core/lib/Drupal/Core/Extension/ModuleHandler.php b/core/lib/Drupal/Core/Extension/ModuleHandler.php index af276747e4e6..fa60b827ad27 100644 --- a/core/lib/Drupal/Core/Extension/ModuleHandler.php +++ b/core/lib/Drupal/Core/Extension/ModuleHandler.php @@ -209,8 +209,8 @@ public function addProfile($name, $path) { * The module path; e.g., 'core/modules/node'. * * @deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. - * There is no direct replacement. - * @see https://www.drupal.org/node/3491200 + * There is no direct replacement. + * @see https://www.drupal.org/node/3491200 */ protected function add($type, $name, $path) { $pathname = "$path/$name.info.yml"; @@ -227,7 +227,17 @@ protected function add($type, $name, $path) { // Load all includes so the legacy section of invoke can handle hooks in // includes. $hook_collector->loadAllIncludes(); - // Register procedural implementations. + // Register procedural implementations from the new module. + // This is done for BC reasons, but it has some limitations: + // - OOP implementations from the new module are not added. + // This is because the services won't be available yet. + // - New implementations are all appended to the list, no ordering is + // applied. + // - Procedural implementations that call \Drupal::service() may find the + // requested service to be missing, if the added module was not properly + // installed. + // @todo Calling ->add() suppresses implementations from other modules. + // See https://www.drupal.org/project/drupal/issues/3528899. foreach ($hook_collector->getImplementations() as $hook => $moduleImplements) { $list = $this->hookImplementationLists[$hook] ?? NULL; $listeners = $list?->listeners ?? []; @@ -238,7 +248,6 @@ protected function add($type, $name, $path) { $modules[] = $module; } } - // @todo Order the listeners after adding. if ($listeners) { $this->hookImplementationLists[$hook] = new ImplementationList($listeners, $modules); } -- GitLab From 121d86fb70ab8ad52076b1a76b4af9912f96ede8 Mon Sep 17 00:00:00 2001 From: Andreas Hennings <andreas@dqxtech.net> Date: Fri, 6 Jun 2025 23:10:32 +0200 Subject: [PATCH 13/13] Add comment in ImplementationList::getForModule(). --- core/lib/Drupal/Core/Hook/ImplementationList.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/core/lib/Drupal/Core/Hook/ImplementationList.php b/core/lib/Drupal/Core/Hook/ImplementationList.php index 688a54155693..0dee1d54642e 100644 --- a/core/lib/Drupal/Core/Hook/ImplementationList.php +++ b/core/lib/Drupal/Core/Hook/ImplementationList.php @@ -56,6 +56,8 @@ public function iterateByModule(): \Iterator { * Listeners for that module. */ public function getForModule(string $module): array { + // Find indices where $this->modules[$i] === $module. + // Then get the listeners for these indices. return array_values(array_intersect_key( $this->listeners, array_intersect($this->modules, [$module]), -- GitLab