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 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 
441  public function getHandlers( string $hook, array $options = [] ): array {
442  if ( $this->tombstones[$hook] ?? false ) {
443  // There is at least one tombstone for the hook, so suppress all new-style hooks.
444  return [];
445  }
446  $handlers = [];
447  $deprecatedHooks = $this->registry->getDeprecatedHooks();
448  $registeredHooks = $this->registry->getExtensionHooks();
449  if ( isset( $registeredHooks[$hook] ) ) {
450  foreach ( $registeredHooks[$hook] as $hookReference ) {
451  // Non-legacy hooks have handler attributes
452  $handlerSpec = $hookReference['handler'];
453  // Skip hooks that both acknowledge deprecation and are deprecated in core
454  $flaggedDeprecated = !empty( $hookReference['deprecated'] );
455  $deprecated = $deprecatedHooks->isHookDeprecated( $hook );
456  if ( $deprecated && $flaggedDeprecated ) {
457  continue;
458  }
459  $handlerName = $handlerSpec['name'];
460  if (
461  !empty( $options['noServices'] ) && (
462  isset( $handlerSpec['services'] ) ||
463  isset( $handlerSpec['optional_services'] )
464  )
465  ) {
466  throw new UnexpectedValueException(
467  "The handler for the hook $hook registered in " .
468  "{$hookReference['extensionPath']} has a service dependency, " .
469  "but this hook does not allow it." );
470  }
471  if ( !isset( $this->handlersByName[$handlerName] ) ) {
472  $this->handlersByName[$handlerName] =
473  $this->objectFactory->createObject( $handlerSpec );
474  }
475  $handlers[] = $this->handlersByName[$handlerName];
476  }
477  }
478  return $handlers;
479  }
480 
487  public function emitDeprecationWarnings() {
488  $deprecatedHooks = $this->registry->getDeprecatedHooks();
489  $registeredHooks = $this->registry->getExtensionHooks();
490  foreach ( $registeredHooks as $name => $handlers ) {
491  if ( $deprecatedHooks->isHookDeprecated( $name ) ) {
492  $deprecationInfo = $deprecatedHooks->getDeprecationInfo( $name );
493  if ( !empty( $deprecationInfo['silent'] ) ) {
494  continue;
495  }
496  $version = $deprecationInfo['deprecatedVersion'] ?? '';
497  $component = $deprecationInfo['component'] ?? 'MediaWiki';
498  foreach ( $handlers as $handler ) {
499  if ( !isset( $handler['deprecated'] ) || !$handler['deprecated'] ) {
501  "Hook $name was deprecated in $component $version " .
502  "but is registered in " . $handler['extensionPath']
503  );
504  }
505  }
506  }
507  }
508  }
509 }
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:327
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:487
MediaWiki\HookContainer\HookContainer\isRegistered
isRegistered(string $hook)
Return whether hook has any handlers registered to it.
Definition: HookContainer.php:348
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:396
wfDeprecatedMsg
wfDeprecatedMsg( $msg, $version=false, $component=false, $callerOffset=2)
Log a deprecation warning with arbitrary message text.
Definition: GlobalFunctions.php:1028
MWException
MediaWiki exception.
Definition: MWException.php:29
wfDeprecated
wfDeprecated( $function, $version=false, $component=false, $callerOffset=2)
Logs a warning that a deprecated feature was used.
Definition: GlobalFunctions.php:997
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:441
$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:367