ThemeHookCollectorPass.php

Namespace

Drupal\Core\Hook

File

core/lib/Drupal/Core/Hook/ThemeHookCollectorPass.php

View source
<?php

declare (strict_types=1);
namespace Drupal\Core\Hook;

use Drupal\Component\Annotation\Doctrine\StaticReflectionParser;
use Drupal\Component\Annotation\Reflection\MockFileFinder;
use Drupal\Component\FileCache\FileCacheFactory;
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;
use Drupal\Core\Hook\Attribute\ReorderHook;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;

/**
 * Collects and registers hook implementations.
 *
 * A hook implementation is a class in a Drupal\themename\Hook namespace
 * where either the class itself or the methods have a #[Hook] attribute.
 * These classes are automatically registered as autowired services.
 *
 * Finally, a .theme_hook_data container parameter is added. This
 * contains a mapping from [hook,class,method] to the theme name.
 *
 * @internal
 */
class ThemeHookCollectorPass implements CompilerPassInterface {
  
  /**
   * OOP implementation theme names keyed by hook name and "$class::$method".
   *
   * @var array<string, array<string, string>>
   */
  protected array $oopImplementations = [];
  
  /**
   * Procedural implementation extension names by hook name.
   *
   * @var array<string, list<string>>
   */
  protected array $proceduralImplementations = [];
  
  /**
   * Preprocess suggestions discovered in extensions.
   *
   * These are stored to prevent adding preprocess suggestions to the invoke map
   * that are not discovered in extensions.
   *
   * @var array<string, true>
   */
  protected array $preprocessForSuggestions;
  
  /**
   * Constructor.
   *
   * @param list<string> $themes
   *   Names of installed themes.
   *   When used as a compiler pass, this parameter should be omitted.
   */
  public function __construct(protected readonly array $themes = []) {
  }
  
  /**
   * {@inheritdoc}
   */
  public function process(ContainerBuilder $container) : void {
    $collectorThemes = static::collectAllHookImplementations($container);
    $collectorThemes->writeToContainer($container);
  }
  
  /**
   * Writes collected definitions to the container builder.
   *
   * @param \Symfony\Component\DependencyInjection\ContainerBuilder $container
   *   Container builder.
   */
  protected function writeToContainer(ContainerBuilder $container) : void {
    $implementationsByHook = $this->calculateImplementations();
    static::registerHookServices($container, $implementationsByHook);
    // Write aggregated data about hooks into a temporary parameter.
    // We use a dot prefixed parameter so it will automatically get cleaned up.
    $container->setParameter('.theme_hook_data', [
      'theme_hook_list' => $this->sortByTheme($implementationsByHook),
      'theme_preprocess_for_suggestions' => $this->preprocessForSuggestions ?? [],
    ]);
  }
  
  /**
   * Sort by theme.
   *
   * @param array<string, array<string, string>> $implementationsByHook
   *   Implementations, as theme names keyed by hook name and
   *   "$class::$method" identifier.
   *
   * @return array<string, array<string, list>>
   *   Implementations, as theme names keyed by theme, hook name and
   *   "$class::$method" identifier.
   */
  protected function sortByTheme(array $implementationsByHook) {
    $implementationsByTheme = [];
    foreach ($implementationsByHook as $hook => $identifiers) {
      foreach ($identifiers as $identifier => $theme) {
        $implementationsByTheme[$theme][$hook][] = $identifier;
      }
    }
    return $implementationsByTheme;
  }
  
  /**
   * Calculates the ordered implementations.
   *
   * @return array<string, array<string, string>>
   *   Implementations, as theme names keyed by hook name and
   *   "$class::$method" identifier.
   */
  protected function calculateImplementations() : array {
    $implementationsByHookOrig = $this->getFilteredImplementations();
    // Store preprocess implementations for themes.
    foreach ($implementationsByHookOrig as $hook => $hookImplementations) {
      if (is_string($hook) && str_starts_with($hook, 'preprocess_') && str_contains($hook, '__')) {
        foreach ($hookImplementations as $theme) {
          $this->preprocessForSuggestions[$theme . '_' . $hook] = TRUE;
        }
      }
    }
    return $implementationsByHookOrig;
  }
  
  /**
   * Collects all hook implementations.
   *
   * @param \Symfony\Component\DependencyInjection\ContainerBuilder $container
   *   The container.
   *
   * @return static
   *   A ThemeHookCollectorPass instance holding all hook implementations and
   *   include file information.
   *
   * @internal
   */
  protected static function collectAllHookImplementations(ContainerBuilder $container) : static {
    $parameters = $container->getParameterBag()
      ->all();
    $themeList = $parameters['container.themes'];
    $skipProcedural = array_filter(array_keys($themeList), static fn(string $theme) => !empty($parameters["{$theme}.skip_procedural_hook_scan"]));
    $themes = array_keys($themeList);
    $allThemesPreg = static::getThemeListPattern($themes);
    $collector = new static($themes);
    foreach ($themeList as $theme => $info) {
      $shouldSkipProceduralScan = in_array($theme, $skipProcedural);
      $currentThemePreg = static::getThemeListPattern([
        $theme,
      ]);
      $collector->collectThemeHookImplementations(dirname($info['pathname']), $theme, $currentThemePreg, $allThemesPreg, $shouldSkipProceduralScan);
    }
    return $collector;
  }
  
  /**
   * Get a pattern used to match hooks for the given theme list.
   *
   * The supplied theme list will be sorted by length in descending order so
   * that longer names are matched first.
   *
   * @param list<string> $themeList
   *   A list of theme names.
   *
   * @return string
   *   The pattern used to match hooks for the given theme list.
   */
  protected static function getThemeListPattern(array $themeList) : string {
    usort($themeList, static fn($a, $b) => strlen($b) - strlen($a));
    $themePattern = implode('|', array_map(static fn($x) => preg_quote($x, '/'), $themeList));
    return '/^(?<function>(?<theme>' . $themePattern . ')_(?!update_\\d)(?<hook>[a-zA-Z0-9_\\x80-\\xff]+$))/';
  }
  
