diff --git a/core/lib/Drupal/Core/Hook/Attribute/Hook.php b/core/lib/Drupal/Core/Hook/Attribute/Hook.php index 822f7ba013e..46584a13838 100644 --- a/core/lib/Drupal/Core/Hook/Attribute/Hook.php +++ b/core/lib/Drupal/Core/Hook/Attribute/Hook.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace Drupal\Core\Hook\Attribute; +use Drupal\Core\Hook\Order\OrderInterface; + /** * This class will not have an effect until Drupal 11.1.0. * @@ -12,13 +14,29 @@ namespace Drupal\Core\Hook\Attribute; * #LegacyHook attributes. */ #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] -class Hook { +class Hook implements HookAttributeInterface { + /** + * The hook prefix such as `form`. + * + * @var string + */ + public const string PREFIX = ''; + + /** + * The hook suffix such as `alter`. + * + * @var string + */ + public const string SUFFIX = ''; /** * Constructs a Hook attribute object. * * @param string $hook * The short hook name, without the 'hook_' prefix. + * $hook is only optional when Hook is extended and a PREFIX or SUFFIX is + * defined. When using the [#Hook] attribute directly $hook is required. + * See Drupal\Core\Hook\Attribute\Preprocess. * @param string $method * (optional) The method name. If this attribute is on a method, this * parameter is not required. If this attribute is on a class and this @@ -30,16 +48,26 @@ class Hook { * are executed first. If omitted, the module order is used to order the * hook implementations. * @param string|null $module - * (optional) The module this implementation is for. This allows one module to - * implement a hook on behalf of another module. Defaults to the module the - * implementation is in. + * (optional) The module this implementation is for. This allows one module + * to implement a hook on behalf of another module. Defaults to the module + * the implementation is in. + * @param \Drupal\Core\Hook\Order\OrderInterface|null $order + * (optional) Set the order of the implementation. This parameter is + * supported in Drupal 11.2 and greater. It will have no affect in Drupal + * 11.1. */ public function __construct( - public string $hook, + public string $hook = '', public string $method = '', public ?int $priority = NULL, public ?string $module = NULL, - ) {} + public OrderInterface|null $order = NULL, + ) { + $this->hook = implode('_', array_filter([static::PREFIX, $hook, static::SUFFIX])); + if ($this->hook === '') { + throw new \LogicException('The Hook attribute or an attribute extending the Hook attribute must provide the $hook parameter, a PREFIX or a SUFFIX.'); + } + } /** * Set the method the hook should apply to. diff --git a/core/lib/Drupal/Core/Hook/Attribute/HookAttributeInterface.php b/core/lib/Drupal/Core/Hook/Attribute/HookAttributeInterface.php new file mode 100644 index 00000000000..8a2f2413b20 --- /dev/null +++ b/core/lib/Drupal/Core/Hook/Attribute/HookAttributeInterface.php @@ -0,0 +1,15 @@ + $modules + * A list of modules the implementations should order against. + * @param list $classesAndMethods + * A list of implementations to order against, as [$class, $method]. + */ + public function __construct( + public array $modules = [], + public array $classesAndMethods = [], + ) { + if (!$this->modules && !$this->classesAndMethods) { + throw new \LogicException('Order must provide either modules or class-method pairs to order against.'); + } + } + + /** + * Specifies the ordering direction. + * + * @return bool + * TRUE, if the ordered implementation should be inserted after the + * implementations specified in the constructor. + */ + abstract protected function isAfter(): bool; + + /** + * {@inheritdoc} + */ + public function getOperation(string $identifier): OrderOperation { + return new BeforeOrAfter( + $identifier, + $this->modules, + array_map( + static fn(array $class_and_method) => implode('::', $class_and_method), + $this->classesAndMethods, + ), + $this->isAfter(), + ); + } + +} diff --git a/core/lib/Drupal/Core/Hook/OrderOperation/BeforeOrAfter.php b/core/lib/Drupal/Core/Hook/OrderOperation/BeforeOrAfter.php new file mode 100644 index 00000000000..e87f661fc39 --- /dev/null +++ b/core/lib/Drupal/Core/Hook/OrderOperation/BeforeOrAfter.php @@ -0,0 +1,81 @@ + $modulesToOrderAgainst + * Module names of listeners to order against. + * @param list $identifiersToOrderAgainst + * Identifiers of listeners to order against. + * The format is "$class::$method". + * @param bool $isAfter + * TRUE, if the listener to move should be moved after the listener to order + * against, FALSE if it should be moved before. + */ + public function __construct( + protected readonly string $identifier, + protected readonly array $modulesToOrderAgainst, + protected readonly array $identifiersToOrderAgainst, + protected readonly bool $isAfter, + ) {} + + /** + * {@inheritdoc} + */ + public function apply(array &$identifiers, array $module_finder): void { + assert(array_is_list($identifiers)); + $index = array_search($this->identifier, $identifiers); + if ($index === FALSE) { + // Nothing to reorder. + return; + } + $identifiers_to_order_against = $this->identifiersToOrderAgainst; + if ($this->modulesToOrderAgainst) { + $identifiers_to_order_against = [ + ...$identifiers_to_order_against, + ...array_keys(array_intersect($module_finder, $this->modulesToOrderAgainst)), + ]; + } + $indices_to_order_against = array_keys(array_intersect($identifiers, $identifiers_to_order_against)); + if ($indices_to_order_against === []) { + return; + } + if ($this->isAfter) { + $max_index_to_order_against = max($indices_to_order_against); + if ($index >= $max_index_to_order_against) { + // The element is already after the other elements. + return; + } + array_splice($identifiers, $max_index_to_order_against + 1, 0, $this->identifier); + // Remove the element after splicing. + unset($identifiers[$index]); + $identifiers = array_values($identifiers); + } + else { + $min_index_to_order_against = min($indices_to_order_against); + if ($index <= $min_index_to_order_against) { + // The element is already before the other elements. + return; + } + // Remove the element before splicing. + unset($identifiers[$index]); + array_splice($identifiers, $min_index_to_order_against, 0, $this->identifier); + } + } + +} diff --git a/core/lib/Drupal/Core/Hook/OrderOperation/FirstOrLast.php b/core/lib/Drupal/Core/Hook/OrderOperation/FirstOrLast.php new file mode 100644 index 00000000000..2169e533891 --- /dev/null +++ b/core/lib/Drupal/Core/Hook/OrderOperation/FirstOrLast.php @@ -0,0 +1,48 @@ +identifier, $identifiers); + if ($index === FALSE) { + // The element does not exist. + return; + } + unset($identifiers[$index]); + if ($this->isLast) { + $identifiers[] = $this->identifier; + } + else { + $identifiers = [$this->identifier, ...$identifiers]; + } + $identifiers = array_values($identifiers); + } + +} diff --git a/core/lib/Drupal/Core/Hook/OrderOperation/OrderOperation.php b/core/lib/Drupal/Core/Hook/OrderOperation/OrderOperation.php new file mode 100644 index 00000000000..329d850481c --- /dev/null +++ b/core/lib/Drupal/Core/Hook/OrderOperation/OrderOperation.php @@ -0,0 +1,55 @@ + $identifiers + * Hook implementation identifiers, as "$class::$method", to be changed by + * reference. + * The order operation must make sure that the array remains a list, and + * that the values are the same as before. + * @param array $module_finder + * Lookup map to find a module name for each implementation. + * This may contain more entries than $identifiers. + */ + abstract public function apply(array &$identifiers, array $module_finder): void; + + /** + * Converts the operation to a structure that can be stored in the container. + * + * @return array + * Packed operation. + */ + final public function pack(): array { + $is_before_or_after = match(get_class($this)) { + BeforeOrAfter::class => TRUE, + FirstOrLast::class => FALSE, + }; + return [$is_before_or_after, get_object_vars($this)]; + } + + /** + * Converts the stored operation to objects that can apply ordering rules. + * + * @param array $packed_operation + * Packed operation. + * + * @return self + * Unpacked operation. + */ + final public static function unpack(array $packed_operation): self { + [$is_before_or_after, $args] = $packed_operation; + $class = $is_before_or_after ? BeforeOrAfter::class : FirstOrLast::class; + return new $class(...$args); + } + +}