MediaWiki REL1_40
HookContainer.php
Go to the documentation of this file.
1<?php
27
28use Closure;
29use MWDebug;
30use MWException;
31use UnexpectedValueException;
32use Wikimedia\Assert\Assert;
33use Wikimedia\NonSerializable\NonSerializableTrait;
34use Wikimedia\ObjectFactory\ObjectFactory;
35use Wikimedia\ScopedCallback;
36use Wikimedia\Services\SalvageableService;
37
45class 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'] ) ) {
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 );
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'] ) {
532 MWDebug::sendRawDeprecated(
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'))
The persistent session ID (if any) loaded at startup.
Definition WebStart.php:88
New debugger system that outputs a toolbar on page view.
Definition MWDebug.php:37
MediaWiki exception.
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.