• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

ICanBoogie / Event / 38639909456

14 Oct 2026 09:23PM UTC coverage: 92.713%. Remained the same
38639909456

push

github

olvlvl
"Hook" is now consistently named "listener"

78 of 83 new or added lines in 5 files covered. (93.98%)

10 existing lines in 2 files now uncovered.

229 of 247 relevant lines covered (92.71%)

6.69 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

97.17
/lib/EventCollection.php
1
<?php
2

3
namespace ICanBoogie;
4

5
use ArrayIterator;
6
use Closure;
7
use Generator;
8
use ICanBoogie\Event\Config;
9
use ICanBoogie\Event\Detach;
10
use IteratorAggregate;
11
use LogicException;
12
use ReflectionException;
13
use SplObjectStorage;
14
use Throwable;
15
use Traversable;
16

17
use function array_diff_key;
18
use function array_intersect_key;
19
use function array_map;
20
use function array_merge;
21
use function array_search;
22
use function array_unique;
23
use function array_unshift;
24
use function array_values;
25
use function assert;
26
use function class_implements;
27
use function count;
28
use function explode;
29
use function get_parent_class;
30
use function is_callable;
31
use function is_object;
32
use function microtime;
33
use function strpos;
34

35
/**
36
 * Events collected from the "listeners" config or attached by the user.
37
 *
38
 * @implements IteratorAggregate<non-empty-string, (callable(Event, ?object): void)[]>
39
 */
