*/ private $handlers = []; /** @var array handler name and their handler objects */ private $handlerObjects = []; /** @var HookRegistry */ private $registry; /** * Handlers registered by calling register(). * @var array */ private $extraHandlers = []; /** @var ObjectFactory */ private $objectFactory; /** @var int The next ID to be used by scopedRegister() */ private $nextScopedRegisterId = 0; public function __construct( HookRegistry $hookRegistry, ObjectFactory $objectFactory ) { $this->registry = $hookRegistry; $this->objectFactory = $objectFactory; } /** * Salvage the state of HookContainer by retaining existing handler objects * and hooks registered via HookContainer::register(). Necessary in the event * that MediaWikiServices::resetGlobalInstance() is called after hooks have already * been registered. * * @param HookContainer|SalvageableService $other The object to salvage state from. $other be * of type HookContainer */ public function salvage( SalvageableService $other ) { Assert::parameterType( self::class, $other, '$other' ); if ( $this->handlers || $this->handlerObjects || $this->extraHandlers ) { throw new LogicException( 'salvage() must be called immediately after construction' ); } $this->handlerObjects = $other->handlerObjects; $this->handlers = $other->handlers; $this->extraHandlers = $other->extraHandlers; } /** * Call registered hook functions through either the legacy $wgHooks or extension.json * * For the given hook, fetch the array of handler objects and * process them. Determine the proper callback for each hook and * then call the actual hook using the appropriate arguments. * Finally, process the return value and return/throw accordingly. * * @param string $hook Name of the hook * @param array $args Arguments to pass to hook handler * @param array $options options map: * - abortable: (bool) If false, handlers will not be allowed to abort the call sequence. * An exception will be raised if a handler returns anything other than true or null. * - deprecatedVersion: (string) Version of MediaWiki this hook was deprecated in. For supporting * Hooks::run() legacy $deprecatedVersion parameter. New core code should add deprecated * hooks to the DeprecatedHooks::$deprecatedHooks array literal. New extension code should * use the DeprecatedHooks attribute. * - silent: (bool) If true, do not raise a deprecation warning * - noServices: (bool) If true, do not allow hook handlers with service dependencies * @return bool True if no handler aborted the hook * @throws UnexpectedValueException if handlers return an invalid value */ public function run( string $hook, array $args = [], array $options = [] ): bool { $checkDeprecation = isset( $options['deprecatedVersion'] ); $abortable = $options['abortable'] ?? true; foreach ( $this->getHandlers( $hook, $options ) as $handler ) { if ( $checkDeprecation ) { $this->checkDeprecation( $hook, $handler, $options ); } // Compose callback arguments. if ( !empty( $handler['args'] ) ) { $callbackArgs = array_merge( $handler['args'], $args ); } else { $callbackArgs = $args; } // Call the handler. $callback = $handler['callback']; $return = $callback( ...$callbackArgs ); // Handler returned false, signal abort to caller if ( $return === false ) { if ( !$abortable ) { throw new UnexpectedValueException( "Handler {$handler['functionName']}" . " return false for unabortable $hook." ); } return false; } elseif ( $return !== null && $return !== true ) { throw new UnexpectedValueException( "Hook handlers can only return null or a boolean. Got an unexpected value from " . "handler {$handler['functionName']} for $hook" ); } } return true; } /** * Clear handlers of the given hook. * This is intended for use while testing and will fail if MW_PHPUNIT_TEST * and MW_PARSER_TEST are not defined. * * @param string $hook Name of hook to clear * * @internal For testing only * @codeCoverageIgnore */ public function clear( string $hook ): void { if ( !defined( 'MW_PHPUNIT_TEST' ) && !defined( 'MW_PARSER_TEST' ) ) { throw new LogicException( 'Cannot reset hooks in operation.' ); } $this->handlers[$hook] = []; } /** * Register hook and handler, allowing for easy removal. * Intended for use in temporary registration e.g. testing * * @param string $hook Name of hook * @param callable|string|array $handler Handler to attach * @return ScopedCallback */ public function scopedRegister( string $hook, $handler ): ScopedCallback { $handler = $this->normalizeHandler( $hook, $handler ); if ( !$handler ) { throw new InvalidArgumentException( 'Bad hook handler!' ); } $this->checkDeprecation( $hook, $handler ); $id = 'TemporaryHook_' . $this->nextScopedRegisterId++; $this->getHandlers( $hook ); $this->handlers[$hook][$id] = $handler; return new ScopedCallback( function () use ( $hook, $id ) { unset( $this->handlers[$hook][$id] ); } ); } /** * Returns a callable array based on the handler specification provided. * This will find the appropriate handler object to call a method on, * This will find the appropriate handler object to call a method on, * instantiating it if it doesn't exist yet. * * @param string $hook The name of the hook the handler was registered for * @param array $handler A hook handler specification as given in an extension.json file. * @param array $options Options to apply. If the 'noServices' option is set and the * handler requires service injection, this method will throw an * UnexpectedValueException. * * @return array */ private function makeExtensionHandlerCallback( string $hook, array $handler, array $options = [] ): array { $spec = $handler['handler']; $name = $spec['name']; if ( !empty( $options['noServices'] ) && ( !empty( $spec['services'] ) || !empty( $spec['optional_services'] ) ) ) { throw new UnexpectedValueException( "The handler for the hook $hook registered in " . "{$handler['extensionPath']} has a service dependency, " . "but this hook does not allow it." ); } if ( !isset( $this->handlerObjects[$name] ) ) { // @phan-suppress-next-line PhanTypeInvalidCallableArraySize $this->handlerObjects[$name] = $this->objectFactory->createObject( $spec ); } $obj = $this->handlerObjects[$name]; $method = $this->getHookMethodName( $hook ); return [ $obj, $method ]; } /** * Normalize/clean up format of argument passed as hook handler * * @param string $hook Hook name * @param string|array|callable $handler Executable handler function. See register() for supported structures. * @param array $options see makeExtensionHandlerCallback() * * @return array|false * - callback: (callable) Executable handler function * - functionName: (string) Handler name for passing to wfDeprecated() or Exceptions thrown * - args: (array) Extra handler function arguments (omitted when not needed) */ private function normalizeHandler( string $hook, $handler, array $options = [] ) { if ( is_object( $handler ) && !$handler instanceof Closure ) { $handler = [ $handler, $this->getHookMethodName( $hook ) ]; } // Backwards compatibility with old-style callable that uses a qualified method name. if ( is_array( $handler ) && is_object( $handler[0] ?? false ) && is_string( $handler[1] ?? false ) ) { $ofs = strpos( $handler[1], '::' ); if ( $ofs !== false ) { $msg = self::callableToString( $handler ); wfDeprecatedMsg( "Deprecated handler style for hook '$hook': " . "callable using qualified method name ($msg)" ); $handler[1] = substr( $handler[1], $ofs + 2 ); } } // Backwards compatibility: support objects wrapped in an array but no method name. if ( is_array( $handler ) && is_object( $handler[0] ?? false ) && !isset( $handler[1] ) ) { if ( !$handler[0] instanceof Closure ) { $handler[1] = $this->getHookMethodName( $hook ); $msg = self::callableToString( $handler ); wfDeprecatedMsg( "Deprecated handler style for hook '$hook': object wrapped in array ($msg)" ); } } // The empty callback is used to represent a no-op handler in some test cases. if ( $handler === [] || $handler === null || $handler === false || $handler === self::NOOP ) { if ( $handler !== self::NOOP ) { wfDeprecatedMsg( "Deprecated handler style for hook '$hook': falsy value, use HookContainer::NOOP instead." ); } return [ 'callback' => static function () { // no-op }, 'functionName' => self::NOOP, ]; } // Plain callback if ( self::mayBeCallable( $handler ) ) { return [ 'callback' => $handler, 'functionName' => self::callableToString( $handler ), ]; } // Not callable and not an array. Something is wrong. if ( !is_array( $handler ) ) { return false; } // Empty array or array filled with null/false/empty. if ( !array_filter( $handler ) ) { return false; } // ExtensionRegistry style handler if ( isset( $handler['handler'] ) ) { // Skip hooks that both acknowledge deprecation and are deprecated in core if ( $handler['deprecated'] ?? false ) { $deprecatedHooks = $this->registry->getDeprecatedHooks(); $deprecated = $deprecatedHooks->isHookDeprecated( $hook ); if ( $deprecated ) { return false; } } $callback = $this->makeExtensionHandlerCallback( $hook, $handler, $options ); return [ 'callback' => $callback, 'functionName' => self::callableToString( $callback ), ]; } // Not an indexed array, something is wrong. if ( !isset( $handler[0] ) ) { return false; } // Backwards compatibility: support for arrays of the form [ $object, $method, $data... ] if ( is_object( $handler[0] ) && is_string( $handler[1] ?? false ) && array_key_exists( 2, $handler ) ) { $obj = $handler[0]; if ( !$obj instanceof Closure ) { // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset $method = $handler[1]; $handler = array_merge( [ [ $obj, $method ] ], array_slice( $handler, 2 ) ); $msg = self::callableToString( $handler[1] ); wfDeprecatedMsg( "Deprecated handler style for hook '$hook': callable array with extra data ($msg)" ); } } // The only option left is an array of the form [ $callable, $data... ] $callback = array_shift( $handler ); // Backwards-compatibility for callbacks in the form [ [ $function ], $data ] if ( is_array( $callback ) && count( $callback ) === 1 && is_string( $callback[0] ?? null ) ) { $callback = $callback[0]; $msg = self::callableToString( $callback ); wfDeprecatedMsg( "Deprecated handler style for hook '$hook': function wrapped in array ($msg)" ); } if ( !self::mayBeCallable( $callback ) ) { return false; } $msg = self::callableToString( $callback ); wfDeprecatedMsg( "Deprecated handler style for hook '$hook': callable wrapped in array ($msg)" ); return [ 'callback' => $callback, 'functionName' => self::callableToString( $callback ), 'args' => $handler, ]; } /** * Return whether hook has any handlers registered to it. * The function may have been registered via Hooks::register or in extension.json * * @param string $hook Name of hook * @return bool Whether the hook has a handler registered to it */ public function isRegistered( string $hook ): bool { return (bool)$this->getHandlers( $hook ); } /** * Attach an event handler to a given hook. * * The handler should be given in one of the following forms: * * 1) A callable (string, array, or closure) * 2) An extension hook handler spec in the form returned by * HookRegistry::getExtensionHooks * * Several other forms are supported for backwards compatibility, but * should not be used when calling this method directly. * * @note This method accepts "broken callables", that is, callable * structures that reference classes that could not be found or could * not be loaded, e.g. because they implement an interface that cannot * be loaded. This situation may legitimately arise when implementing * hooks defined by extensions that are not present. * In that case, the hook will never fire and registering the "broken" * handlers is harmless. If a broken hook handler is registered for a * hook that is indeed called, it will cause an error. This is * intentional: we don't want to silently ignore mistakes like mistyped * class names in a hook handler registration. * * @param string $hook Name of hook * @param string|array|callable $handler handler */ public function register( string $hook, $handler ) { $this->checkDeprecation( $hook, $handler ); if ( !isset( $this->handlers[$hook] ) ) { // Just remember the handler for later. // NOTE: It would be nice to normalize immediately. But since some extensions make extensive // use of this method for registering hooks on every call, that could be a performance // issue. This is particularly true if the hook is declared in a way that would require // service objects to be instantiated. $this->extraHandlers[$hook][] = $handler; return; } $normalized = $this->normalizeHandler( $hook, $handler ); if ( !$normalized ) { throw new InvalidArgumentException( 'Bad hook handler!' ); } $this->getHandlers( $hook ); $this->handlers[$hook][] = $normalized; } /** * Get handler callbacks. * * @deprecated since 1.41. * @internal For use by FauxHookHandlerArray. Delete when no longer needed. * @param string $hook Name of hook * @return callable[] */ public function getHandlerCallbacks( string $hook ): array { wfDeprecated( __METHOD__, '1.41' ); $handlers = $this->getHandlers( $hook ); $callbacks = []; foreach ( $handlers as $h ) { $callback = $h['callback']; if ( isset( $h['args'] ) ) { // Needs curry in order to pass extra arguments. // NOTE: This does not support reference parameters! $extraArgs = $h['args']; $callbacks[] = static function ( ...$hookArgs ) use ( $callback, $extraArgs ) { return $callback( ...$extraArgs, ...$hookArgs ); }; } else { $callbacks[] = $callback; } } return $callbacks; } /** * Returns the names of all hooks that have at least one handler registered. * @return string[] */ public function getHookNames(): array { $names = array_merge( array_keys( array_filter( $this->handlers ) ), array_keys( array_filter( $this->extraHandlers ) ), array_keys( array_filter( $this->registry->getGlobalHooks() ) ), array_keys( array_filter( $this->registry->getExtensionHooks() ) ) ); return array_unique( $names ); } /** * Return the array of handlers for the given hook. * * @param string $hook Name of the hook * @param array $options Handler options, which may include: * - noServices: Do not allow hook handlers with service dependencies * @return array[] A list of handler entries */ private function getHandlers( string $hook, array $options = [] ): array { if ( !isset( $this->handlers[$hook] ) ) { $handlers = []; $registeredHooks = $this->registry->getExtensionHooks(); $configuredHooks = $this->registry->getGlobalHooks(); $rawHandlers = array_merge( $configuredHooks[ $hook ] ?? [], $registeredHooks[ $hook ] ?? [], $this->extraHandlers[ $hook ] ?? [], ); foreach ( $rawHandlers as $raw ) { $handler = $this->normalizeHandler( $hook, $raw, $options ); if ( !$handler ) { // XXX: log this?! // NOTE: also happens for deprecated hooks, which is fine! continue; } $handlers[] = $handler; } $this->handlers[ $hook ] = $handlers; } return $this->handlers[ $hook ]; } /** * Return the array of strings that describe the handler registered with the given hook. * * @internal Only public for use by ApiQuerySiteInfo.php and SpecialVersion.php * @param string $hook Name of the hook * @return string[] A list of handler descriptions */ public function getHandlerDescriptions( string $hook ): array { $descriptions = []; if ( isset( $this->handlers[ $hook ] ) ) { $rawHandlers = $this->handlers[ $hook ]; } else { $registeredHooks = $this->registry->getExtensionHooks(); $configuredHooks = $this->registry->getGlobalHooks(); $rawHandlers = array_merge( $configuredHooks[ $hook ] ?? [], $registeredHooks[ $hook ] ?? [], $this->extraHandlers[ $hook ] ?? [], ); } foreach ( $rawHandlers as $raw ) { $descr = $this->describeHandler( $hook, $raw ); if ( $descr ) { $descriptions[] = $descr; } } return $descriptions; } /** * Returns a human-readable description of the given handler. * * @param string $hook * @param string|array|callable $handler * * @return ?string */ private function describeHandler( string $hook, $handler ): ?string { if ( is_array( $handler ) ) { // already normalized if ( isset( $handler['functionName'] ) ) { return $handler['functionName']; } if ( isset( $handler['callback'] ) ) { return self::callableToString( $handler['callback'] ); } if ( isset( $handler['handler']['class'] ) ) { // New style hook. Avoid instantiating the handler object $method = $this->getHookMethodName( $hook ); return $handler['handler']['class'] . '::' . $method; } } $handler = $this->normalizeHandler( $hook, $handler ); return $handler ? $handler['functionName'] : null; } /** * For each hook handler of each hook, this will log a deprecation if: * 1. the hook is marked deprecated and * 2. the "silent" flag is absent or false, and * 3. an extension registers a handler in the new way but does not acknowledge deprecation */ public function emitDeprecationWarnings() { $deprecatedHooks = $this->registry->getDeprecatedHooks(); $extensionHooks = $this->registry->getExtensionHooks(); foreach ( $extensionHooks as $name => $handlers ) { if ( $deprecatedHooks->isHookDeprecated( $name ) ) { $deprecationInfo = $deprecatedHooks->getDeprecationInfo( $name ); if ( !empty( $deprecationInfo['silent'] ) ) { continue; } $version = $deprecationInfo['deprecatedVersion'] ?? ''; $component = $deprecationInfo['component'] ?? 'MediaWiki'; foreach ( $handlers as $handler ) { if ( !isset( $handler['deprecated'] ) || !$handler['deprecated'] ) { MWDebug::sendRawDeprecated( "Hook $name was deprecated in $component $version " . "but is registered in " . $handler['extensionPath'] ); } } } } } /** * Will trigger a deprecation warning if the given hook is deprecated and the deprecation * is not marked as silent. * * @param string $hook The name of the hook. * @param array|callable|string $handler A handler spec * @param array|null $deprecationInfo Deprecation info if the caller already knows it. * If not given, it will be looked up from the hook registry. * * @return void */ private function checkDeprecation( string $hook, $handler, ?array $deprecationInfo = null ): void { if ( !$deprecationInfo ) { $deprecatedHooks = $this->registry->getDeprecatedHooks(); $deprecationInfo = $deprecatedHooks->getDeprecationInfo( $hook ); } if ( $deprecationInfo && empty( $deprecationInfo['silent'] ) ) { $description = $this->describeHandler( $hook, $handler ); wfDeprecated( "$hook hook (used in $description)", $deprecationInfo['deprecatedVersion'] ?? false, $deprecationInfo['component'] ?? false ); } } /** * Returns a human-readable representation of the given callable. * * @param callable $callable * * @return string */ private static function callableToString( $callable ): string { if ( is_string( $callable ) ) { return $callable; } if ( $callable instanceof Closure ) { $hash = spl_object_hash( $callable ); return "*closure#$hash*"; } if ( is_array( $callable ) ) { [ $on, $func ] = $callable; if ( is_object( $on ) ) { $on = get_class( $on ); } return "$on::$func"; } throw new InvalidArgumentException( 'Unexpected kind of callable' ); } /** * Returns the default handler method name for the given hook. * * @param string $hook * * @return string */ private function getHookMethodName( string $hook ): string { $hook = strtr( $hook, ':\\-', '___' ); return "on$hook"; } /** * Replacement for is_callable that will also return true when the callable uses a class * that cannot be loaded. * * This may legitimately happen when a hook handler uses a hook interfaces that is defined * in another extension. In that case, the hook itself is also defined in the other extension, * so the hook will never be called and no problem arises. * * However, it is entirely possible to register broken handlers for hooks that will indeed * be called, causing an error. This is intentional: we don't want to silently ignore * mistakes like mistyped class names in a hook handler registration. * * @param mixed $v * * @return bool */ private static function mayBeCallable( $v ): bool { try { return is_callable( $v ); } catch ( Error $error ) { // If the callable uses a class that can't be loaded because it extends an unknown base class. // Continue as if is_callable had returned true, to allow the handler to be registered. if ( preg_match( '/Class.*not found/', $error->getMessage() ) ) { return true; } throw $error; } } }