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 
128  public function run( string $hook, array $args = [], array $options = [] ) : bool {
129  $legacyHandlers = $this->getLegacyHandlers( $hook );
130  $options = array_merge(
131  $this->registry->getDeprecatedHooks()->getDeprecationInfo( $hook ) ?? [],
132  $options
133  );
134  // Equivalent of legacy Hooks::runWithoutAbort()
135  $notAbortable = ( isset( $options['abortable'] ) && $options['abortable'] === false );
136  foreach ( $legacyHandlers as $handler ) {
137  $normalizedHandler = $this->normalizeHandler( $handler, $hook );
138  if ( $normalizedHandler ) {
139  $functionName = $normalizedHandler['functionName'];
140  $return = $this->callLegacyHook( $hook, $normalizedHandler, $args, $options );
141  if ( $notAbortable && $return !== null && $return !== true ) {
142  throw new UnexpectedValueException( "Invalid return from $functionName" .
143  " for unabortable $hook." );
144  }
145  if ( $return === false ) {
146  return false;
147  }
148  if ( is_string( $return ) ) {
150  "Returning a string from a hook handler is deprecated since MediaWiki 1.35 ' .
151  '(done by $functionName for $hook)",
152  '1.35', false, false
153  );
154  throw new UnexpectedValueException( $return );
155  }
156  }
157  }
158 
159  $handlers = $this->getHandlers( $hook, $options );
160  $funcName = 'on' . str_replace( ':', '_', ucfirst( $hook ) );
161 
162  foreach ( $handlers as $handler ) {
163  $return = $handler->$funcName( ...$args );
164  if ( $notAbortable && $return !== null && $return !== true ) {
165  throw new UnexpectedValueException(
166  "Invalid return from " . $funcName . " for unabortable $hook."
167  );
168  }
169  if ( $return === false ) {
170  return false;
171  }
172  if ( $return !== null && !is_bool( $return ) ) {
173  throw new UnexpectedValueException( "Invalid return from " . $funcName . " for $hook." );
174  }
175  }
176  return true;
177  }
178 
190  public function clear( string $hook ) : void {
191  if ( !defined( 'MW_PHPUNIT_TEST' ) && !defined( 'MW_PARSER_TEST' ) ) {
192  throw new MWException( 'Cannot reset hooks in operation.' );
193  }
194 
195  // The tombstone logic makes it so the clear() operation can be reversed reliably,
196  // and does not affect global state.
197  // $this->tombstones[$hook]>0 suppresses any handlers from the HookRegistry,
198  // see getHandlers().
199  // The TOMBSTONE value in $this->dynamicHandlers[$hook] means that all handlers
200  // that precede it in the array are ignored, see getLegacyHandlers().
201  $this->dynamicHandlers[$hook][] = self::TOMBSTONE;
202  $this->tombstones[$hook] = ( $this->tombstones[$hook] ?? 0 ) + 1;
203  }
204 
215  public function scopedRegister( string $hook, $callback, bool $replace = false ) : ScopedCallback {
216  // Use a known key to register the handler, so we can later remove it
217  // from $this->dynamicHandlers using that key.
218  $id = 'TemporaryHook_' . $this->nextScopedRegisterId++;
219 
220  if ( $replace ) {
221  // Use a known key for the tombstone, so we can later remove it
222  // from $this->dynamicHandlers using that key.
223  $ts = "{$id}_TOMBSTONE";
224 
225  // See comment in clear() for the tombstone logic.
226  $this->dynamicHandlers[$hook][$ts] = self::TOMBSTONE;
227  $this->dynamicHandlers[$hook][$id] = $callback;
228  $this->tombstones[$hook] = ( $this->tombstones[$hook] ?? 0 ) + 1;
229 
230  return new ScopedCallback(
231  function () use ( $hook, $id, $ts ) {
232  unset( $this->dynamicHandlers[$hook][$ts] );
233  unset( $this->dynamicHandlers[$hook][$id] );
234  $this->tombstones[$hook]--;
235  }
236  );
237  } else {
238  $this->dynamicHandlers[$hook][$id] = $callback;
239  return new ScopedCallback( function () use ( $hook, $id ) {
240  unset( $this->dynamicHandlers[$hook][$id] );
241  } );
242  }
243  }
244 
255  private function normalizeHandler( $handler, string $hook ) {
256  $normalizedHandler = $handler;
257  if ( !is_array( $handler ) ) {
258  $normalizedHandler = [ $normalizedHandler ];
259  }
260 
261  // Empty array or array filled with null/false/empty.
262  if ( !array_filter( $normalizedHandler ) ) {
263  return false;
264  }
265 
266  if ( is_array( $normalizedHandler[0] ) ) {
267  // First element is an array, meaning the developer intended
268  // the first element to be a callback. Merge it in so that
269  // processing can be uniform.
270  $normalizedHandler = array_merge( $normalizedHandler[0], array_slice( $normalizedHandler, 1 ) );
271  }
272 
273  $firstArg = $normalizedHandler[0];
274 
275  // Extract function name, handler object, and any arguments for handler object
276  if ( $firstArg instanceof Closure ) {
277  $functionName = "hook-$hook-closure";
278  $callback = array_shift( $normalizedHandler );
279  } elseif ( is_object( $firstArg ) ) {
280  $object = array_shift( $normalizedHandler );
281  $functionName = array_shift( $normalizedHandler );
282 
283  // If no method was specified, default to on$event
284  if ( $functionName === null ) {
285  $functionName = "on$hook";
286  } else {
287  $colonPos = strpos( $functionName, '::' );
288  if ( $colonPos !== false ) {
289  // Some extensions use [ $object, 'Class::func' ] which
290  // worked with call_user_func_array() but doesn't work now
291  // that we use a plain varadic call
292  $functionName = substr( $functionName, $colonPos + 2 );
293  }
294  }
295 
296  $callback = [ $object, $functionName ];
297  } elseif ( is_string( $firstArg ) ) {
298  $functionName = $callback = array_shift( $normalizedHandler );
299  } else {
300  throw new UnexpectedValueException( 'Unknown datatype in hooks for ' . $hook );
301  }
302  return [
303  'callback' => $callback,
304  'args' => $normalizedHandler,
305  'functionName' => $functionName,
306  ];
307  }
308 
322  private function callLegacyHook( string $hook, $handler, array $args, array $options ) {
323  $callback = $handler['callback'];
324  $hookArgs = array_merge( $handler['args'], $args );
325  if ( isset( $options['deprecatedVersion'] ) && empty( $options['silent'] ) ) {
326  wfDeprecated(
327  "$hook hook (used in " . $handler['functionName'] . ")",
328  $options['deprecatedVersion'] ?? false,
329  $options['component'] ?? false
330  );
331  }
332  // Call the hooks
333  return $callback( ...$hookArgs );
334  }
335 
343  public function isRegistered( string $hook ) : bool {
344  if ( $this->tombstones[$hook] ?? false ) {
345  // If a tombstone is set, we only care about dynamically registered hooks,
346  // and leave it to getLegacyHandlers() to handle the cut-off.
347  return !empty( $this->getLegacyHandlers( $hook ) );
348  }
349 
350  // If no tombstone is set, we just check if any of the three arrays contains handlers.
351  if ( !empty( $this->registry->getGlobalHooks()[$hook] ) ||
352  !empty( $this->dynamicHandlers[$hook] ) ||
353  !empty( $this->registry->getExtensionHooks()[$hook] )
354  ) {
355  return true;
356  }
357 
358  return false;
359  }
360 
367  public function register( string $hook, $callback ) {
368  $deprecatedHooks = $this->registry->getDeprecatedHooks();
369  $deprecated = $deprecatedHooks->isHookDeprecated( $hook );
370  if ( $deprecated ) {
371  $info = $deprecatedHooks->getDeprecationInfo( $hook );
372  if ( empty( $info['silent'] ) ) {
373  $deprecatedVersion = $info['deprecatedVersion'] ?? false;
374  $component = $info['component'] ?? false;
375  wfDeprecated(
376  "$hook hook", $deprecatedVersion, $component
377  );
378  }
379  }
380  $this->dynamicHandlers[$hook][] = $callback;
381  }
382 
391  public function getLegacyHandlers( string $hook ) : array {
392  if ( $this->tombstones[$hook] ?? false ) {
393  // If there is at least one tombstone set for the hook,
394  // ignore all handlers from the registry, and
395  // only consider handlers registered after the tombstone
396  // was set.
397  $handlers = $this->dynamicHandlers[$hook] ?? [];
398  $keys = array_keys( $handlers );
399 
400  // Loop over the handlers backwards, to find the last tombstone.
401  for ( $i = count( $keys ) - 1; $i >= 0; $i-- ) {
402  $k = $keys[$i];
403  $v = $handlers[$k];
404 
405  if ( $v === self::TOMBSTONE ) {
406  break;
407  }
408  }
409 
410  // Return the part of $this->dynamicHandlers[$hook] after the TOMBSTONE
411  // marker, preserving keys.
412  $keys = array_slice( $keys, $i + 1 );
413  $handlers = array_intersect_key( $handlers, array_flip( $keys ) );
414  } else {
415  // If no tombstone is set, just merge the two arrays.
416  $handlers = array_merge(
417  $this->registry->getGlobalHooks()[$hook] ?? [],
418  $this->dynamicHandlers[$hook] ?? []
419  );
420  }
421 
422  return $handlers;
423  }
424 
436  public function getHandlers( string $hook, array $options = [] ) : array {
437  if ( $this->tombstones[$hook] ?? false ) {
438  // There is at least one tombstone for the hook, so suppress all new-style hooks.
439  return [];
440  }
441  $handlers = [];
442  $deprecatedHooks = $this->registry->getDeprecatedHooks();
443  $registeredHooks = $this->registry->getExtensionHooks();
444  if ( isset( $registeredHooks[$hook] ) ) {
445  foreach ( $registeredHooks[$hook] as $hookReference ) {
446  // Non-legacy hooks have handler attributes
447  $handlerSpec = $hookReference['handler'];
448  // Skip hooks that both acknowledge deprecation and are deprecated in core
449  $flaggedDeprecated = !empty( $hookReference['deprecated'] );
450  $deprecated = $deprecatedHooks->isHookDeprecated( $hook );
451  if ( $deprecated && $flaggedDeprecated ) {
452  continue;
453  }
454  $handlerName = $handlerSpec['name'];
455  if (
456  !empty( $options['noServices'] ) && (
457  isset( $handlerSpec['services'] ) ||
458  isset( $handlerSpec['optional_services'] )
459  )
460  ) {
461  throw new UnexpectedValueException(
462  "The handler for the hook $hook registered in " .
463  "{$hookReference['extensionPath']} has a service dependency, " .
464  "but this hook does not allow it." );
465  }
466  if ( !isset( $this->handlersByName[$handlerName] ) ) {
467  $this->handlersByName[$handlerName] =
468  $this->objectFactory->createObject( $handlerSpec );
469  }
470  $handlers[] = $this->handlersByName[$handlerName];
471  }
472  }
473  return $handlers;
474  }
475 
482  public function emitDeprecationWarnings() {
483  $deprecatedHooks = $this->registry->getDeprecatedHooks();
484  $registeredHooks = $this->registry->getExtensionHooks();
485  foreach ( $registeredHooks as $name => $handlers ) {
486  if ( $deprecatedHooks->isHookDeprecated( $name ) ) {
487  $deprecationInfo = $deprecatedHooks->getDeprecationInfo( $name );
488  if ( !empty( $deprecationInfo['silent'] ) ) {
489  continue;
490  }
491  $version = $deprecationInfo['deprecatedVersion'] ?? '';
492  $component = $deprecationInfo['component'] ?? 'MediaWiki';
493  foreach ( $handlers as $handler ) {
494  if ( !isset( $handler['deprecated'] ) || !$handler['deprecated'] ) {
496  "Hook $name was deprecated in $component $version " .
497  "but is registered in " . $handler['extensionPath']
498  );
499  }
500  }
501  }
502  }
503  }
504 }
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:322
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:482
MediaWiki\HookContainer\HookContainer\isRegistered
isRegistered(string $hook)
Return whether hook has any handlers registered to it.
Definition: HookContainer.php:343
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:255
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:391
wfDeprecatedMsg
wfDeprecatedMsg( $msg, $version=false, $component=false, $callerOffset=2)
Log a deprecation warning with arbitrary message text.
Definition: GlobalFunctions.php:1059
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:1027
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:215
$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:190
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:128
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:436
$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:321