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  $handler = $this->normalizeHandler( $callback, $hook );
379  wfDeprecated(
380  "$hook hook (used in " . $handler['functionName'] . ")",
381  $info['deprecatedVersion'] ?? false,
382  $info['component'] ?? false
383  );
384  }
385  }
386  $this->dynamicHandlers[$hook][] = $callback;
387  }
388 
397  public function getLegacyHandlers( string $hook ): array {
398  if ( $this->tombstones[$hook] ?? false ) {
399  // If there is at least one tombstone set for the hook,
400  // ignore all handlers from the registry, and
401  // only consider handlers registered after the tombstone
402  // was set.
403  $handlers = $this->dynamicHandlers[$hook] ?? [];
404  $keys = array_keys( $handlers );
405 
406  // Loop over the handlers backwards, to find the last tombstone.
407  for ( $i = count( $keys ) - 1; $i >= 0; $i-- ) {
408  $k = $keys[$i];
409  $v = $handlers[$k];
410 
411  if ( $v === self::TOMBSTONE ) {
412  break;
413  }
414  }
415 
416  // Return the part of $this->dynamicHandlers[$hook] after the TOMBSTONE
417  // marker, preserving keys.
418  $keys = array_slice( $keys, $i + 1 );
419  $handlers = array_intersect_key( $handlers, array_fill_keys( $keys, true ) );
420  } else {
421  // If no tombstone is set, just merge the two arrays.
422  $handlers = array_merge(
423  $this->registry->getGlobalHooks()[$hook] ?? [],
424  $this->dynamicHandlers[$hook] ?? []
425  );
426  }
427 
428  return $handlers;
429  }
430 
435  public function getHookNames(): array {
436  $names = array_merge(
437  array_keys( $this->dynamicHandlers ),
438  array_keys( $this->registry->getGlobalHooks() ),
439  array_keys( $this->registry->getExtensionHooks() )
440  );
441 
442  return array_unique( $names );
443  }
444 
452  public function getRegisteredHooks(): array {
453  $names = array_merge(
454  array_keys( $this->dynamicHandlers ),
455  array_keys( $this->registry->getExtensionHooks() ),
456  array_keys( $this->registry->getGlobalHooks() ),
457  );
458 
459  return array_unique( $names );
460  }
461 
473  public function getHandlers( string $hook, array $options = [] ): array {
474  if ( $this->tombstones[$hook] ?? false ) {
475  // There is at least one tombstone for the hook, so suppress all new-style hooks.
476  return [];
477  }
478  $handlers = [];
479  $deprecatedHooks = $this->registry->getDeprecatedHooks();
480  $registeredHooks = $this->registry->getExtensionHooks();
481  if ( isset( $registeredHooks[$hook] ) ) {
482  foreach ( $registeredHooks[$hook] as $hookReference ) {
483  // Non-legacy hooks have handler attributes
484  $handlerSpec = $hookReference['handler'];
485  // Skip hooks that both acknowledge deprecation and are deprecated in core
486  $flaggedDeprecated = !empty( $hookReference['deprecated'] );
487  $deprecated = $deprecatedHooks->isHookDeprecated( $hook );
488  if ( $deprecated && $flaggedDeprecated ) {
489  continue;
490  }
491  $handlerName = $handlerSpec['name'];
492  if (
493  !empty( $options['noServices'] ) && (
494  isset( $handlerSpec['services'] ) ||
495  isset( $handlerSpec['optional_services'] )
496  )
497  ) {
498  throw new UnexpectedValueException(
499  "The handler for the hook $hook registered in " .
500  "{$hookReference['extensionPath']} has a service dependency, " .
501  "but this hook does not allow it." );
502  }
503  if ( !isset( $this->handlersByName[$handlerName] ) ) {
504  $this->handlersByName[$handlerName] =
505  $this->objectFactory->createObject( $handlerSpec );
506  }
507  $handlers[] = $this->handlersByName[$handlerName];
508  }
509  }
510  return $handlers;
511  }
512 
519  public function emitDeprecationWarnings() {
520  $deprecatedHooks = $this->registry->getDeprecatedHooks();
521  $registeredHooks = $this->registry->getExtensionHooks();
522  foreach ( $registeredHooks as $name => $handlers ) {
523  if ( $deprecatedHooks->isHookDeprecated( $name ) ) {
524  $deprecationInfo = $deprecatedHooks->getDeprecationInfo( $name );
525  if ( !empty( $deprecationInfo['silent'] ) ) {
526  continue;
527  }
528  $version = $deprecationInfo['deprecatedVersion'] ?? '';
529  $component = $deprecationInfo['component'] ?? 'MediaWiki';
530  foreach ( $handlers as $handler ) {
531  if ( !isset( $handler['deprecated'] ) || !$handler['deprecated'] ) {
533  "Hook $name was deprecated in $component $version " .
534  "but is registered in " . $handler['extensionPath']
535  );
536  }
537  }
538  }
539  }
540  }
541 }
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'))
Definition: WebStart.php:88
New debugger system that outputs a toolbar on page view.
Definition: MWDebug.php:37
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:371
MediaWiki exception.
Definition: MWException.php:32
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.