40
class EventCollection implements IteratorAggregate
41
{
42
    /**
43
     * Resolves type and listener.
44
     *
45
     * Listeners are accepted whatever the event they are meant for, hence `never` for their parameters. They
46
     * are returned as called by the collection: with any event and sender, but they only ever receive the
47
     * ones they were attached for, which is the type resolved here.
48
     *
49
     * @param (callable(never, never): void)|(callable(never): void)|non-empty-string $type_or_listener
50
     * @param (callable(never, never): void)|(callable(never): void)|null $listener
51
     *
52
     * @return array{ non-empty-string, (callable(Event, ?object): void) } Returns the type and listener.
53
     *
54
     * @throws ReflectionException
55
     */
56
    private static function resolve_type_and_listener(string|callable $type_or_listener, ?callable $listener): array
57
    {
58
        if ($listener === null) {
12 ✔
59
            $type = null;
5 ✔
60
            $listener = $type_or_listener;
5 ✔
61
        } else {
62
            $type = $type_or_listener;
9 ✔
63
        }
64

65
        ListenerReflection::assert_valid($listener);
12 ✔
66

NEW
67
        assert(is_callable($listener));
×
68

69
        if ($type === null) {
12 ✔
70
            $type = ListenerReflection::from($listener)->type;
5 ✔
71
        }
72

73
        assert(is_string($type));
×
74

75
        // @phpstan-ignore return.type (see the description of the method)
76
        return [ $type, $listener ];
12 ✔
77
    }
78

79
    /**
80
     * Event listeners by type.
81
     *
82
     * @var array<non-empty-string, (callable(Event, ?object): void)[]>
83
     *     Where _key_ is an event type and _value_ an array of callables.
84
     */
85
    private array $listeners = [];
86

87
    /**
88
     * Event listeners by class and type.
89
     *
90
     * @var array<string, (callable(Event, ?object): void)[]>
91
     *     Where _key_ is an event type and _value_ an array of callables.
92
     */
93
    private array $consolidated_listeners = [];
94

95
    /**
96
     * @var SplObjectStorage<Closure, callable>
97
     */
98
    private SplObjectStorage $original_listeners;
99

100
    /**
101
     * Skippable events.
102
     *
103
     * @var array<string, true>
104
     *     Where _key_ is an event type.
105
     */
106
    private array $skippable = [];
107

108
    public function __construct(?Config $config = null)
109
    {
110
        $this->original_listeners = new SplObjectStorage();
20 ✔
111

112
        if ($config) {
20 ✔
113
            $this->attach_many($config);
1 ✔
114
        }
115
    }
116

117
    /**
118
     * Returns an iterator for event listeners.
119
     *
120
     * @return Generator<non-empty-string, (callable(Event, ?object): void)[]>
121
     */
122
    public function getIterator(): Generator
123
    {
124
        yield from $this->listeners;
1 ✔
125
    }
126

127
    /**
128
     * Revokes consolidated listeners and skippable types.
129
     */
130
    private function revoke_traces(): void
131
    {
132
        $this->consolidated_listeners = [];
13 ✔
133
        $this->skippable = [];
13 ✔
134
    }
135

136
    /**
137
     * Attaches an event listener.
138
     *
139
     * @template TEvent of Event
140
     * @template TSender of ?object
141
     *
142
     * @param (Closure(TEvent, TSender): void)|(Closure(TEvent): void)|non-empty-string $type_or_listener
143
     *     Event type or event listener.
144
     * @param (Closure(TEvent, TSender): void)|(Closure(TEvent): void)|null $listener
145
     *     The event listener, or nothing if $type is the event listener.
146
     *
147
     * @throws ReflectionException
148
     */
149
    public function attach(Closure|string $type_or_listener, ?Closure $listener = null): Detach
150
    {
151
        [ $type, $listener ] = self::resolve_type_and_listener($type_or_listener, $listener);
12 ✔
152

NEW
153
        assert($listener instanceof Closure);
×
154

155
        $this->listeners[$type] ??= [];
12 ✔
156
        array_unshift($this->listeners[$type], $listener);
12 ✔
157

158
        // Listeners attached to a bare type also apply to the events with a sender, hence the consolidated listeners
159
        // are revoked whatever the type.
160
        $this->revoke_traces();
12 ✔
161

162
        return new Detach($this, $type, $listener);
12 ✔
163
    }
164

165
    /**
166
     * Attaches many event listeners at once.
167
     *
168
     * **Note**: The event listeners must be grouped by event type.
169
     */
170
    public function attach_many(Config $config): void
171
    {
172
        $listeners = &$this->listeners;
1 ✔
173
        $intersect = array_intersect_key($config->listeners, $listeners);
1 ✔
174
        $listeners += array_diff_key($config->listeners, $listeners);
1 ✔
175

176
        foreach ($intersect as $type => $type_listeners) {
1 ✔
177
            $listeners[$type] = array_merge($listeners[$type], $type_listeners);
1 ✔
178
        }
179

180
        $listeners = array_map(function ($callables) {
1 ✔
181
            return array_values(array_unique($callables, SORT_REGULAR));
1 ✔
182
        }, $listeners);
1 ✔
183

184
        $this->revoke_traces();
1 ✔
185
    }
186

187
    /**
188
     * Attaches an event listener to a specific sender.
189
     *
190
     * @template TEvent of Event
191
     * @template TSender of object
192
     *
193
     * @param TSender $sender
194
     * @param Closure(TEvent, TSender): void $listener
195
     *
196
     * @throws ReflectionException
197
     */
198
    public function attach_to(object $sender, Closure $listener): Detach
199
    {
200
        $type = ListenerReflection::from($listener)->type;
1 ✔
201

202
        return $this->attach(
1 ✔
203
            $type,
1 ✔
204
            $this->shadow_original_listener($listener, static function (Event $e, ?object $s) use ($sender, $listener): void {
1 ✔
205
                if ($s !== $sender) {
1 ✔
206
                    return;
1 ✔
207
                }
208

209
                // @phpstan-ignore argument.type (the type of the listener is the type of the event it is attached to)
210
                $listener($e, $s);
1 ✔
211
            })
1 ✔
212
        );
1 ✔
213
    }
214

215
    /**
216
     * Attaches an event listener that is detached once used.
217
     *
218
     * @template TEvent of Event
219
     * @template TSender of ?object
220
     *
221
     * @param (Closure(TEvent, TSender): void)|(Closure(TEvent): void)|non-empty-string $type_or_listener
222
     *     Event type or event listener.
223
     * @param (Closure(TEvent, TSender): void)|(Closure(TEvent): void)|null $listener
224
     *     The event listener, or nothing if $type is the event listener.
225
     *
226
     * @throws ReflectionException
227
     */
228
    public function once(Closure|string $type_or_listener, ?Closure $listener = null): Detach
229
    {
230
        [ $type, $listener ] = self::resolve_type_and_listener($type_or_listener, $listener);
1 ✔
231

232
        $detach = $this->attach(
1 ✔
233
            $type,
1 ✔
234
            $this->shadow_original_listener($listener, static function (Event $e, ?object $s) use ($listener, &$detach): void {
1 ✔
235
                $listener($e, $s);
1 ✔
236

237
                /** @phpstan-ignore-next-line */
238
                $detach();
1 ✔
239
            })
1 ✔
240
        );
1 ✔
241

242
        return $detach;
1 ✔
243
    }
244

245
    /**
246
     * Detaches an event listener.
247
     *
248
     * @param non-empty-string $type The name of the event.
249
     * @param callable $listener The event listener.
250
     *
251
     * @throws LogicException when the event listener is not attached to the event name.
252
     */
253
    public function detach(string $type, callable $listener): void
254
    {
255
        $listeners = &$this->listeners;
6 ✔
256
        $key = $this->search_listener($type, $listener);
6 ✔
257

258
        if ($key === false) {
6 ✔
259
            throw new LogicException("The specified event listener is not attached to `$type`.");
1 ✔
260
        }
261

262
        unset($listeners[$type][$key]);
5 ✔
263

264
        if (!count($listeners[$type])) {
5 ✔
265
            unset($listeners[$type]);
5 ✔
266
        }
267

268
        $this->consolidated_listeners = [];
5 ✔
269
    }
270

271
    /**
272
     * Fires the event.
273
     *
274
     * @template T of Event
275
     *
276
     * @phpstan-param T $event
277
     *
278
     * @return T
279
     *
280
     * @throws Throwable
281
     */
282
    public function emit(Event $event): Event
283
    {
284
        $sender = $event->sender ?? null;
12 ✔
285
        $type = $event->qualified_type;
12 ✔
286

287
        if ($this->is_skippable($type)) {
12 ✔
288
            return $event;
1 ✔
289
        }
290

291
        $listeners = $this->get_listeners($type);
12 ✔
292

293
        if (!$listeners) {
12 ✔
294
            EventProfiler::add_unused($type);
5 ✔
295

296
            $this->skip($type);
5 ✔
297

298
            return $event;
5 ✔
299
        }
300

301
        $this->process_chain($event, $listeners, $type, $sender);
12 ✔
302

303
        if ($event->stopped || !$event->internal_chain) {
12 ✔
304
            return $event;
12 ✔
305
        }
306

307
        $this->process_chain($event, $event->internal_chain, $type, $sender);
1 ✔
308

309
        return $event;
1 ✔
310
    }
311

312
    /**
313
     * Process an event chain.
314
     *
315
     * @param (callable(Event, ?object): void)[] $chain
316
     * @param non-empty-string $type
317
     */
318
    private function process_chain(Event $event, iterable $chain, string $type, ?object $sender): void
319
    {
320
        foreach ($chain as $listener) {
12 ✔
321
            $started_at = microtime(true);
12 ✔
322

323
            try {
324
                $listener($event, $sender);
12 ✔
325
            } finally {
326
                EventProfiler::add_call($type, $this->resolve_original_listener($listener), $started_at);
12 ✔
327
            }
328

329
            if ($event->stopped) {
12 ✔
330
                return;
2 ✔
331
            }
332
        }
333
    }
334

335
    /**
336
     * Marks an event as skippable.
337
     *
338
     * @param string $type The event type.
339
     */
340
    public function skip(string $type): void
341
    {
342
        $this->skippable[$type] = true;
5 ✔
343
    }
344

345
    /**
346
     * Returns whether an event has been marked as skippable.
347
     *
348
     * @param string $type The event type.
349
     */
350
    public function is_skippable(string $type): bool
351
    {
352
        return isset($this->skippable[$type]);
12 ✔
353
    }
354

355
    /**
356
     * Returns the event listeners attached to the specified event type.
357
     *
358
     * @return array<(callable(Event, object|null): void)>
359
     */
360
    public function get_listeners(string $type): array
361
    {
362
        if (!strpos($type, '::')) {
12 ✔
363
            return $this->listeners[$type] ?? [];
3 ✔
364
        }
365

366
        if (isset($this->consolidated_listeners[$type])) {
9 ✔
367
            return $this->consolidated_listeners[$type];
1 ✔
368
        }
369

370
        return $this->consolidated_listeners[$type] = $this->consolidate_listeners($type);
9 ✔
371
    }
372

373
    /**
374
     * Searches an event listener.
375
     */
376
    private function search_listener(string $type, callable $listener): string|false
377
    {
378
        $listeners = $this->listeners;
6 ✔
379

380
        /** @phpstan-ignore-next-line */
381
        return empty($listeners[$type]) ? false : array_search($listener, $listeners[$type], strict: true);
6 ✔
382
    }
383

384
    /**
385
     * Consolidate listeners of the same type.
386
     *
387
     * If the class of the event's sender is provided, event listeners are filtered according to the class and its
388
     * hierarchy: listeners attached to the class come first, followed by the listeners attached to its parent classes, the
389
     * listeners attached to the interfaces it implements, e.g. `Throwable`, and finally the listeners attached to the bare
390
     * event type, which are called whatever the sender.
391
     *
392
     * @return (callable(Event, ?object): void)[]
393
     */
394
    private function consolidate_listeners(string $type): array
395
    {
396
        [ $sender_class, $type ] = explode('::', $type);
9 ✔
397

398
        $listeners = [];
9 ✔
399
        $class = $sender_class;
9 ✔
400

401
        while ($class) {
9 ✔
402
            $k = $class . '::' . $type;
9 ✔
403

404
            if (isset($this->listeners[$k])) {
9 ✔
405
                $listeners = array_merge($listeners, $this->listeners[$k]);
7 ✔
406
            }
407

408
            $class = get_parent_class($class);
9 ✔
409
        }
410

411
        foreach (class_implements($sender_class) ?: [] as $interface) {
9 ✔
412
            $k = $interface . '::' . $type;
3 ✔
413

414
            if (isset($this->listeners[$k])) {
3 ✔
415
                $listeners = array_merge($listeners, $this->listeners[$k]);
2 ✔
416
            }
417
        }
418

419
        if (isset($this->listeners[$type])) {
9 ✔
420
            $listeners = array_merge($listeners, $this->listeners[$type]);
2 ✔
421
        }
422

423
        return $listeners;
9 ✔
424
    }
425

426
    private function resolve_original_listener(callable $listener): callable
427
    {
428
        if (!is_object($listener) || empty($this->original_listeners[$listener])) {
12 ✔
429
            return $listener;
10 ✔
430
        }
431

432
        return $this->original_listeners[$listener];
2 ✔
433
    }
434

435
    /**
436
     * Adds a reference to the original listener.
437
     *
438
     * @return Closure
439
     *     Returns `$wrapper` back.
440
     */
441
    private function shadow_original_listener(callable $listener, Closure $wrapper): Closure
442
    {
443
        $this->original_listeners[$wrapper] = $listener;
2 ✔
444

445
        return $wrapper;
2 ✔
446
    }
447
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc