1. <?php
  2. /**
  3. * Plugin API: WP_Hook class
  4. *
  5. * @package WordPress
  6. * @subpackage Plugin
  7. * @since 4.7.0
  8. */
  9. /**
  10. * Core class used to implement action and filter hook functionality.
  11. *
  12. * @since 4.7.0
  13. *
  14. * @see Iterator
  15. * @see ArrayAccess
  16. */
  17. #[AllowDynamicProperties]
  18. final class WP_Hook implements Iterator, ArrayAccess {
  19. /**
  20. * Hook callbacks.
  21. *
  22. * @since 4.7.0
  23. * @var array
  24. */
  25. public $callbacks = array();
  26. /**
  27. * Priorities list.
  28. *
  29. * @since 6.4.0
  30. * @var array
  31. */
  32. protected $priorities = array();
  33. /**
  34. * The priority keys of actively running iterations of a hook.
  35. *
  36. * @since 4.7.0
  37. * @var array
  38. */
  39. private $iterations = array();
  40. /**
  41. * The current priority of actively running iterations of a hook.
  42. *
  43. * @since 4.7.0
  44. * @var array
  45. */
  46. private $current_priority = array();
  47. /**
  48. * Number of levels this hook can be recursively called.
  49. *
  50. * @since 4.7.0
  51. * @var int
  52. */
  53. private $nesting_level = 0;
  54. /**
  55. * Flag for if we're currently doing an action, rather than a filter.
  56. *
  57. * @since 4.7.0
  58. * @var bool
  59. */
  60. private $doing_action = false;
  61. /**
  62. * Adds a callback function to a filter hook.
  63. *
  64. * @since 4.7.0
  65. *
  66. * @param string $hook_name The name of the filter to add the callback to.
  67. * @param callable $callback The callback to be run when the filter is applied.
  68. * @param int $priority The order in which the functions associated with a particular filter
  69. * are executed. Lower numbers correspond with earlier execution,
  70. * and functions with the same priority are executed in the order
  71. * in which they were added to the filter.
  72. * @param int $accepted_args The number of arguments the function accepts.
  73. */
  74. public function add_filter( $hook_name, $callback, $priority, $accepted_args ) {
  75. $idx = _wp_filter_build_unique_id( $hook_name, $callback, $priority );
  76. $priority_existed = isset( $this->callbacks[ $priority ] );
  77. $this->callbacks[ $priority ][ $idx ] = array(
  78. 'function' => $callback,
  79. 'accepted_args' => (int) $accepted_args,
  80. );
  81. // If we're adding a new priority to the list, put them back in sorted order.
  82. if ( ! $priority_existed && count( $this->callbacks ) > 1 ) {
  83. ksort( $this->callbacks, SORT_NUMERIC );
  84. }
  85. $this->priorities = array_keys( $this->callbacks );
  86. if ( $this->nesting_level > 0 ) {
  87. $this->resort_active_iterations( $priority, $priority_existed );
  88. }
  89. }
  90. /**
  91. * Handles resetting callback priority keys mid-iteration.
  92. *
  93. * @since 4.7.0
  94. *
  95. * @param false|int $new_priority Optional. The priority of the new filter being added. Default false,
  96. * for no priority being added.
  97. * @param bool $priority_existed Optional. Flag for whether the priority already existed before the new
  98. * filter was added. Default false.
  99. */
  100. private function resort_active_iterations( $new_priority = false, $priority_existed = false ) {
  101. $new_priorities = $this->priorities;
  102. // If there are no remaining hooks, clear out all running iterations.
  103. if ( ! $new_priorities ) {
  104. foreach ( $this->iterations as $index => $iteration ) {
  105. $this->iterations[ $index ] = $new_priorities;
  106. }
  107. return;
  108. }
  109. $min = min( $new_priorities );
  110. foreach ( $this->iterations as $index => &$iteration ) {
  111. $current = current( $iteration );
  112. // If we're already at the end of this iteration, just leave the array pointer where it is.
  113. if ( false === $current ) {
  114. continue;
  115. }
  116. $iteration = $new_priorities;
  117. if ( $current < $min ) {
  118. array_unshift( $iteration, $current );
  119. continue;
  120. }
  121. while ( current( $iteration ) < $current ) {
  122. if ( false === next( $iteration ) ) {
  123. break;
  124. }
  125. }
  126. // If we have a new priority that didn't exist, but ::apply_filters() or ::do_action() thinks it's the current priority...
  127. if ( $new_priority === $this->current_priority[ $index ] && ! $priority_existed ) {
  128. /*
  129. * ...and the new priority is the same as what $this->iterations thinks is the previous
  130. * priority, we need to move back to it.
  131. */
  132. if ( false === current( $iteration ) ) {
  133. // If we've already moved off the end of the array, go back to the last element.
  134. $prev = end( $iteration );
  135. } else {
  136. // Otherwise, just go back to the previous element.
  137. $prev = prev( $iteration );
  138. }
  139. if ( false === $prev ) {
  140. // Start of the array. Reset, and go about our day.
  141. reset( $iteration );
  142. } elseif ( $new_priority !== $prev ) {
  143. // Previous wasn't the same. Move forward again.
  144. next( $iteration );
  145. }
  146. }
  147. }
  148. unset( $iteration );
  149. }
  150. /**
  151. * Removes a callback function from a filter hook.
  152. *
  153. * @since 4.7.0
  154. *
  155. * @param string $hook_name The filter hook to which the function to be removed is hooked.
  156. * @param callable|string|array $callback The callback to be removed from running when the filter is applied.
  157. * This method can be called unconditionally to speculatively remove
  158. * a callback that may or may not exist.
  159. * @param int $priority The exact priority used when adding the original filter callback.
  160. * @return bool Whether the callback existed before it was removed.
  161. */
  162. public function remove_filter( $hook_name, $callback, $priority ) {
  163. $function_key = _wp_filter_build_unique_id( $hook_name, $callback, $priority );
  164. $exists = isset( $this->callbacks[ $priority ][ $function_key ] );
  165. if ( $exists ) {
  166. unset( $this->callbacks[ $priority ][ $function_key ] );
  167. if ( ! $this->callbacks[ $priority ] ) {
  168. unset( $this->callbacks[ $priority ] );
  169. $this->priorities = array_keys( $this->callbacks );
  170. if ( $this->nesting_level > 0 ) {
  171. $this->resort_active_iterations();
  172. }
  173. }
  174. }
  175. return $exists;
  176. }
  177. /**
  178. * Checks if a specific callback has been registered for this hook.
  179. *
  180. * When using the `$callback` argument, this function may return a non-boolean value
  181. * that evaluates to false (e.g. 0), so use the `===` operator for testing the return value.
  182. *
  183. * @since 4.7.0
  184. *
  185. * @param string $hook_name Optional. The name of the filter hook. Default empty.
  186. * @param callable|string|array|false $callback Optional. The callback to check for.
  187. * This method can be called unconditionally to speculatively check
  188. * a callback that may or may not exist. Default false.
  189. * @return bool|int If `$callback` is omitted, returns boolean for whether the hook has
  190. * anything registered. When checking a specific function, the priority
  191. * of that hook is returned, or false if the function is not attached.
  192. */
  193. public function has_filter( $hook_name = '', $callback = false ) {
  194. if ( false === $callback ) {
  195. return $this->has_filters();
  196. }
  197. $function_key = _wp_filter_build_unique_id( $hook_name, $callback, false );
  198. if ( ! $function_key ) {
  199. return false;
  200. }
  201. foreach ( $this->callbacks as $priority => $callbacks ) {
  202. if ( isset( $callbacks[ $function_key ] ) ) {
  203. return $priority;
  204. }
  205. }
  206. return false;
  207. }
  208. /**
  209. * Checks if any callbacks have been registered for this hook.
  210. *
  211. * @since 4.7.0
  212. *
  213. * @return bool True if callbacks have been registered for the current hook, otherwise false.
  214. */
  215. public function has_filters() {
  216. foreach ( $this->callbacks as $callbacks ) {
  217. if ( $callbacks ) {
  218. return true;
  219. }
  220. }
  221. return false;
  222. }
  223. /**
  224. * Removes all callbacks from the current filter.
  225. *
  226. * @since 4.7.0
  227. *
  228. * @param int|false $priority Optional. The priority number to remove. Default false.
  229. */
  230. public function remove_all_filters( $priority = false ) {
  231. if ( ! $this->callbacks ) {
  232. return;
  233. }
  234. if ( false === $priority ) {
  235. $this->callbacks = array();
  236. $this->priorities = array();
  237. } elseif ( isset( $this->callbacks[ $priority ] ) ) {
  238. unset( $this->callbacks[ $priority ] );
  239. $this->priorities = array_keys( $this->callbacks );
  240. }
  241. if ( $this->nesting_level > 0 ) {
  242. $this->resort_active_iterations();
  243. }
  244. }
  245. /**
  246. * Calls the callback functions that have been added to a filter hook.
  247. *
  248. * @since 4.7.0
  249. *
  250. * @param mixed $value The value to filter.
  251. * @param array $args Additional parameters to pass to the callback functions.
  252. * This array is expected to include $value at index 0.
  253. * @return mixed The filtered value after all hooked functions are applied to it.
  254. */
  255. public function apply_filters( $value, $args ) {
  256. if ( ! $this->callbacks ) {
  257. return $value;
  258. }
  259. $nesting_level = $this->nesting_level++;
  260. $this->iterations[ $nesting_level ] = $this->priorities;
  261. $num_args = count( $args );
  262. do {
  263. $this->current_priority[ $nesting_level ] = current( $this->iterations[ $nesting_level ] );
  264. $priority = $this->current_priority[ $nesting_level ];
  265. foreach ( $this->callbacks[ $priority ] as $the_ ) {
  266. if ( ! $this->doing_action ) {
  267. $args[0] = $value;
  268. }
  269. // Avoid the array_slice() if possible.
  270. if ( 0 === $the_['accepted_args'] ) {
  271. $value = call_user_func( $the_['function'] );
  272. } elseif ( $the_['accepted_args'] >= $num_args ) {
  273. $value = call_user_func_array( $the_['function'], $args );
  274. } else {
  275. $value = call_user_func_array( $the_['function'], array_slice( $args, 0, $the_['accepted_args'] ) );
  276. }
  277. }
  278. } while ( false !== next( $this->iterations[ $nesting_level ] ) );
  279. unset( $this->iterations[ $nesting_level ] );
  280. unset( $this->current_priority[ $nesting_level ] );
  281. --$this->nesting_level;
  282. return $value;
  283. }
  284. /**
  285. * Calls the callback functions that have been added to an action hook.
  286. *
  287. * @since 4.7.0
  288. *
  289. * @param array $args Parameters to pass to the callback functions.
  290. */
  291. public function do_action( $args ) {
  292. $this->doing_action = true;
  293. $this->apply_filters( '', $args );
  294. // If there are recursive calls to the current action, we haven't finished it until we get to the last one.
  295. if ( ! $this->nesting_level ) {
  296. $this->doing_action = false;
  297. }
  298. }
  299. /**
  300. * Processes the functions hooked into the 'all' hook.
  301. *
  302. * @since 4.7.0
  303. *
  304. * @param array $args Arguments to pass to the hook callbacks. Passed by reference.
  305. */
  306. public function do_all_hook( &$args ) {
  307. $nesting_level = $this->nesting_level++;
  308. $this->iterations[ $nesting_level ] = $this->priorities;
  309. do {
  310. $priority = current( $this->iterations[ $nesting_level ] );
  311. foreach ( $this->callbacks[ $priority ] as $the_ ) {
  312. call_user_func_array( $the_['function'], $args );
  313. }
  314. } while ( false !== next( $this->iterations[ $nesting_level ] ) );
  315. unset( $this->iterations[ $nesting_level ] );
  316. --$this->nesting_level;
  317. }
  318. /**
  319. * Return the current priority level of the currently running iteration of the hook.
  320. *
  321. * @since 4.7.0
  322. *
  323. * @return int|false If the hook is running, return the current priority level.
  324. * If it isn't running, return false.
  325. */
  326. public function current_priority() {
  327. if ( false === current( $this->iterations ) ) {
  328. return false;
  329. }
  330. return current( current( $this->iterations ) );
  331. }
  332. /**
  333. * Normalizes filters set up before WordPress has initialized to WP_Hook objects.
  334. *
  335. * The `$filters` parameter should be an array keyed by hook name, with values
  336. * containing either:
  337. *
  338. * - A `WP_Hook` instance
  339. * - An array of callbacks keyed by their priorities
  340. *
  341. * Examples:
  342. *
  343. * $filters = array(
  344. * 'wp_fatal_error_handler_enabled' => array(
  345. * 10 => array(
  346. * array(
  347. * 'accepted_args' => 0,
  348. * 'function' => function() {
  349. * return false;
  350. * },
  351. * ),
  352. * ),
  353. * ),
  354. * );
  355. *
  356. * @since 4.7.0
  357. *
  358. * @param array $filters Filters to normalize. See documentation above for details.
  359. * @return WP_Hook[] Array of normalized filters.
  360. */
  361. public static function build_preinitialized_hooks( $filters ) {
  362. /** @var WP_Hook[] $normalized */
  363. $normalized = array();
  364. foreach ( $filters as $hook_name => $callback_groups ) {
  365. if ( $callback_groups instanceof WP_Hook ) {
  366. $normalized[ $hook_name ] = $callback_groups;
  367. continue;
  368. }
  369. $hook = new WP_Hook();
  370. // Loop through callback groups.
  371. foreach ( $callback_groups as $priority => $callbacks ) {
  372. // Loop through callbacks.
  373. foreach ( $callbacks as $cb ) {
  374. $hook->add_filter( $hook_name, $cb['function'], $priority, $cb['accepted_args'] );
  375. }
  376. }
  377. $normalized[ $hook_name ] = $hook;
  378. }
  379. return $normalized;
  380. }
  381. /**
  382. * Determines whether an offset value exists.
  383. *
  384. * @since 4.7.0
  385. *
  386. * @link https://www.php.net/manual/en/arrayaccess.offsetexists.php
  387. *
  388. * @param mixed $offset An offset to check for.
  389. * @return bool True if the offset exists, false otherwise.
  390. */
  391. #[ReturnTypeWillChange]
  392. public function offsetExists( $offset ) {
  393. return isset( $this->callbacks[ $offset ] );
  394. }
  395. /**
  396. * Retrieves a value at a specified offset.
  397. *
  398. * @since 4.7.0
  399. *
  400. * @link https://www.php.net/manual/en/arrayaccess.offsetget.php
  401. *
  402. * @param mixed $offset The offset to retrieve.
  403. * @return mixed If set, the value at the specified offset, null otherwise.
  404. */
  405. #[ReturnTypeWillChange]
  406. public function offsetGet( $offset ) {
  407. return isset( $this->callbacks[ $offset ] ) ? $this->callbacks[ $offset ] : null;
  408. }
  409. /**
  410. * Sets a value at a specified offset.
  411. *
  412. * @since 4.7.0
  413. *
  414. * @link https://www.php.net/manual/en/arrayaccess.offsetset.php
  415. *
  416. * @param mixed $offset The offset to assign the value to.
  417. * @param mixed $value The value to set.
  418. */
  419. #[ReturnTypeWillChange]
  420. public function offsetSet( $offset, $value ) {
  421. if ( is_null( $offset ) ) {
  422. $this->callbacks[] = $value;
  423. } else {
  424. $this->callbacks[ $offset ] = $value;
  425. }
  426. $this->priorities = array_keys( $this->callbacks );
  427. }
  428. /**
  429. * Unsets a specified offset.
  430. *
  431. * @since 4.7.0
  432. *
  433. * @link https://www.php.net/manual/en/arrayaccess.offsetunset.php
  434. *
  435. * @param mixed $offset The offset to unset.
  436. */
  437. #[ReturnTypeWillChange]
  438. public function offsetUnset( $offset ) {
  439. unset( $this->callbacks[ $offset ] );
  440. $this->priorities = array_keys( $this->callbacks );
  441. }
  442. /**
  443. * Returns the current element.
  444. *
  445. * @since 4.7.0
  446. *
  447. * @link https://www.php.net/manual/en/iterator.current.php
  448. *
  449. * @return array Of callbacks at current priority.
  450. */
  451. #[ReturnTypeWillChange]
  452. public function current() {
  453. return current( $this->callbacks );
  454. }
  455. /**
  456. * Moves forward to the next element.
  457. *
  458. * @since 4.7.0
  459. *
  460. * @link https://www.php.net/manual/en/iterator.next.php
  461. *
  462. * @return array Of callbacks at next priority.
  463. */
  464. #[ReturnTypeWillChange]
  465. public function next() {
  466. return next( $this->callbacks );
  467. }
  468. /**
  469. * Returns the key of the current element.
  470. *
  471. * @since 4.7.0
  472. *
  473. * @link https://www.php.net/manual/en/iterator.key.php
  474. *
  475. * @return mixed Returns current priority on success, or NULL on failure
  476. */
  477. #[ReturnTypeWillChange]
  478. public function key() {
  479. return key( $this->callbacks );
  480. }
  481. /**
  482. * Checks if current position is valid.
  483. *
  484. * @since 4.7.0
  485. *
  486. * @link https://www.php.net/manual/en/iterator.valid.php
  487. *
  488. * @return bool Whether the current position is valid.
  489. */
  490. #[ReturnTypeWillChange]
  491. public function valid() {
  492. return key( $this->callbacks ) !== null;
  493. }
  494. /**
  495. * Rewinds the Iterator to the first element.
  496. *
  497. * @since 4.7.0
  498. *
  499. * @link https://www.php.net/manual/en/iterator.rewind.php
  500. */
  501. #[ReturnTypeWillChange]
  502. public function rewind() {
  503. reset( $this->callbacks );
  504. }
  505. }