Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
52 / 52
100.00% covered (success)
100.00%
13 / 13
CRAP
100.00% covered (success)
100.00%
1 / 1
Manager
100.00% covered (success)
100.00%
52 / 52
100.00% covered (success)
100.00%
13 / 13
32
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 on
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 off
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 get
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 has
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getResults
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 listen
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getListenersForEvent
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 dispatch
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
8
 trigger
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 dispatchNamed
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 __set
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 offsetSet
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2declare(strict_types=1);
3/**
4 * Pop PHP Framework (https://www.popphp.org/)
5 *
6 * @link       https://github.com/popphp/popphp-framework
7 * @author     Nick Sagona, III <nick@popphp.org>
8 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
9 * @license    https://www.popphp.org/license     New BSD License
10 */
11
12/**
13 * @namespace
14 */
15namespace Pop\Event;
16
17use Pop\AbstractManager;
18use Pop\Utils\CallableObject;
19use Psr\EventDispatcher\EventDispatcherInterface;
20use Psr\EventDispatcher\ListenerProviderInterface;
21use Psr\EventDispatcher\StoppableEventInterface;
22
23/**
24 * Event manager class
25 *
26 * @category   Pop
27 * @package    Pop\Event
28 * @author     Nick Sagona, III <nick@popphp.org>
29 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
30 * @license    https://www.popphp.org/license     New BSD License
31 * @version    5.0.0
32 */
33class Manager extends AbstractManager implements EventDispatcherInterface, ListenerProviderInterface
34{
35
36    /**
37     * Event results
38     * @var array
39     */
40    protected array $results = [];
41
42    /**
43     * Class-indexed listeners, keyed by exact event class name
44     * @var array<string, \SplPriorityQueue>
45     */
46    protected array $classListeners = [];
47
48    /**
49     * Constructor
50     *
51     * Instantiate the event manager object.
52     *
53     * @param  ?string $name
54     * @param  mixed   $action
55     * @param  int     $priority
56     */
57    public function __construct(?string $name = null, mixed $action = null, int $priority = 0)
58    {
59        if (($name !== null) && ($action !== null)) {
60            $this->on($name, $action, $priority);
61        }
62    }
63
64    /**
65     * Attach an event listener
66     *
67     *     $event->on('event.name', 'someFunction');
68     *     $event->on('event.name', function() { ... });
69     *     $event->on('event.name', new SomeClass());
70     *     $event->on('event.name', [new SomeClass, 'foo']);
71     *     $event->on('event.name', 'SomeClass');
72     *     $event->on('event.name', 'SomeClass->foo');
73     *     $event->on('event.name', 'SomeClass::bar');
74     *
75     * @param  string $name
76     * @param  mixed  $action
77     * @param  int    $priority
78     * @return static
79     */
80    public function on(string $name, mixed $action, int $priority = 0): static
81    {
82        if (!isset($this->items[$name])) {
83            $this->items[$name] = new \SplPriorityQueue();
84        }
85        if (!($action instanceof CallableObject)) {
86            $action = new CallableObject($action);
87        }
88        $this->items[$name]->insert($action, $priority);
89
90        return $this;
91    }
92
93    /**
94     * Detach an event listener
95     *
96     * @param  string $name
97     * @param  mixed  $action
98     * @return static
99     */
100    public function off(string $name, mixed $action): static
101    {
102        // If the event exists, loop through and remove the action if found.
103        if (isset($this->items[$name])) {
104            $newListeners = new \SplPriorityQueue();
105
106            // Normalize the same way on() does, so a raw closure/string/etc.
107            // can be compared against the CallableObject wrappers on() stored.
108            if (!($action instanceof CallableObject)) {
109                $action = new CallableObject($action);
110            }
111
112            $listeners = clone $this->items[$name];
113            $listeners->setExtractFlags(\SplPriorityQueue::EXTR_BOTH);
114
115            foreach ($listeners as $item) {
116                if ($action->getCallable() !== $item['data']->getCallable()) {
117                    $newListeners->insert($item['data'], $item['priority']);
118                }
119            }
120
121            $this->items[$name] = $newListeners;
122        }
123
124        return $this;
125    }
126
127    /**
128     * Return an event
129     *
130     * @param  string $name
131     * @return mixed
132     */
133    public function get(string $name): mixed
134    {
135        return $this->getItem($name);
136    }
137
138    /**
139     * Determine whether the event manager has an event registered with it
140     *
141     * @param  string $name
142     * @return bool
143     */
144    public function has(string $name): bool
145    {
146        return $this->hasItem($name);
147    }
148
149    /**
150     * Return the event results
151     *
152     * @param  string $name
153     * @return mixed
154     */
155    public function getResults(string $name): mixed
156    {
157        return $this->results[$name] ?? null;
158    }
159
160    /**
161     * Register a listener for an event class - called with the raw event object as its sole argument
162     *
163     * @param  string   $eventClass
164     * @param  callable $listener
165     * @param  int      $priority
166     * @return static
167     */
168    public function listen(string $eventClass, callable $listener, int $priority = 0): static
169    {
170        if (!isset($this->classListeners[$eventClass])) {
171            $this->classListeners[$eventClass] = new \SplPriorityQueue();
172        }
173        $this->classListeners[$eventClass]->insert($listener, $priority);
174
175        return $this;
176    }
177
178    /**
179     * Get the class-indexed listeners for an event, matched by exact event class only
180     *
181     * @param  object $event
182     * @return iterable
183     */
184    public function getListenersForEvent(object $event): iterable
185    {
186        $eventClass = $event::class;
187
188        if (!isset($this->classListeners[$eventClass])) {
189            return [];
190        }
191
192        // Clone before iterating - SplPriorityQueue iteration is destructive,
193        // same reasoning as trigger()'s existing clone below.
194        return clone $this->classListeners[$eventClass];
195    }
196
197    /**
198     * Dispatch an event to its class-indexed listeners
199     *
200     * @param  object $event
201     * @return object
202     */
203    public function dispatch(object $event): object
204    {
205        foreach ($this->getListenersForEvent($event) as $listener) {
206            if (($event instanceof StoppableEventInterface) && $event->isPropagationStopped()) {
207                return $event;
208            }
209            $listener($event);
210        }
211
212        if (($event instanceof StoppableEventInterface) && $event->isPropagationStopped()) {
213            return $event;
214        }
215
216        if (($event instanceof AbstractEvent) && isset($this->items[$event->getName()])) {
217            $this->dispatchNamed($event->getName(), $event);
218        }
219
220        return $event;
221    }
222
223    /**
224     * Trigger an event listener
225     *
226     * @param  string $name
227     * @param  array  $params
228     * @return void
229     */
230    public function trigger(string $name, array $params = []): void
231    {
232        $this->dispatch(new Event($name, $params));
233    }
234
235    /**
236     * Dispatch a name-indexed event's listeners - always called positionally,
237     * sourced from $event->toParams() plus an appended 'result' (previous
238     * listener's return value, same chaining as before this event object
239     * existed) and 'event' (the event object itself, appended last so it
240     * never shifts the positional index of a key an existing listener
241     * already reads - a listener that wants stopPropagation() declares one
242     * extra trailing parameter to receive it, everyone else is unaffected).
243     *
244     * @param  string       $name
245     * @param  AbstractEvent $event
246     * @return void
247     */
248    protected function dispatchNamed(string $name, AbstractEvent $event): void
249    {
250        $this->results[$name] = [];
251
252        // Iterate a clone, not $this->items[$name] itself - SplPriorityQueue
253        // iteration destructively dequeues, and an early return on a stopped
254        // event would otherwise leave undequeued listeners stuck in the
255        // original queue, permanently missing from every future trigger()
256        // call for this name (same technique off() already uses above).
257        $listeners = clone $this->items[$name];
258
259        foreach ($listeners as $action) {
260            if ($event->isPropagationStopped()) {
261                return;
262            }
263
264            $params            = $event->toParams();
265            $params['result']  = end($this->results[$name]);
266            $params['event']   = $event;
267
268            // Positional, not associative - CallableObject's constructor-invoking
269            // call types (e.g. a 'new Class' listener) route a string-keyed
270            // array into ReflectionClass::newInstanceArgs() as PHP named
271            // arguments, which throws for any key that isn't a declared
272            // parameter name. array_values() keeps every listener type on the
273            // positional contract this method documents above.
274            $result                 = $action->call(array_values($params));
275            $this->results[$name][] = $result;
276        }
277    }
278
279    /**
280     * Set an event
281     *
282     * @param  string $name
283     * @param  mixed  $value
284     * @return void
285     */
286    public function __set(string $name, mixed $value): void
287    {
288        $this->on($name, $value);
289    }
290
291    /**
292     * Set an event
293     *
294     * @param  mixed $offset
295     * @param  mixed  $value
296     * @return void
297     */
298    public function offsetSet(mixed $offset, mixed $value): void
299    {
300        $this->on($offset, $value);
301    }
302
303}