ThemeHookCollectorPass.php
Same filename and directory in other branches
Namespace
Drupal\Core\HookFile
-
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\Component\Utility\OpCodeCache;
use Drupal\Core\Hook\Attribute\Hook;
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 Drupal\Core\Hook\Attribute\ExtensionFileIsConverted;
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 temporary .theme_hook_data container parameter is added. This
* contains:
* - theme_hook_list a mapping from theme to [hook,class,method].
* - theme_preprocess_for_suggestions preprocess hooks with double underscores.
*
* The parameter theme_hook_data is processed in HookCollectorKeyValueWritePass
* and removed automatically.
*
* @internal
*/
class ThemeHookCollectorPass extends HookCollectorBase 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;
/**
* Deprecated .theme files.
*
* These are stored to allow emitting deprecation messages.
*
* @var array<string, true>
*/
protected array $deprecatedThemeFiles = [];
/**
* 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 ?? [],
]);
foreach ($this->deprecatedThemeFiles as $deprecatedThemeFile => $v) {
@trigger_error('Using ' . $deprecatedThemeFile . '.theme is deprecated in drupal:11.5.0 and is removed from drupal:13.0.0. Use classes instead. See https://www.drupal.org/node/3581222', E_USER_DEPRECATED);
}
}
/**
* 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] = 'theme';
}
}
}
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);
foreach ($this->getHookFileIterator($dir, [
"{$theme}.theme",
'theme-settings.php',
]) as $fileinfo) {
assert($fileinfo instanceof \SplFileInfo);
$fileExtension = $fileinfo->getExtension();
$filename = $fileinfo->getPathname();
$isThemeSettings = str_ends_with($filename, 'theme-settings.php');
if ($fileExtension === 'theme') {
$this->deprecatedThemeFiles[pathinfo($filename, PATHINFO_FILENAME)] = TRUE;
}
if ($isThemeSettings) {
@trigger_error('Using a theme-settings.php file in the ' . $theme . ' theme is deprecated in drupal:11.5.0 and is removed from drupal:12.0.0. Convert the hooks to use attributes or move to the .theme file. See https://www.drupal.org/node/3587273', E_USER_DEPRECATED);
}
if ($fileExtension === 'php' && !$isThemeSettings) {
$cached = $hookFileCache->get($filename);
if ($cached) {
$class = $cached['class'];
$attributes = $cached['attributes'];
}
else {
// Immediately after a deployment, opcache may not yet have refreshed
// depending on the value of opcache.revalidation_freq which defaults
// to 2 seconds. Ensure that reflection operates on the new code by
// 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}/", substr($fileinfo->getPath(), strlen($dir) + 1));
$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 = [
'hooks' => [],
];
foreach ($parser->getMethodAttributes() as $function => $attributes) {
if (StaticReflectionParser::hasAttribute($attributes, ExtensionFileIsConverted::class)) {
$implementations['@skip_theme_file_deprecation'] = TRUE;
}
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['hooks'][] = [
'theme' => $matches['theme'],
'hook' => $matches['hook'],
];
}
}
$proceduralHookFileCache->set($filename, $implementations);
}
if (isset($implementations['@skip_theme_file_deprecation'])) {
unset($this->deprecatedThemeFiles[pathinfo($filename, PATHINFO_FILENAME)]);
}
foreach ($implementations['hooks'] 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;
}
/**
* 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}.");
}
}
}
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.