Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
78 / 78
100.00% covered (success)
100.00%
9 / 9
CRAP
100.00% covered (success)
100.00%
1 / 1
ConfiguredListenerProviderCollection
100.00% covered (success)
100.00%
78 / 78
100.00% covered (success)
100.00%
9 / 9
38
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
10
 listenerProviders
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 eventSubscribers
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 reflectionListeners
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 prioritizedListeners
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getReflectionAttributes
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
7
 getReflectionEventType
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 getCallableEventType
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 getCallableReflector
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
11
1<?php
2
3declare(strict_types=1);
4
5/**
6 * This file is part of php-fast-forward/event-dispatcher.
7 *
8 * This source file is subject to the license bundled
9 * with this source code in the file LICENSE.
10 *
11 * @copyright Copyright (c) 2025-2026 Felipe SayĆ£o Lobato Abreu <github@mentordosnerds.com>
12 * @license   https://opensource.org/licenses/MIT MIT License
13 *
14 * @see       https://github.com/php-fast-forward/event-dispatcher
15 * @see       https://github.com/php-fast-forward
16 * @see       https://datatracker.ietf.org/doc/html/rfc2119
17 */
18
19namespace FastForward\EventDispatcher\ServiceProvider\Configuration;
20
21use ReflectionException;
22use Closure;
23use FastForward\Config\ConfigInterface;
24use FastForward\EventDispatcher\Exception\RuntimeException;
25use Psr\EventDispatcher\ListenerProviderInterface;
26use ReflectionClass;
27use ReflectionFunction;
28use ReflectionFunctionAbstract;
29use ReflectionMethod;
30use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
31use Symfony\Component\EventDispatcher\EventSubscriberInterface;
32
33/**
34 * Classify configured listeners by the provider strategy they require.
35 *
36 * @internal
37 */
38 class ConfiguredListenerProviderCollection
39{
40    /**
41     * Listener providers declared directly in configuration.
42     *
43     * @var list<ListenerProviderInterface|string>
44     */
45    private array $listenerProviders = [];
46
47    /**
48     * Event subscribers declared in configuration.
49     *
50     * @var list<EventSubscriberInterface|string>
51     */
52    private array $eventSubscribers = [];
53
54    /**
55     * Callables that should be registered with the reflection-based provider.
56     *
57     * @var list<ConfiguredReflectionListener>
58     */
59    private array $reflectionListeners = [];
60
61    /**
62     * Attribute-based listeners that carry an explicit priority.
63     *
64     * @var list<ConfiguredPrioritizedListener>
65     */
66    private array $prioritizedListeners = [];
67
68    /**
69     * Build the configured listener collections from application configuration.
70     *
71     * @param string|ConfigInterface $config configuration source or container key
72     *
73     * @throws RuntimeException thrown when a configured listener cannot be classified
74     * @throws ReflectionException thrown when reflective listener inspection fails
75     */
76    public function __construct(string|ConfigInterface $config)
77    {
78        if (\is_string($config) || ! $config->has(ListenerProviderInterface::class)) {
79            return;
80        }
81
82        $listeners = $config->get(ListenerProviderInterface::class);
83
84        foreach ($listeners as $listener) {
85            $attributes = $this->getReflectionAttributes($listener);
86
87            if ([] !== $attributes) {
88                foreach ($attributes as $attribute) {
89                    $this->prioritizedListeners[] = new ConfiguredPrioritizedListener(
90                        $listener,
91                        $attribute->event,
92                        $attribute->method,
93                        $attribute->priority,
94                    );
95                }
96
97                continue;
98            }
99
100            if (is_subclass_of($listener, ListenerProviderInterface::class)) {
101                $this->listenerProviders[] = $listener;
102
103                continue;
104            }
105
106            if (is_subclass_of($listener, EventSubscriberInterface::class)) {
107                $this->eventSubscribers[] = $listener;
108
109                continue;
110            }
111
112            if (\is_string($listener) || \is_callable($listener)) {
113                $this->reflectionListeners[] = new ConfiguredReflectionListener(
114                    $listener,
115                    $this->getCallableEventType($listener),
116                );
117
118                continue;
119            }
120
121            throw RuntimeException::forUnsupportedType($listener);
122        }
123    }
124
125    /**
126     * Return configured listener providers.
127     *
128     * @return list<ListenerProviderInterface|string>
129     */
130    public function listenerProviders(): array
131    {
132        return $this->listenerProviders;
133    }
134
135    /**
136     * Return configured event subscribers.
137     *
138     * @return list<EventSubscriberInterface|string>
139     */
140    public function eventSubscribers(): array
141    {
142        return $this->eventSubscribers;
143    }
144
145    /**
146     * Return configured reflection-based listeners.
147     *
148     * @return list<ConfiguredReflectionListener>
149     */
150    public function reflectionListeners(): array
151    {
152        return $this->reflectionListeners;
153    }
154
155    /**
156     * Return configured prioritized listeners.
157     *
158     * @return list<ConfiguredPrioritizedListener>
159     */
160    public function prioritizedListeners(): array
161    {
162        return $this->prioritizedListeners;
163    }
164
165    /**
166     * Resolve listener attributes declared through {@see AsEventListener}.
167     *
168     * @param mixed $listener listener value to inspect
169     *
170     * @return list<AsEventListener> attribute instances resolved from the listener
171     *
172     * @throws RuntimeException thrown when an attribute target cannot resolve its event type
173     * @throws ReflectionException thrown when reflective lookup fails
174     */
175    private function getReflectionAttributes(mixed $listener): array
176    {
177        if (! \is_object($listener) && (! \is_string($listener) || ! class_exists($listener))) {
178            return [];
179        }
180
181        $reflection = new ReflectionClass($listener);
182        $attributes = [];
183
184        foreach ($reflection->getAttributes(AsEventListener::class) as $attribute) {
185            $instance = $attribute->newInstance();
186            $instance->method ??= '__invoke';
187            $instance->event ??= $this->getReflectionEventType($reflection->getMethod($instance->method));
188            $attributes[] = $instance;
189        }
190
191        foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
192            foreach ($method->getAttributes(AsEventListener::class) as $attribute) {
193                $instance = $attribute->newInstance();
194                $instance->method ??= $method->getName();
195                $instance->event ??= $this->getReflectionEventType($method);
196                $attributes[] = $instance;
197            }
198        }
199
200        return $attributes;
201    }
202
203    /**
204     * Resolve the event type handled by an attributed listener method.
205     *
206     * @param ReflectionMethod $method reflected listener method
207     *
208     * @return string event type declared by the first method parameter
209     *
210     * @throws RuntimeException thrown when the method does not expose a typed first parameter
211     */
212    private function getReflectionEventType(ReflectionMethod $method): string
213    {
214        $parameters = $method->getParameters();
215        if ([] === $parameters) {
216            throw RuntimeException::forMethodWithoutParameters();
217        }
218
219        $parameter = $parameters[0];
220
221        if (! $parameter->hasType()) {
222            throw RuntimeException::forMethodParameterWithoutType();
223        }
224
225        return $parameter->getType()
226            ->getName();
227    }
228
229    /**
230     * Resolve the event type handled by a callable listener.
231     *
232     * @param mixed $listener listener value to inspect
233     *
234     * @return string event type declared by the first callable parameter
235     *
236     * @throws RuntimeException thrown when the callable does not expose a typed first parameter
237     * @throws ReflectionException thrown when reflective lookup fails
238     */
239    private function getCallableEventType(mixed $listener): string
240    {
241        $parameters = $this->getCallableReflector($listener)
242            ->getParameters();
243
244        if ([] === $parameters) {
245            throw RuntimeException::forListenerWithoutParameters();
246        }
247
248        $parameter = $parameters[0];
249
250        if (! $parameter->hasType()) {
251            throw RuntimeException::forListenerParameterWithoutType();
252        }
253
254        return $parameter->getType()
255            ->getName();
256    }
257
258    /**
259     * Create a reflector for the provided callable listener value.
260     *
261     * @param mixed $listener listener value to reflect
262     *
263     * @return ReflectionFunctionAbstract reflection object for the callable
264     *
265     * @throws RuntimeException thrown when the listener cannot be reflected as a callable
266     * @throws ReflectionException thrown when reflective lookup fails
267     */
268    private function getCallableReflector(mixed $listener): ReflectionFunctionAbstract
269    {
270        if ($listener instanceof Closure) {
271            return new ReflectionFunction($listener);
272        }
273
274        if (\is_string($listener) && str_contains($listener, '::')) {
275            [$class, $method] = explode('::', $listener, 2);
276
277            return new ReflectionMethod($class, $method);
278        }
279
280        if (\is_array($listener) && isset($listener[0], $listener[1])) {
281            return new ReflectionMethod($listener[0], $listener[1]);
282        }
283
284        if (\is_object($listener)) {
285            return new ReflectionMethod($listener, '__invoke');
286        }
287
288        if (\is_string($listener) && \function_exists($listener)) {
289            return new ReflectionFunction($listener);
290        }
291
292        if (\is_string($listener) && class_exists($listener)) {
293            return new ReflectionMethod($listener, '__invoke');
294        }
295
296        throw RuntimeException::forUnsupportedType($listener);
297    }
298}