1: <?php
2:
3: declare(strict_types=1);
4:
5: namespace Atk4\Core;
6:
7: trait HookTrait
8: {
9: /**
10: * Contains information about configured hooks (callbacks).
11: *
12: * @var array<string, array<int, array<int, array{\Closure, 1?: array<int, mixed>}>>>
13: */
14: protected array $hooks = [];
15:
16: /** Next hook index counter. */
17: private int $_hookIndexCounter = 0;
18:
19: /** @var \WeakReference<static>|null */
20: private ?\WeakReference $_hookOrigThis = null;
21:
22: /**
23: * Optimize GC. When a Closure is guaranteed to be rebound before invoke, it can be rebound
24: * to (deduplicated) fake instance before safely.
25: */
26: private function _rebindHookFxToFakeInstance(\Closure $fx): \Closure
27: {
28: $fxThis = (new \ReflectionFunction($fx))->getClosureThis();
29:
30: $instanceWithoutConstructorCache = new class() {
31: /** @var array<class-string, object> */
32: private static array $_instances = [];
33:
34: /**
35: * @param class-string $class
36: */
37: public function getInstance(string $class): object
38: {
39: if (!isset(self::$_instances[$class])) {
40: $dummyInstance = (new \ReflectionClass($class))->newInstanceWithoutConstructor();
41: foreach ([$class, ...array_keys(class_parents($class))] as $scope) {
42: \Closure::bind(static function () use ($dummyInstance) {
43: foreach (array_keys(get_object_vars($dummyInstance)) as $k) {
44: unset($dummyInstance->{$k});
45: }
46: }, null, $scope)();
47: }
48:
49: self::$_instances[$class] = $dummyInstance;
50: }
51:
52: return self::$_instances[$class];
53: }
54: };
55: $fakeThis = $instanceWithoutConstructorCache->getInstance(get_class($fxThis));
56:
57: return \Closure::bind($fx, $fakeThis);
58: }
59:
60: /**
61: * When hook Closure is bound to $this, rebinding all hooks after clone can be slow, optimize clone
62: * by unbinding $this in favor of rebinding $this when hook is invoked.
63: */
64: private function _unbindHookFxIfBoundToThis(\Closure $fx, bool $isShort): \Closure
65: {
66: $fxThis = (new \ReflectionFunction($fx))->getClosureThis();
67: if ($fxThis !== $this) {
68: return $fx;
69: }
70:
71: $fx = $this->_rebindHookFxToFakeInstance($fx);
72:
73: return $this->_makeHookDynamicFx(null, $fx, $isShort);
74: }
75:
76: private function _rebindHooksIfCloned(): void
77: {
78: if ($this->_hookOrigThis !== null) {
79: $hookOrigThis = $this->_hookOrigThis->get();
80: if ($hookOrigThis === $this) {
81: return;
82: }
83:
84: foreach ($this->hooks as $spot => $hooksByPriority) {
85: foreach ($hooksByPriority as $priority => $hooksByIndex) {
86: foreach ($hooksByIndex as $index => $hookData) {
87: $fxRefl = new \ReflectionFunction($hookData[0]);
88: $fxThis = $fxRefl->getClosureThis();
89: if ($fxThis === null) {
90: continue;
91: }
92:
93: // TODO we throw only if the class name is the same, otherwise the check is too strict
94: // and on a bad side - we should not throw when an object with a hook is cloned,
95: // but instead we should throw once the closure this object is cloned
96: // example of legit use: https://github.com/atk4/audit/blob/eb9810e085a40caedb435044d7318f4d8dd93e11/src/Controller.php#L85
97: if (get_class($fxThis) === static::class || preg_match('~^Atk4\\\\(?:Core|Data)~', get_class($fxThis))) {
98: throw (new Exception('Object cannot be cloned with hook bound to a different object than this'))
99: ->addMoreInfo('closure_file', $fxRefl->getFileName())
100: ->addMoreInfo('closure_start_line', $fxRefl->getStartLine());
101: }
102: }
103: }
104: }
105: }
106:
107: $this->_hookOrigThis = \WeakReference::create($this);
108: }
109:
110: /**
111: * Add another callback to be executed during hook($spot);.
112: *
113: * Lower priority is called sooner.
114: *
115: * If priority is negative, then hook is prepended (executed first for the same priority).
116: *
117: * @param array<int, mixed> $args
118: *
119: * @return int index under which the hook was added
120: */
121: public function onHook(string $spot, \Closure $fx, array $args = [], int $priority = 5): int
122: {
123: $this->_rebindHooksIfCloned();
124:
125: $fx = $this->_unbindHookFxIfBoundToThis($fx, false);
126:
127: $index = $this->_hookIndexCounter++;
128: $data = [$fx, $args];
129: if ($priority < 0) {
130: $this->hooks[$spot][$priority] = [$index => $data] + ($this->hooks[$spot][$priority] ?? []);
131: } else {
132: $this->hooks[$spot][$priority][$index] = $data;
133: }
134:
135: return $index;
136: }
137:
138: /**
139: * Same as onHook() except no $this is passed to the callback as the 1st argument.
140: *
141: * @param array<int, mixed> $args
142: *
143: * @return int index under which the hook was added
144: */
145: public function onHookShort(string $spot, \Closure $fx, array $args = [], int $priority = 5): int
146: {
147: // create long callback and bind it to the same scope class and object
148: $fxRefl = new \ReflectionFunction($fx);
149: $fxScopeClassRefl = $fxRefl->getClosureScopeClass();
150: $fxThis = $fxRefl->getClosureThis();
151: if ($fxScopeClassRefl === null) {
152: $fxLong = static function ($ignore, &...$args) use ($fx) {
153: return $fx(...$args);
154: };
155: } elseif ($fxThis === null) {
156: $fxLong = \Closure::bind(static function ($ignore, &...$args) use ($fx) {
157: return $fx(...$args);
158: }, null, $fxScopeClassRefl->getName());
159: } else {
160: $fxLong = $this->_unbindHookFxIfBoundToThis($fx, true);
161: if ($fxLong === $fx) {
162: $fx = $this->_rebindHookFxToFakeInstance($fx);
163:
164: $fxLong = \Closure::bind(function ($ignore, &...$args) use ($fx) {
165: return \Closure::bind($fx, $this)(...$args);
166: }, $fxThis, $fxScopeClassRefl->getName());
167: }
168: }
169:
170: return $this->onHook($spot, $fxLong, $args, $priority);
171: }
172:
173: /**
174: * @param \Closure($this): object $getFxThisFx
175: */
176: private function _makeHookDynamicFx(?\Closure $getFxThisFx, \Closure $fx, bool $isShort): \Closure
177: {
178: if ($getFxThisFx !== null) {
179: $getFxThisFxThis = (new \ReflectionFunction($getFxThisFx))->getClosureThis();
180: if ($getFxThisFxThis !== null) {
181: throw new \TypeError('New $this getter must be static');
182: }
183: }
184:
185: $fx = $this->_rebindHookFxToFakeInstance($fx);
186:
187: return static function (self $target, &...$args) use ($getFxThisFx, $fx, $isShort) {
188: if ($getFxThisFx === null) {
189: $fxThis = $target;
190: } else {
191: $fxThis = $getFxThisFx($target); // @phpstan-ignore-line
192: if (!is_object($fxThis)) { // @phpstan-ignore-line
193: throw new \TypeError('New $this must be an object');
194: }
195: }
196:
197: return $isShort
198: ? \Closure::bind($fx, $fxThis)(...$args)
199: : \Closure::bind($fx, $fxThis)($target, ...$args);
200: };
201: }
202:
203: /**
204: * Same as onHook() except $this of the callback is dynamically rebound before invoke.
205: *
206: * @param \Closure($this): object $getFxThisFx
207: * @param array<int, mixed> $args
208: *
209: * @return int index under which the hook was added
210: */
211: public function onHookDynamic(string $spot, \Closure $getFxThisFx, \Closure $fx, array $args = [], int $priority = 5): int
212: {
213: // @phpstan-ignore-next-line https://github.com/phpstan/phpstan/issues/9022
214: return $this->onHook($spot, $this->_makeHookDynamicFx($getFxThisFx, $fx, false), $args, $priority); // @phpstan-ignore-line https://github.com/phpstan/phpstan/issues/9009
215: }
216:
217: /**
218: * Same as onHookDynamic() except no $this is passed to the callback as the 1st argument.
219: *
220: * @param \Closure($this): object $getFxThisFx
221: * @param array<int, mixed> $args
222: *
223: * @return int index under which the hook was added
224: */
225: public function onHookDynamicShort(string $spot, \Closure $getFxThisFx, \Closure $fx, array $args = [], int $priority = 5): int
226: {
227: // @phpstan-ignore-next-line https://github.com/phpstan/phpstan/issues/9022
228: return $this->onHook($spot, $this->_makeHookDynamicFx($getFxThisFx, $fx, true), $args, $priority); // @phpstan-ignore-line https://github.com/phpstan/phpstan/issues/9009
229: }
230:
231: /**
232: * Returns true if at least one callback is defined for this hook.
233: *
234: * @param ($priorityIsIndex is true ? int : int|null) $priority filter specific priority, null for all
235: * @param bool $priorityIsIndex filter by index instead of priority
236: */
237: public function hookHasCallbacks(string $spot, int $priority = null, bool $priorityIsIndex = false): bool
238: {
239: if (!isset($this->hooks[$spot])) {
240: return false;
241: } elseif ($priority === null) {
242: return true;
243: }
244:
245: if ($priorityIsIndex) {
246: $index = $priority;
247: unset($priority);
248:
249: foreach (array_keys($this->hooks[$spot]) as $priority) {
250: if (isset($this->hooks[$spot][$priority][$index])) {
251: return true;
252: }
253: }
254:
255: return false;
256: }
257:
258: return isset($this->hooks[$spot][$priority]);
259: }
260:
261: /**
262: * Delete all hooks for specified spot, priority and index.
263: *
264: * @param ($priorityIsIndex is true ? int : int|null) $priority filter specific priority, null for all
265: * @param bool $priorityIsIndex filter by index instead of priority
266: *
267: * @return static
268: */
269: public function removeHook(string $spot, int $priority = null, bool $priorityIsIndex = false)
270: {
271: if ($priority !== null) {
272: if ($priorityIsIndex) {
273: $index = $priority;
274: unset($priority);
275:
276: foreach (array_keys($this->hooks[$spot] ?? []) as $priority) {
277: unset($this->hooks[$spot][$priority][$index]);
278:
279: if ($this->hooks[$spot][$priority] === []) {
280: unset($this->hooks[$spot][$priority]);
281: }
282: }
283: } else {
284: unset($this->hooks[$spot][$priority]);
285: }
286:
287: if (($this->hooks[$spot] ?? null) === []) {
288: unset($this->hooks[$spot]);
289: }
290: } else {
291: unset($this->hooks[$spot]);
292: }
293:
294: return $this;
295: }
296:
297: /**
298: * Execute all closures assigned to $spot.
299: *
300: * @param array<int, mixed> $args
301: *
302: * @return array<int, mixed>|mixed Array of responses indexed by hook indexes or value specified to breakHook
303: */
304: public function hook(string $spot, array $args = [], HookBreaker &$brokenBy = null)
305: {
306: $brokenBy = null;
307: $this->_rebindHooksIfCloned();
308:
309: $return = [];
310: if (isset($this->hooks[$spot])) {
311: krsort($this->hooks[$spot]); // lower priority is called sooner
312: $hooksBackup = $this->hooks[$spot];
313: try {
314: while ($hooks = array_pop($this->hooks[$spot])) {
315: foreach ($hooks as $index => [$hookFx, $hookArgs]) {
316: $return[$index] = $hookFx($this, ...$args, ...$hookArgs);
317: }
318: }
319: } catch (HookBreaker $e) {
320: $brokenBy = $e;
321:
322: return $e->getReturnValue();
323: } finally {
324: $this->hooks[$spot] = $hooksBackup;
325: }
326: }
327:
328: return $return;
329: }
330:
331: /**
332: * When called from inside a hook closure, it will stop execution of other
333: * closures on the same hook. The passed argument will be returned by the
334: * hook method.
335: *
336: * @param mixed $return What would hook() return?
337: */
338: public function breakHook($return): void
339: {
340: throw new HookBreaker($return);
341: }
342: }
343: