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;
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 object, and any arguments for handler object
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 varadic call
289  $functionName = substr( $functionName, $colonPos + 2 );
290  }
291  }
292 
293  $callback = [ $object, $functionName ];
294  } elseif ( is_string( $firstArg ) ) {
295  $functionName = $callback = array_shift( $normalizedHandler );
296  } else {
297  throw new UnexpectedValueException( 'Unknown datatype in hooks for ' . $hook );
298  }
299  return [
300  'callback' => $callback,
301  'args' => $normalizedHandler,
302  'functionName' => $functionName,
303  ];
304  }
305 
319  private function callLegacyHook( string $hook, $handler, array $args, array $options ) {
320  $callback = $handler['callback'];
321  $hookArgs = array_merge( $handler['args'], $args );
322  if ( isset( $options['deprecatedVersion'] ) && empty( $options['silent'] ) ) {
323  wfDeprecated(
324  "$hook hook (used in " . $handler['functionName'] . ")",
325  $options['deprecatedVersion'] ?? false,
326  $options['component'] ?? false
327  );
328  }
329  // Call the hooks
330  return $callback( ...$hookArgs );
331  }
332 
340  public function isRegistered( string $hook ) : bool {
341  if ( $this->tombstones[$hook] ?? false ) {
342  // If a tombstone is set, we only care about dynamically registered hooks,
343  // and leave it to getLegacyHandlers() to handle the cut-off.
344  return !empty( $this->getLegacyHandlers( $hook ) );
345  }
346 
347  // If no tombstone is set, we just check if any of the three arrays contains handlers.
348  if ( !empty( $this->registry->getGlobalHooks()[$hook] ) ||
349  !empty( $this->dynamicHandlers[$hook] ) ||
350  !empty( $this->registry->getExtensionHooks()[$hook] )
351  ) {
352  return true;
353  }
354 
355  return false;
356  }
357 
364  public function register( string $hook, $callback ) {
365  $deprecatedHooks = $this->registry->getDeprecatedHooks();
366  $deprecated = $deprecatedHooks->isHookDeprecated( $hook );
367  if ( $deprecated ) {
368  $info = $deprecatedHooks->getDeprecationInfo( $hook );
369  if ( empty( $info['silent'] ) ) {
370  $deprecatedVersion = $info['deprecatedVersion'] ?? false;
371  $component = $info['component'] ?? false;
372  wfDeprecated(
373  "$hook hook", $deprecatedVersion, $component
374  );
375  }
376  }
377  $this->dynamicHandlers[$hook][] = $callback;
378  }
379 
388  public function getLegacyHandlers( string $hook ) : array {
389  if ( $this->tombstones[$hook] ?? false ) {
390  // If there is at least one tombstone set for the hook,
391  // ignore all handlers from the registry, and
392  // only consider handlers registered after the tombstone
393  // was set.
394  $handlers = $this->dynamicHandlers[$hook] ?? [];
395  $keys = array_keys( $handlers );
396 
397  // Loop over the handlers backwards, to find the last tombstone.
398  for ( $i = count( $keys ) - 1; $i >= 0; $i-- ) {
399  $k = $keys[$i];
400  $v = $handlers[$k];
401 
402  if ( $v === self::TOMBSTONE ) {
403  break;
404  }
405  }
406 
407  // Return the part of $this->dynamicHandlers[$hook] after the TOMBSTONE
408  // marker, preserving keys.
409  $keys = array_slice( $keys, $i + 1 );
410  $handlers = array_intersect_key( $handlers, array_flip( $keys ) );
411  } else {
412  // If no tombstone is set, just merge the two arrays.
413  $handlers = array_merge(
414  $this->registry->getGlobalHooks()[$hook] ?? [],
415  $this->dynamicHandlers[$hook] ?? []
416  );
417  }
418 
419  return $handlers;
420  }
421 
433  public function getHandlers( string $hook, array $options = [] ) : array {
434  if ( $this->tombstones[$hook] ?? false ) {
435  // There is at least one tombstone for the hook, so suppress all new-style hooks.
436  return [];
437  }
438  $handlers = [];
439  $deprecatedHooks = $this->registry->getDeprecatedHooks();
440  $registeredHooks = $this->registry->getExtensionHooks();
441  if ( isset( $registeredHooks[$hook] ) ) {
442  foreach ( $registeredHooks[$hook] as $hookReference ) {
443  // Non-legacy hooks have handler attributes
444  $handlerSpec = $hookReference['handler'];
445  // Skip hooks that both acknowledge deprecation and are deprecated in core
446  $flaggedDeprecated = !empty( $hookReference['deprecated'] );
447  $deprecated = $deprecatedHooks->isHookDeprecated( $hook );
448  if ( $deprecated && $flaggedDeprecated ) {
449  continue;
450  }
451  $handlerName = $handlerSpec['name'];
452  if (
453  !empty( $options['noServices'] ) && (
454  isset( $handlerSpec['services'] ) ||
455  isset( $handlerSpec['optional_services'] )
456  )
457  ) {
458  throw new UnexpectedValueException(
459  "The handler for the hook $hook registered in " .
460  "{$hookReference['extensionPath']} has a service dependency, " .
461  "but this hook does not allow it." );
462  }
463  if ( !isset( $this->handlersByName[$handlerName] ) ) {
464  $this->handlersByName[$handlerName] =
465  $this->objectFactory->createObject( $handlerSpec );
466  }
467  $handlers[] = $this->handlersByName[$handlerName];
468  }
469  }
470  return $handlers;
471  }
472 
479  public function emitDeprecationWarnings() {
480  $deprecatedHooks = $this->registry->getDeprecatedHooks();
481  $registeredHooks = $this->registry->getExtensionHooks();
482  foreach ( $registeredHooks as $name => $handlers ) {
483  if ( $deprecatedHooks->isHookDeprecated( $name ) ) {
484  $deprecationInfo = $deprecatedHooks->getDeprecationInfo( $name );
485  if ( !empty( $deprecationInfo['silent'] ) ) {
486  continue;
487  }
488  $version = $deprecationInfo['deprecatedVersion'] ?? '';
489  $component = $deprecationInfo['component'] ?? 'MediaWiki';
490  foreach ( $handlers as $handler ) {
491  if ( !isset( $handler['deprecated'] ) || !$handler['deprecated'] ) {
493  "Hook $name was deprecated in $component $version " .
494  "but is registered in " . $handler['extensionPath']
495  );
496  }
497  }
498  }
499  }
500  }
501 }
MediaWiki\HookContainer\HookContainer\callLegacyHook
callLegacyHook(string $hook, $handler, array $args, array $options)
Run legacy hooks Hook can be: a function, an object, an array of $function and $data,...
Definition: HookContainer.php:319
MediaWiki\HookContainer\HookContainer\$handlersByName
array $handlersByName
handler name and their handler objects
Definition: HookContainer.php:60
MediaWiki\HookContainer\HookContainer\emitDeprecationWarnings
emitDeprecationWarnings()
Will log a deprecation warning if:
Definition: HookContainer.php:479
MediaWiki\HookContainer\HookContainer\isRegistered
isRegistered(string $hook)
Return whether hook has any handlers registered to it.
Definition: HookContainer.php:340
MWDebug
New debugger system that outputs a toolbar on page view.
Definition: MWDebug.php:33
MediaWiki\HookContainer\HookContainer\$objectFactory
ObjectFactory $objectFactory
Definition: HookContainer.php:66
MediaWiki\HookContainer\HookRegistry
Definition: HookRegistry.php:5
MediaWiki\HookContainer\HookContainer\normalizeHandler
normalizeHandler( $handler, string $hook)
Normalize/clean up format of argument passed as hook handler.
Definition: HookContainer.php:252
MediaWiki\HookContainer\HookContainer\$dynamicHandlers
array $dynamicHandlers
Hooks and their callbacks registered through $this->register()
Definition: HookContainer.php:49
MediaWiki\HookContainer\HookContainer\getLegacyHandlers
getLegacyHandlers(string $hook)
Get all handlers for legacy hooks system, plus any handlers added using register().
Definition: HookContainer.php:388
wfDeprecatedMsg
wfDeprecatedMsg( $msg, $version=false, $component=false, $callerOffset=2)
Log a deprecation warning with arbitrary message text.
Definition: GlobalFunctions.php:1066
MWException
MediaWiki exception.
Definition: MWException.php:29
wfDeprecated
wfDeprecated( $function, $version=false, $component=false, $callerOffset=2)
Logs a warning that $function is deprecated.
Definition: GlobalFunctions.php:1034
MediaWiki\HookContainer\HookContainer\__construct
__construct(HookRegistry $hookRegistry, ObjectFactory $objectFactory)
Definition: HookContainer.php:75
MediaWiki\HookContainer\HookContainer\scopedRegister
scopedRegister(string $hook, $callback, bool $replace=false)
Register hook and handler, allowing for easy removal.
Definition: HookContainer.php:212
$args
if( $line===false) $args
Definition: mcc.php:124
MediaWiki\HookContainer\HookContainer\$registry
HookRegistry $registry
Definition: HookContainer.php:63
MediaWiki\HookContainer
Definition: DeprecatedHooks.php:23
MediaWiki\HookContainer\HookContainer\$tombstones
array $tombstones
Tombstone count by hook name.
Definition: HookContainer.php:54
MediaWiki\HookContainer\HookContainer\$nextScopedRegisterId
int $nextScopedRegisterId
The next ID to be used by scopedRegister()
Definition: HookContainer.php:69
MediaWiki\HookContainer\HookContainer\clear
clear(string $hook)
Clear hooks registered via Hooks::register().
Definition: HookContainer.php:187
MediaWiki\HookContainer\HookContainer\run
run(string $hook, array $args=[], array $options=[])
Call registered hook functions through either the legacy $wgHooks or extension.json.
Definition: HookContainer.php:125
MediaWiki\HookContainer\HookContainer\getHandlers
getHandlers(string $hook, array $options=[])
Return array of handler objects registered with given hook in the new system.
Definition: HookContainer.php:433
$keys
$keys
Definition: testCompression.php:72
MediaWiki\HookContainer\HookContainer
HookContainer class.
Definition: HookContainer.php:45
MediaWiki\HookContainer\HookContainer\salvage
salvage(SalvageableService $other)
Salvage the state of HookContainer by retaining existing handler objects and hooks registered via Hoo...
Definition: HookContainer.php:93
MWDebug\sendRawDeprecated
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:368