MediaWiki REL1_39
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 $deprecatedVersion = $info['deprecatedVersion'] ?? false;
379 $component = $info['component'] ?? false;
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'] ) {
500 MWDebug::sendRawDeprecated(
501 "Hook $name was deprecated in $component $version " .
502 "but is registered in " . $handler['extensionPath']
503 );
504 }
505 }
506 }
507 }
508 }
509}
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:82
New debugger system that outputs a toolbar on page view.
Definition MWDebug.php:36
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.
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.
if( $line===false) $args
Definition mcc.php:124