Unverified Commit 7d62c7b7 authored by Alex Pott's avatar Alex Pott
Browse files

task: #3618955 Reduce necessary directory traversing in HookCollectorPass

By: berdir
By: longwave
By: nicxvan
parent 803373a7
Loading
Loading
Loading
Loading
+98 −0
Changes for core/lib/Drupal/Core/Hook/HookCollectorBase.php: 98 added lines, 0 removed lines.
Original line number Diff line number Diff line
<?php

declare(strict_types=1);

namespace Drupal\Core\Hook;

use Drupal\Core\Hook\Attribute\HookAttributeInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;

/**
 * Provides shared hook collection functionality.
 *
 * @internal
 */
abstract class HookCollectorBase {

  /**
   * Gets the files that can contain hook implementations.
   *
   * @param string $dir
   *   The extension directory.
   * @param list<string> $procedural_files
   *   The root-level procedural hook implementation files.
   *
   * @return \Generator<\SplFileInfo>
   *   The procedural hook files and PHP files in src/Hook.
   */
  protected function getHookFileIterator(string $dir, array $procedural_files): \Generator {
    foreach ($procedural_files as $filename) {
      $filename = "$dir/$filename";
      if (is_file($filename)) {
        yield new \SplFileInfo($filename);
      }
    }

    $hook_directory = "$dir/src/Hook";
    if (!is_dir($hook_directory)) {
      return;
    }
    $flags = \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::UNIX_PATHS | \FilesystemIterator::FOLLOW_SYMLINKS;
    $iterator = new \RecursiveDirectoryIterator($hook_directory, $flags);
    $iterator = new \RecursiveCallbackFilterIterator($iterator, static fn (\SplFileInfo $fileinfo): bool => $fileinfo->isDir() || $fileinfo->getExtension() === 'php');
    foreach (new \RecursiveIteratorIterator($iterator) as $fileinfo) {
      yield $fileinfo;
    }
  }

  /**
   * Registers hook implementation services.
   *
   * @param \Symfony\Component\DependencyInjection\ContainerBuilder $container
   *   The container builder.
   * @param array<string, array<string, string>> $implementations_by_hook
   *   Implementations keyed by hook name and identifier.
   */
  protected static function registerHookServices(ContainerBuilder $container, array $implementations_by_hook): void {
    $classes_map = [];
    foreach ($implementations_by_hook as $hook_implementations) {
      foreach (array_keys($hook_implementations) as $identifier) {
        $parts = explode('::', $identifier, 2);
        if (isset($parts[1])) {
          $classes_map[$parts[0]] = TRUE;
        }
      }
    }

    foreach (array_keys($classes_map) as $class) {
      if (!$container->hasDefinition($class)) {
        $container
          ->register($class, $class)
          ->setAutowired(TRUE);
      }
    }
  }

  /**
   * Gets attribute instances from a class reflection.
   *
   * @param \ReflectionClass $reflection_class
   *   The class reflection.
   *
   * @return array<string, list<\Drupal\Core\Hook\Attribute\HookAttributeInterface>>
   *   Attribute instances keyed by method name.
   */
  protected static function getAttributeInstances(\ReflectionClass $reflection_class): array {
    $attributes = [];
    $reflections = $reflection_class->getMethods(\ReflectionMethod::IS_PUBLIC);
    $reflections[] = $reflection_class;
    foreach ($reflections as $reflection) {
      if ($reflection_attributes = $reflection->getAttributes(HookAttributeInterface::class, \ReflectionAttribute::IS_INSTANCEOF)) {
        $method = $reflection instanceof \ReflectionMethod ? $reflection->getName() : '__invoke';
        $attributes[$method] = array_map(static fn (\ReflectionAttribute $reflection_attribute) => $reflection_attribute->newInstance(), $reflection_attributes);
      }
    }
    return $attributes;
  }

}
+11 −76
Changes for core/lib/Drupal/Core/Hook/HookCollectorPass.php: 11 added lines, 76 removed lines.
Original line number Diff line number Diff line
@@ -41,7 +41,14 @@
 *
 * @internal
 */
class HookCollectorPass implements CompilerPassInterface {
class HookCollectorPass extends HookCollectorBase implements CompilerPassInterface {

  /**
   * The extensions of procedural hook implementation files.
   *
   * @var string[]
   */
  protected array $proceduralFileExtensions = ['module', 'profile', 'install'];

  /**
   * OOP implementation module names keyed by hook name and "$class::$method".
@@ -255,38 +262,6 @@ protected static function applyOrderOperations(array &$implementation_list, arra
    $implementation_list = array_replace($identifiers, $module_finder);
  }

  /**
   * Registers the hook implementation services.
   *
   * @param \Symfony\Component\DependencyInjection\ContainerBuilder $container
   *   The container builder.
   * @param array<string, array<string, string>> $implementationsByHook
   *   Implementations, as module names keyed by hook name and "$class::$method"
   *   or $function identifier.
   */
  protected static function registerHookServices(
    ContainerBuilder $container,
    array $implementationsByHook,
  ): void {
    $classesMap = [];
    foreach ($implementationsByHook as $hookImplementations) {
      foreach (array_keys($hookImplementations) as $identifier) {
        $parts = explode('::', $identifier, 2);
        if (isset($parts[1])) {
          $classesMap[$parts[0]] = TRUE;
        }
      }
    }

    foreach (array_keys($classesMap) as $class) {
      if (!$container->hasDefinition($class)) {
        $container
          ->register($class, $class)
          ->setAutowired(TRUE);
      }
    }
  }

  /**
   * Collects all hook implementations.
   *
@@ -360,11 +335,8 @@ protected function collectModuleHookImplementations($dir, $module, $current_modu
    // Hash to prevent massive key sizes.
    $procedural_hook_file_cache = FileCacheFactory::get('procedural_hook_implementations:' . hash('xxh3', $all_modules_preg));

    $iterator = new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::UNIX_PATHS | \FilesystemIterator::FOLLOW_SYMLINKS);
    $iterator = new \RecursiveCallbackFilterIterator($iterator, static::filterIterator(...));
    $iterator = new \RecursiveIteratorIterator($iterator);
    /** @var \RecursiveDirectoryIterator | \RecursiveIteratorIterator $iterator*/
    foreach ($iterator as $fileinfo) {
    $procedural_files = array_map(fn (string $extension): string => "$module.$extension", $this->proceduralFileExtensions);
    foreach ($this->getHookFileIterator($dir, $procedural_files) as $fileinfo) {
      assert($fileinfo instanceof \SplFileInfo);
      $extension = $fileinfo->getExtension();
      $filename = $fileinfo->getPathname();
@@ -383,7 +355,7 @@ protected function collectModuleHookImplementations($dir, $module, $current_modu
          // forcibly invalidating the opcode cache.
          // @see https://www.php.net/manual/en/opcache.configuration.php#ini.opcache.revalidate-freq
          OpCodeCache::invalidate($filename);
          $namespace = preg_replace('#^src/#', "Drupal/$module/", $iterator->getSubPath());
          $namespace = preg_replace('#^src/#', "Drupal/$module/", substr($fileinfo->getPath(), strlen($dir) + 1));
          $class = $namespace . '/' . $fileinfo->getBasename('.php');
          $class = str_replace('/', '\\', $class);
          $class_attributes = [];
@@ -519,21 +491,6 @@ public static function hasAnyAttribute(array $existingAttributes, array $attribu
    return FALSE;
  }

  /**
   * Filter iterator callback. Allows include files and .php files in src/Hook.
   */
  protected static function filterIterator(\SplFileInfo $fileInfo, $key, \RecursiveDirectoryIterator $iterator): bool {
    $sub_path_name = $iterator->getSubPathname();
    $extension = $fileInfo->getExtension();
    if (str_starts_with($sub_path_name, 'src/Hook/')) {
      return $iterator->isDir() || $extension === 'php';
    }
    if ($iterator->isDir()) {
      return $sub_path_name === 'src' || $sub_path_name === 'src/Hook';
    }
    return in_array($extension, ['module', 'profile', 'install']);
  }

  /**
   * Adds a procedural hook implementation.
   *
@@ -579,26 +536,4 @@ public static function checkForProceduralOnlyHooks(Hook $hookAttribute, string $
    }
  }

  /**
   * Get attribute instances from class and method reflections.
   *
   * @param \ReflectionClass $reflectionClass
   *   A reflected class.
   *
   * @return array<string, list<\Drupal\Core\Hook\Attribute\HookAttributeInterface>>
   *   Lists of Hook attribute instances by method name.
   */
  protected static function getAttributeInstances(\ReflectionClass $reflectionClass): array {
    $attributes = [];
    $reflections = $reflectionClass->getMethods(\ReflectionMethod::IS_PUBLIC);
    $reflections[] = $reflectionClass;
    foreach ($reflections as $reflection) {
      if ($reflectionAttributes = $reflection->getAttributes(HookAttributeInterface::class, \ReflectionAttribute::IS_INSTANCEOF)) {
        $method = $reflection instanceof \ReflectionMethod ? $reflection->getName() : '__invoke';
        $attributes[$method] = array_map(static fn (\ReflectionAttribute $ra) => $ra->newInstance(), $reflectionAttributes);
      }
    }
    return $attributes;
  }

}
+3 −80
Changes for core/lib/Drupal/Core/Hook/ThemeHookCollectorPass.php: 3 added lines, 80 removed lines.
Original line number Diff line number Diff line
@@ -9,7 +9,6 @@
use Drupal\Component\FileCache\FileCacheFactory;
use Drupal\Component\Utility\OpCodeCache;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\Hook\Attribute\HookAttributeInterface;
use Drupal\Core\Hook\Attribute\LegacyHook;
use Drupal\Core\Hook\Attribute\RemoveHook;
use Drupal\Core\Hook\Attribute\ProceduralHookScanStop;
@@ -35,7 +34,7 @@
 *
 * @internal
 */
class ThemeHookCollectorPass implements CompilerPassInterface {
class ThemeHookCollectorPass extends HookCollectorBase implements CompilerPassInterface {

  /**
   * OOP implementation theme names keyed by hook name and "$class::$method".
@@ -227,11 +226,7 @@ protected function collectThemeHookImplementations($dir, $theme, $currentThemePr
    $hookFileCache = FileCacheFactory::get('theme_hook_implementations');
    $proceduralHookFileCache = FileCacheFactory::get('theme_procedural_hook_implementations:' . $allThemesPreg);

    $iterator = new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::UNIX_PATHS | \FilesystemIterator::FOLLOW_SYMLINKS);
    $iterator = new \RecursiveCallbackFilterIterator($iterator, static::filterIterator(...));
    $iterator = new \RecursiveIteratorIterator($iterator);
    /** @var \RecursiveDirectoryIterator | \RecursiveIteratorIterator $iterator*/
    foreach ($iterator as $fileinfo) {
    foreach ($this->getHookFileIterator($dir, ["$theme.theme", 'theme-settings.php']) as $fileinfo) {
      assert($fileinfo instanceof \SplFileInfo);
      $fileExtension = $fileinfo->getExtension();
      $filename = $fileinfo->getPathname();
@@ -259,7 +254,7 @@ protected function collectThemeHookImplementations($dir, $theme, $currentThemePr
          // forcibly invalidating the opcode cache.
          // @see https://www.php.net/manual/en/opcache.configuration.php#ini.opcache.revalidate-freq
          OpCodeCache::invalidate($filename);
          $namespace = preg_replace('#^src/#', "Drupal/$theme/", $iterator->getSubPath());
          $namespace = preg_replace('#^src/#', "Drupal/$theme/", substr($fileinfo->getPath(), strlen($dir) + 1));
          $class = $namespace . '/' . $fileinfo->getBasename('.php');
          $class = str_replace('/', '\\', $class);
          $attributes = [];
@@ -342,56 +337,6 @@ protected function getFilteredImplementations(): array {
    return $implementationsByHook;
  }

  /**
   * Registers the hook implementation services.
   *
   * @param \Symfony\Component\DependencyInjection\ContainerBuilder $container
   *   The container builder.
   * @param array<string, array<string, string>> $implementationsByHook
   *   Implementations, as module names keyed by hook name and "$class::$method"
   *   or $function identifier.
   */
  protected static function registerHookServices(
    ContainerBuilder $container,
    array $implementationsByHook,
  ): void {
    $classesMap = [];
    foreach ($implementationsByHook as $hookImplementations) {
      foreach (array_keys($hookImplementations) as $identifier) {
        $parts = explode('::', $identifier, 2);
        if (isset($parts[1])) {
          $classesMap[$parts[0]] = TRUE;
        }
      }
    }

    foreach (array_keys($classesMap) as $class) {
      if (!$container->hasDefinition($class)) {
        $container
          ->register($class, $class)
          ->setAutowired(TRUE);
      }
    }
  }

  /**
   * Filter iterator callback. Allows include files and .php files in src/Hook.
   */
  protected static function filterIterator(\SplFileInfo $fileInfo, $key, \RecursiveDirectoryIterator $iterator): bool {
    $subPathName = $iterator->getSubPathname();
    $extension = $fileInfo->getExtension();
    if (str_starts_with($subPathName, 'src/Hook/')) {
      return $iterator->isDir() || $extension === 'php';
    }
    if ($iterator->isDir()) {
      return $subPathName === 'src' || $subPathName === 'src/Hook';
    }
    if ($fileInfo->getFilename() === 'theme-settings.php') {
      return TRUE;
    }
    return in_array($extension, ['theme']);
  }

  /**
   * Checks for hooks which can't be supported in theme classes.
   *
@@ -411,26 +356,4 @@ public static function checkInvalidHookParametersInThemes(Hook $hookAttribute, s
    }
  }

  /**
   * Get attribute instances from class and method reflections.
   *
   * @param \ReflectionClass $reflectionClass
   *   A reflected class.
   *
   * @return array<string, list<\Drupal\Core\Hook\Attribute\HookAttributeInterface>>
   *   Lists of Hook attribute instances by method name.
   */
  protected static function getAttributeInstances(\ReflectionClass $reflectionClass): array {
    $attributes = [];
    $reflections = $reflectionClass->getMethods(\ReflectionMethod::IS_PUBLIC);
    $reflections[] = $reflectionClass;
    foreach ($reflections as $reflection) {
      if ($reflectionAttributes = $reflection->getAttributes(HookAttributeInterface::class, \ReflectionAttribute::IS_INSTANCEOF)) {
        $method = $reflection instanceof \ReflectionMethod ? $reflection->getName() : '__invoke';
        $attributes[$method] = array_map(static fn(\ReflectionAttribute $ra) => $ra->newInstance(), $reflectionAttributes);
      }
    }
    return $attributes;
  }

}
+48 −1
Changes for core/tests/Drupal/Tests/Core/Hook/HookCollectorPassTest.php: 48 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -6,6 +6,7 @@

use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Extension\ProceduralCall;
use Drupal\Core\Hook\HookCollectorBase;
use Drupal\Core\Hook\HookCollectorPass;
use Drupal\Tests\UnitTestCase;
use org\bovigo\vfs\vfsStream;
@@ -24,7 +25,7 @@ class HookCollectorPassTest extends UnitTestCase {
   * Tests collect all hook implementations.
   *
   * @legacy-covers ::collectAllHookImplementations
   * @legacy-covers ::filterIterator
   * @legacy-covers ::getHookFileIterator
   */
  public function testCollectAllHookImplementations(): void {
    vfsStream::setup('drupal_root');
@@ -67,6 +68,52 @@ function test_module_should_be_skipped();
    );
  }

  public function testCollectsOnlyRootProceduralAndHookDirectoryFiles(): void {
    vfsStream::setup('drupal_root');
    vfsStream::create([
      'test_module' => [
        'test_module.module' => '',
        'test_module.install' => '',
        'test_module.profile' => '',
        'another_module.module' => '',
        'ignored.php' => '',
        'src' => [
          'Hook' => [
            'TestHooks.php' => '',
            'Subdirectory' => ['NestedHooks.php' => ''],
          ],
          'Other' => ['IgnoredHooks.php' => ''],
        ],
        'ignored_directory' => ['ignored.module' => ''],
      ],
    ]);

    $collector = new HookCollectorPass();
    $get_files = \Closure::bind(
      static function (HookCollectorPass $collector, string $directory, string $module): array {
        $procedural_files = [
          "$module.module",
          "$module.profile",
          "$module.install",
        ];
        return iterator_to_array($collector->getHookFileIterator($directory, $procedural_files));
      },
      NULL,
      HookCollectorBase::class,
    );
    $files = $get_files($collector, 'vfs://drupal_root/test_module', 'test_module');
    $paths = array_map(static fn (\SplFileInfo $file): string => $file->getPathname(), $files);
    sort($paths);

    $this->assertSame([
      'vfs://drupal_root/test_module/src/Hook/Subdirectory/NestedHooks.php',
      'vfs://drupal_root/test_module/src/Hook/TestHooks.php',
      'vfs://drupal_root/test_module/test_module.install',
      'vfs://drupal_root/test_module/test_module.module',
      'vfs://drupal_root/test_module/test_module.profile',
    ], $paths);
  }

  /**
   * Tests prefix ownership of procedural hooks.
   *