Merge pull request #23042 from eileenmcnaughton/sane
[civicrm-core.git] / Civi / Core / Event / EventScanner.php
CommitLineData
b90e57be
TO
1<?php
2
3namespace Civi\Core\Event;
4
5/**
6 * The `EventScanner` is a utility for scanning a class to see if it has any event-listeners. It may check
7 * for common interfaces and conventions. Example:
8 *
9 * ```
10 * $map = EventScanner::findListeners($someObject);
11 * $dispatcher->addListenerMap($someObject, $map);
12 * ```
13 */
14class EventScanner {
15
16 /**
17 * In-memory cache of the listener-maps found on various classes.
18 *
19 * This cache is a little unusual -- it is geared toward improving unit-tests. Bear in mind:
20 *
21 * - The `EventScanner` is fundamentally scanning class-structure.
22 * - Within a given PHP process, the class-structure cannot change. Therefore, the cached view in `static::$listenerMaps` cannot be stale.
23 * - There are three kinds of PHP processes:
24 * 1. System-flushes -- During this operation, we rebuild the `Container`. This may do some scanning, and the results will be recorded in `Container`.
25 * 2. Ordinary page-loads -- We use the `Container` cache. It shouldn't need any more scans.
26 * 3. Headless unit-tests -- For these, we must frequently tear-down and rebuild a fresh `Container`, often with varying decisions about
27 * which extensions/services/classes to activate. The container-cache does not operate.
28 *
29 * Here's how `$listenerMaps` plays out in each:
30 *
31 * 1. The `$listenerMaps` is not needed or used.
32 * 2. The `$listenerMaps` (and `EventScanner` generally) is not needed or used.
33 * 3. The `$listenerMaps` is used frequently, preventing redundant scanning.
34 *
35 * A more common approach would be to use `Civi::$statics` or `Civi::cache()`. These would be inappropriate because we want the data to be
36 * preserved across multiple test-runs -- and because the underlying data (PHP class-structure) does not change within a unit-test.
37 *
38 * @var array
39 * Ex: ['api_v3_SyntaxConformanceTest' => [...listener-names...]]
40 */
41 private static $listenerMaps = [];
42
43 /**
44 * Scan an object or class for event listeners.
45 *
46 * Note: This requires scanning. Consequently, it should not be run in bulk on a regular (runtime) basis. Instead, store
47 * the listener-maps in a cache (e.g. `Container`).
48 *
49 * @param string|object $target
50 * The object/class which will receive the notifications.
51 * Use a string (class-name) if the listeners are static methods.
52 * Use an object-instance if the listeners are regular methods.
53 * @param string|null $self
54 * If the target $class is focused on a specific entity/form/etc, use the `$self` parameter to specify it.
55 * This will activate support for `self_{$event}` methods.
42f0bf16 56 * Ex: if '$self' is 'Contact', then 'function self_hook_civicrm_pre()' maps to 'on_hook_civicrm_pre::Contact'.
b90e57be
TO
57 * @return array
58 * List of events/listeners. Format is compatible with 'getSubscribedEvents()'.
59 * Ex: ['some.event' => [['firstFunc'], ['secondFunc']]
60 */
60a1dbc1 61 public static function findListeners($target, $self = NULL): array {
b90e57be
TO
62 $class = is_object($target) ? get_class($target) : $target;
63 $key = "$class::" . ($self ?: '');
64 if (isset(self::$listenerMaps[$key])) {
65 return self::$listenerMaps[$key];
66 }
67
68 $listenerMap = [];
ee44263e 69 // These 2 interfaces do the same thing; one is meant for unit tests and the other for runtime code
d5f5cc4c 70 if (is_subclass_of($class, '\Civi\Core\HookInterface')) {
b90e57be
TO
71 $listenerMap = static::mergeListenerMap($listenerMap, static::findFunctionListeners($class, $self));
72 }
73 if (is_subclass_of($class, '\Symfony\Component\EventDispatcher\EventSubscriberInterface')) {
74 $listenerMap = static::mergeListenerMap($listenerMap, static::normalizeListenerMap($class::getSubscribedEvents()));
75 }
76
77 if (CIVICRM_UF === 'UnitTests') {
78 self::$listenerMaps[$key] = $listenerMap;
79 }
80 return $listenerMap;
81 }
82
83 /**
84 * @param string $class
85 * @param string|null $self
86 * If the target $class is focused on a specific entity/form/etc, use the `$self` parameter to specify it.
87 * This will activate support for `self_{$event}` methods.
88 * Ex: if '$self' is 'Contact', then 'function self_hook_civicrm_pre()' maps to 'hook_civicrm_pre::Contact'.
60a1dbc1 89 * @return array
b90e57be 90 */
60a1dbc1 91 protected static function findFunctionListeners(string $class, $self = NULL): array {
b90e57be
TO
92 $listenerMap = [];
93
94 /**
95 * @param string $underscore
96 * Ex: 'civi_foo_bar', 'hook_civicrm_foo'
97 * @return string
98 * Ex: 'civi.foo.bar', 'hook_civicrm_foo'
99 */
100 $toEventName = function ($underscore) {
101 if (substr($underscore, 0, 5) === 'hook_') {
102 return $underscore;
103 }
104 else {
105 return str_replace('_', '.', $underscore);
106 }
107 };
108
109 $addListener = function ($event, $func, $priority = 0) use (&$listenerMap) {
110 $listenerMap[$event][] = [$func, $priority];
111 };
112
113 foreach (get_class_methods($class) as $func) {
114 if (preg_match('/^(hook_|on_|self_)/', $func, $m)) {
115 switch ($m[1]) {
116 case 'hook_':
117 $addListener('&' . $func, $func);
118 break;
119
120 case 'on_':
121 $addListener($toEventName(substr($func, 3)), $func);
122 break;
123
124 case 'self_':
125 if ($self === NULL) {
126 throw new \RuntimeException("Cannot add self_*() listeners for $class");
127 }
128 $addListener($toEventName(substr($func, 5)) . '::' . $self, $func);
129 break;
130 }
131 }
132 }
133
134 return $listenerMap;
135 }
136
137 /**
138 * Convert the listeners to a standard flavor.
139 *
60a1dbc1 140 * @param iterable $listenerMap
b90e57be
TO
141 * List of events/listeners. Listeners may be given in singular or plural form.
142 * Ex: ['some.event' => 'oneListener']
143 * Ex: ['some.event' => ['oneListener', 100]]
144 * Ex: ['some.event' => [['firstListener', 100], ['secondListener']]]
145 * @return array
146 * List of events/listeners. All listeners are described in plural form.
147 * Ex: ['some.event' => [['firstListener', 100], ['secondListener']]]
148 */
60a1dbc1 149 protected static function normalizeListenerMap(iterable $listenerMap): array {
b90e57be
TO
150 $r = [];
151 foreach ($listenerMap as $eventName => $params) {
152 $r[$eventName] = [];
153 if (\is_string($params)) {
154 $r[$eventName][] = [$params];
155 }
156 elseif (\is_string($params[0])) {
157 $r[$eventName][] = $params;
158 }
159 else {
160 $r[$eventName] = array_merge($r[$eventName], $params);
161 }
162 }
163 return $r;
164 }
165
60a1dbc1 166 protected static function mergeListenerMap(array $left, array $right): array {
b90e57be
TO
167 if ($left === []) {
168 return $right;
169 }
170 foreach ($right as $eventName => $listeners) {
171 $left[$eventName] = array_merge($left[$eventName] ?? [], $listeners);
172 }
173 return $left;
174 }
175
176}