  /**
   * Collects procedural and Attribute hook implementations.
   *
   * @param string $dir
   *   The directory in which the theme resides.
   * @param string $theme
   *   The name of the theme.
   * @param string $currentThemePreg
   *   A regular expression matching only the theme being scanned.
   * @param string $allThemesPreg
   *   A regular expression matching every theme, longer theme names are
   *   matched first.
   * @param bool $shouldSkipProceduralScan
   *   Skip the procedural check for the current theme.
   */
  protected function collectThemeHookImplementations($dir, $theme, $currentThemePreg, $allThemesPreg, bool $shouldSkipProceduralScan) : void {
    $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) {
      assert($fileinfo instanceof \SplFileInfo);
      $fileExtension = $fileinfo->getExtension();
      $filename = $fileinfo->getPathname();
      if ($fileExtension === 'php') {
        $cached = $hookFileCache->get($filename);
        if ($cached) {
          $class = $cached['class'];
          $attributes = $cached['attributes'];
        }
        else {
          $namespace = preg_replace('#^src/#', "Drupal/{$theme}/", $iterator->getSubPath());
          $class = $namespace . '/' . $fileinfo->getBasename('.php');
          $class = str_replace('/', '\\', $class);
          $attributes = [];
          if (class_exists($class)) {
            $reflectionClass = new \ReflectionClass($class);
            $attributes = self::getAttributeInstances($reflectionClass);
            $hookFileCache->set($filename, [
              'class' => $class,
              'attributes' => $attributes,
            ]);
          }
        }
        foreach ($attributes as $method => $methodAttributes) {
          foreach ($methodAttributes as $attribute) {
            if ($attribute instanceof Hook) {
              self::checkInvalidHookParametersInThemes($attribute, $class);
              $this->oopImplementations[$attribute->hook][$class . '::' . ($attribute->method ?: $method)] = $theme;
            }
            elseif ($attribute instanceof RemoveHook) {
              throw new \LogicException("The #[RemoveHook] attribute is not allowed in themes. Found in {$class}.");
            }
            elseif ($attribute instanceof ReorderHook) {
              throw new \LogicException("The #[ReorderHook] attribute is not allowed in themes. Found in {$class}.");
            }
          }
        }
      }
      elseif (!$shouldSkipProceduralScan) {
        $implementations = $proceduralHookFileCache->get($filename);
        if ($implementations === NULL) {
          $finder = MockFileFinder::create($filename);
          $parser = new StaticReflectionParser('', $finder);
          $implementations = [];
          foreach ($parser->getMethodAttributes() as $function => $attributes) {
            if (StaticReflectionParser::hasAttribute($attributes, ProceduralHookScanStop::class)) {
              break;

            }
            if (!StaticReflectionParser::hasAttribute($attributes, LegacyHook::class) && (preg_match($currentThemePreg, $function, $matches) || preg_match($allThemesPreg, $function, $matches))) {
              assert($function === $matches['theme'] . '_' . $matches['hook']);
              $implementations[] = [
                'theme' => $matches['theme'],
                'hook' => $matches['hook'],
              ];
            }
          }
          $proceduralHookFileCache->set($filename, $implementations);
        }
        foreach ($implementations as $implementation) {
          $this->proceduralImplementations[$implementation['hook']][] = $implementation['theme'];
        }
      }
    }
  }
  
  /**
   * Gets implementation lists with removals already applied.
   *
   * @return array<string, list<string>>
   *   Implementations, as extension names keyed by hook name and
   *   "$class::$method".
   */
  protected function getFilteredImplementations() : array {
    $implementationsByHook = [];
    foreach ($this->proceduralImplementations as $hook => $proceduralThemes) {
      foreach ($proceduralThemes as $theme) {
        $implementationsByHook[$hook][$theme . '_' . $hook] = $theme;
      }
    }
    foreach ($this->oopImplementations as $hook => $oopImplementations) {
      if (!isset($implementationsByHook[$hook])) {
        $implementationsByHook[$hook] = $oopImplementations;
      }
      else {
        $implementationsByHook[$hook] += $oopImplementations;
      }
    }
    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()) {
      if ($subPathName === 'src' || $subPathName === 'src/Hook') {
        return TRUE;
      }
      // glob() doesn't support streams but scandir() does.
      return !in_array($fileInfo->getFilename(), [
        'tests',
        'js',
        'css',
        'templates',
      ]) && !array_filter(scandir($key), static fn($filename) => str_ends_with($filename, '.info.yml'));
    }
    return in_array($extension, [
      'inc',
      'theme',
    ]);
  }
  
  /**
   * Checks for hooks which can't be supported in theme classes.
   *
   * @param \Drupal\Core\Hook\Attribute\Hook $hookAttribute
   *   The hook to check.
   * @param class-string $class
   *   The class the hook is implemented on.
   */
  public static function checkInvalidHookParametersInThemes(Hook $hookAttribute, string $class) : void {
    // A theme cannot implement a hook on behalf of a module or other theme.
    if ($hookAttribute->module !== NULL) {
      throw new \LogicException("The 'module' parameter on the #[Hook] attribute is not allowed in themes. Found in {$class}.");
    }
    // A theme cannot alter the order of hook implementations.
    if ($hookAttribute->order !== NULL) {
      throw new \LogicException("The 'order' parameter on the #[Hook] attribute is not allowed in themes. Found in {$class}.");
    }
  }
  
  /**
   * 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;
  }

}

Classes

Title Deprecated Summary
ThemeHookCollectorPass Collects and registers hook implementations.

Buggy or inaccurate documentation? Please file an issue. Need support? Need help programming? Connect with the Drupal community.