MediaWiki  master
HookContainer.php
Go to the documentation of this file.
1 <?php
26 namespace MediaWiki\HookContainer;
27 
28 use Closure;
29 use MWDebug;
30 use MWException;
31 use UnexpectedValueException;
32 use Wikimedia\Assert\Assert;
33 use Wikimedia\NonSerializable\NonSerializableTrait;
34 use Wikimedia\ObjectFactory\ObjectFactory;
35 use Wikimedia\ScopedCallback;
36 use Wikimedia\Services\SalvageableService;
37 
45 class HookContainer implements SalvageableService {
46  use NonSerializableTrait;
47 
49  private $dynamicHandlers = [];
50 
54  private $tombstones = [];
55 
57  private const TOMBSTONE = 'TOMBSTONE';
58 
60  private $handlersByName = [];
61 
63  private $registry;
64 
66  private $objectFactory;
67 
69  private $nextScopedRegisterId = 0;
70 
75  public function __construct(
76  HookRegistry $hookRegistry,
77  ObjectFactory $objectFactory
78  ) {
79  $this->registry = $hookRegistry;
80  $this->objectFactory = $objectFactory;
81  }
82 
93  public function salvage( SalvageableService $other ) {
94  Assert::parameterType( self::class, $other, '$other' );
95  if ( $this->dynamicHandlers || $this->handlersByName ) {
96  throw new MWException( 'salvage() must be called immediately after construction' );
97  }
98  $this->handlersByName = $other->handlersByName;
99  $this->dynamicHandlers = $other->dynamicHandlers;
100  $this->tombstones = $other->tombstones;
101  }
102 
125  public function run( string $hook, array $args = [], array $options = [] ): bool {
126  $legacyHandlers = $this->getLegacyHandlers( $hook );
127  $options = array_merge(
128  $this->registry->getDeprecatedHooks()->getDeprecationInfo( $hook ) ?? [],
129  $options
130  );
131  // Equivalent of legacy Hooks::runWithoutAbort()
132  $notAbortable = ( isset( $options['abortable'] ) && $options['abortable'] === false );
133  foreach ( $legacyHandlers as $handler ) {
134  $normalizedHandler = $this->normalizeHandler( $handler, $hook );
135  if ( $normalizedHandler ) {
136  $functionName = $normalizedHandler['functionName'];
137  $return = $this->callLegacyHook( $hook, $normalizedHandler, $args, $options );
138  if ( $notAbortable && $return !== null && $return !== true ) {
139  throw new UnexpectedValueException( "Invalid return from $functionName" .
140  " for unabortable $hook." );
141  }
142  if ( $return === false ) {
143  return false;
144  }
145  if ( is_string( $return ) ) {
147  "Returning a string from a hook handler is deprecated since MediaWiki 1.35 ' .
148  '(done by $functionName for $hook)",
149  '1.35', false, false
150  );
151  throw new UnexpectedValueException( $return );
152  }
153  }
154  }
155 
156  $handlers = $this->getHandlers( $hook, $options );
157  $funcName = 'on' . strtr( ucfirst( $hook ), ':-', '__' );
158 
159  foreach ( $handlers as $handler ) {
160  $return = $handler->$funcName( ...$args );
161  if ( $notAbortable && $return !== null && $return !== true ) {
162  throw new UnexpectedValueException(
163  "Invalid return from " . $funcName . " for unabortable $hook."
164  );
165  }
166  if ( $return === false ) {
167  return false;
168  }
169  if ( $return !== null && !is_bool( $return ) ) {
170  throw new UnexpectedValueException( "Invalid return from " . $funcName . " for $hook." );
171  }
172  }
173  return true;
174  }
175 
187  public function clear( string $hook ): void {
188  if ( !defined( 'MW_PHPUNIT_TEST' ) && !defined( 'MW_PARSER_TEST' ) ) {
189  throw new MWException( 'Cannot reset hooks in operation.' );
190  }
191 
192  // The tombstone logic makes it so the clear() operation can be reversed reliably,
193  // and does not affect global state.
194  // $this->tombstones[$hook]>0 suppresses any handlers from the HookRegistry,
195  // see getHandlers().
196  // The TOMBSTONE value in $this->dynamicHandlers[$hook] means that all handlers
197  // that precede it in the array are ignored, see getLegacyHandlers().
198  $this->dynamicHandlers[$hook][] = self::TOMBSTONE;
199  $this->tombstones[$hook] = ( $this->tombstones[$hook] ?? 0 ) + 1;
200  }
201 
212  public function scopedRegister( string $hook, $callback, bool $replace = false ): ScopedCallback {
213  // Use a known key to register the handler, so we can later remove it
214  // from $this->dynamicHandlers using that key.
215  $id = 'TemporaryHook_' . $this->nextScopedRegisterId++;
216 
217  if ( $replace ) {
218  // Use a known key for the tombstone, so we can later remove it
219  // from $this->dynamicHandlers using that key.
220  $ts = "{$id}_TOMBSTONE";
221 
222  // See comment in clear() for the tombstone logic.
223  $this->dynamicHandlers[$hook][$ts] = self::TOMBSTONE;
224  $this->dynamicHandlers[$hook][$id] = $callback;
225  $this->tombstones[$hook] = ( $this->tombstones[$hook] ?? 0 ) + 1;
226 
227  return new ScopedCallback(
228  function () use ( $hook, $id, $ts ) {
229  unset( $this->dynamicHandlers[$hook][$ts] );
230  unset( $this->dynamicHandlers[$hook][$id] );
231  $this->tombstones[$hook]--;
232  }
233  );
234  } else {
235  $this->dynamicHandlers[$hook][$id] = $callback;
236  return new ScopedCallback( function () use ( $hook, $id ) {
237  unset( $this->dynamicHandlers[$hook][$id] );
238  } );
239  }
240  }
241 
252  private function normalizeHandler( $handler, string $hook ) {
253  $normalizedHandler = $handler;
254  if ( !is_array( $handler ) ) {
255  $normalizedHandler = [ $normalizedHandler ];
256  }
257 
258  // Empty array or array filled with null/false/empty.
259  if ( !array_filter( $normalizedHandler ) ) {
260  return false;
261  }
262 
263  if ( is_array( $normalizedHandler[0] ) ) {
264  // First element is an array, meaning the developer intended
265  // the first element to be a callback. Merge it in so that
266  // processing can be uniform.
267  $normalizedHandler = array_merge( $normalizedHandler[0], array_slice( $normalizedHandler, 1 ) );
268  }
269 
270  $firstArg = $normalizedHandler[0];
271 
272  // Extract function name, handler callback, and any arguments for the callback
273  if ( $firstArg instanceof Closure ) {
274  $functionName = "hook-$hook-closure";
275  $callback = array_shift( $normalizedHandler );
276  } elseif ( is_object( $firstArg ) ) {
277  $object = array_shift( $normalizedHandler );
278  $functionName = array_shift( $normalizedHandler );
279 
280  // If no method was specified, default to on$event
281  if ( $functionName === null ) {
282  $functionName = "on$hook";
283  } else {
284  $colonPos = strpos( $functionName, '::' );
285  if ( $colonPos !== false ) {
286  // Some extensions use [ $object, 'Class::func' ] which
287  // worked with call_user_func_array() but doesn't work now
288  // that we use a plain variadic call
289  $functionName = substr( $functionName, $colonPos + 2 );
290  }
291  }
292 
293  $callback = [ $object, $functionName ];
294  } elseif ( is_string( $firstArg ) ) {
295  if ( is_callable( $normalizedHandler, true, $functionName )
296  && class_exists( $firstArg ) // $firstArg can be a function in global scope
297  ) {
298  $callback = $normalizedHandler;
299  $normalizedHandler = []; // Can't pass arguments here
300  } else {
301  $functionName = $callback = array_shift( $normalizedHandler );
302  }
303  } else {
304  throw new UnexpectedValueException( 'Unknown datatype in hooks for ' . $hook );
305  }
306 
307  return [
308  'callback' => $callback,
309  'args' => $normalizedHandler,
310  'functionName' => $functionName,
311  ];
312  }
313 
327  private function callLegacyHook( string $hook, $handler, array $args, array $options ) {
328  $callback = $handler['callback'];
329  $hookArgs = array_merge( $handler['args'], $args );
330  if ( isset( $options['deprecatedVersion'] ) && empty( $options['silent'] ) ) {
331  wfDeprecated(
332  "$hook hook (used in " . $handler['functionName'] . ")",
333  $options['deprecatedVersion'] ?? false,
334  $options['component'] ?? false
335  );
336  }
337  // Call the hooks
338  return $callback( ...$hookArgs );
339  }
340 
348  public function isRegistered( string $hook ): bool {
349  if ( $this->tombstones[$hook] ?? false ) {
350  // If a tombstone is set, we only care about dynamically registered hooks,
351  // and leave it to getLegacyHandlers() to handle the cut-off.
352  return !empty( $this->getLegacyHandlers( $hook ) );
353  }
354 
355  // If no tombstone is set, we just check if any of the three arrays contains handlers.
356  if ( !empty( $this->registry->getGlobalHooks()[$hook] ) ||
357  !empty( $this->dynamicHandlers[$hook] ) ||
358  !empty( $this->registry->getExtensionHooks()[$hook] )
359  ) {
360  return true;
361  }
362 
363  return false;
364  }
365 
372  public function register( string $hook, $callback ) {
373  $deprecatedHooks = $this->registry->getDeprecatedHooks();
374  $deprecated = $deprecatedHooks->isHookDeprecated( $hook );
375  if ( $deprecated ) {
376  $info = $deprecatedHooks->getDeprecationInfo( $hook );
377  if ( empty( $info['silent'] ) ) {
378  $deprecatedVersion = $info['deprecatedVersion'] ?? false;
379  $component = $info['component'] ?? false;
380  wfDeprecated(
381  "$hook hook", $deprecatedVersion, $component
382  );
383  }
384  }
385  $this->dynamicHandlers[$hook][] = $callback;
386  }
387 
396  public function getLegacyHandlers( string $hook ): array {
397  if ( $this->tombstones[$hook] ?? false ) {
398  // If there is at least one tombstone set for the hook,
399  // ignore all handlers from the registry, and
400  // only consider handlers registered after the tombstone
401  // was set.
402  $handlers = $this->dynamicHandlers[$hook] ?? [];
403  $keys = array_keys( $handlers );
404 
405  // Loop over the handlers backwards, to find the last tombstone.
406  for ( $i = count( $keys ) - 1; $i >= 0; $i-- ) {
407  $k = $keys[$i];
408  $v = $handlers[$k];
409 
410  if ( $v === self::TOMBSTONE ) {
411  break;
412  }
413  }
414 
415  // Return the part of $this->dynamicHandlers[$hook] after the TOMBSTONE
416  // marker, preserving keys.
417  $keys = array_slice( $keys, $i + 1 );
418  $handlers = array_intersect_key( $handlers, array_fill_keys( $keys, true ) );
419  } else {
420  // If no tombstone is set, just merge the two arrays.
421  $handlers = array_merge(
422  $this->registry->getGlobalHooks()[$hook] ?? [],
423  $this->dynamicHandlers[$hook] ?? []
424  );
425  }
426 
427  return $handlers;
428  }
429 
434  public function getHookNames(): array {
435  $names = array_merge(
436  array_keys( $this->dynamicHandlers ),
437  array_keys( $this->registry->getGlobalHooks() ),
438  array_keys( $this->registry->getExtensionHooks() )
439  );
440 
441  return array_unique( $names );
442  }
443 
451  public function getRegisteredHooks(): array {
452  $names = array_merge(
453  array_keys( $this->dynamicHandlers ),
454  array_keys( $this->registry->getExtensionHooks() ),
455  array_keys( $this->registry->getGlobalHooks() ),
456  );
457 
458  return array_unique( $names );
459  }
460 
472  public function getHandlers( string $hook, array $options = [] ): array {
473  if ( $this->tombstones[$hook] ?? false ) {
474  // There is at least one tombstone for the hook, so suppress all new-style hooks.
475  return [];
476  }
477  $handlers = [];
478  $deprecatedHooks = $this->registry->getDeprecatedHooks();
479  $registeredHooks = $this->registry->getExtensionHooks();
480  if ( isset( $registeredHooks[$hook] ) ) {
481  foreach ( $registeredHooks[$hook] as $hookReference ) {
482  // Non-legacy hooks have handler attributes
483  $handlerSpec = $hookReference['handler'];
484  // Skip hooks that both acknowledge deprecation and are deprecated in core
485  $flaggedDeprecated = !empty( $hookReference['deprecated'] );
486  $deprecated = $deprecatedHooks->isHookDeprecated( $hook );
487  if ( $deprecated && $flaggedDeprecated ) {
488  continue;
489  }
490  $handlerName = $handlerSpec['name'];
491  if (
492  !empty( $options['noServices'] ) && (
493  isset( $handlerSpec['services'] ) ||
494  isset( $handlerSpec['optional_services'] )
495  )
496  ) {
497  throw new UnexpectedValueException(
498  "The handler for the hook $hook registered in " .
499  "{$hookReference['extensionPath']} has a service dependency, " .
500  "but this hook does not allow it." );
501  }
502  if ( !isset( $this->handlersByName[$handlerName] ) ) {
503  $this->handlersByName[$handlerName] =
504  $this->objectFactory->createObject( $handlerSpec );
505  }
506  $handlers[] = $this->handlersByName[$handlerName];
507  }
508  }
509  return $handlers;
510  }
511 
518  public function emitDeprecationWarnings() {
519  $deprecatedHooks = $this->registry->getDeprecatedHooks();
520  $registeredHooks = $this->registry->getExtensionHooks();
521  foreach ( $registeredHooks as $name => $handlers ) {
522  if ( $deprecatedHooks->isHookDeprecated( $name ) ) {
523  $deprecationInfo = $deprecatedHooks->getDeprecationInfo( $name );
524  if ( !empty( $deprecationInfo['silent'] ) ) {
525  continue;
526  }
527  $version = $deprecationInfo['deprecatedVersion'] ?? '';
528  $component = $deprecationInfo['component'] ?? 'MediaWiki';
529  foreach ( $handlers as $handler ) {
530  if ( !isset( $handler['deprecated'] ) || !$handler['deprecated'] ) {
532  "Hook $name was deprecated in $component $version " .
533  "but is registered in " . $handler['extensionPath']
534  );
535  }
536  }
537  }
538  }
539  }
540 }
wfDeprecatedMsg( $msg, $version=false, $component=false, $callerOffset=2)
Log a deprecation warning with arbitrary message text.
wfDeprecated( $function, $version=false, $component=false, $callerOffset=2)
Logs a warning that a deprecated feature was used.
if(!defined('MW_SETUP_CALLBACK'))
The persistent session ID (if any) loaded at startup.
Definition: WebStart.php:82
New debugger system that outputs a toolbar on page view.
Definition: MWDebug.php:36
static sendRawDeprecated( $msg, $sendToLog=true, $callerFunc='')
Send a raw deprecation message to the log and the debug toolbar, without filtering of duplicate messa...
Definition: MWDebug.php:370
MediaWiki exception.
Definition: MWException.php:30
getHandlers(string $hook, array $options=[])
Return array of handler objects registered with given hook in the new system.
__construct(HookRegistry $hookRegistry, ObjectFactory $objectFactory)
clear(string $hook)
Clear hooks registered via Hooks::register().
isRegistered(string $hook)
Return whether hook has any handlers registered to it.
getRegisteredHooks()
Returns the names of all hooks that have handlers registered.
getHookNames()
Returns the names of all hooks that have at least one handler registered.
salvage(SalvageableService $other)
Salvage the state of HookContainer by retaining existing handler objects and hooks registered via Hoo...
getLegacyHandlers(string $hook)
Get all handlers for legacy hooks system, plus any handlers added using register().
scopedRegister(string $hook, $callback, bool $replace=false)
Register hook and handler, allowing for easy removal.
emitDeprecationWarnings()
Will log a deprecation warning if:
run(string $hook, array $args=[], array $options=[])
Call registered hook functions through either the legacy $wgHooks or extension.json.