Passed
Push — main ( 139327...3cac76 )
by Olivier
01:38
created

ListenerProviderPass   B

Complexity

Total Complexity 44

Size/Duplication

Total Lines 361
Duplicated Lines 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
wmc 44
eloc 145
c 1
b 0
f 0
dl 0
loc 361
rs 8.8798

14 Methods

Rating   Name   Duplication   Size   Complexity  
A insertAfterListener() 0 14 2
A process() 0 13 2
A extractEvent() 0 20 4
A insertBeforeListener() 0 14 2
A assertRelative() 0 4 2
A sortListenersWithPriority() 0 22 2
A providerIterator() 0 6 2
A assertPlacement() 0 8 2
A resolvePriorities() 0 20 5
A extractPriority() 0 12 4
A __construct() 0 3 1
A sortMapping() 0 16 2
B sortListeners() 0 40 8
B collectListeners() 0 41 6

How to fix   Complexity   

Complex Class

Complex classes like ListenerProviderPass often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use ListenerProviderPass, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
/*
4
 * (c) Olivier Laviale <[email protected]>
5
 *
6
 * For the full copyright and license information, please view the LICENSE
7
 * file that was distributed with this source code.
8
 */
9
10
namespace olvlvl\EventDispatcher\Symfony;
11
12
use olvlvl\EventDispatcher\ListenerProviderWithContainer;
13
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
14
use Symfony\Component\DependencyInjection\Compiler\ServiceLocatorTagPass;
15
use Symfony\Component\DependencyInjection\ContainerBuilder;
16
use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException;
17
use Symfony\Component\DependencyInjection\Exception\LogicException;
18
use Symfony\Component\DependencyInjection\TypedReference;
19
20
use function array_fill_keys;
21
use function array_filter;
22
use function array_flip;
23
use function array_intersect;
24
use function array_intersect_key;
25
use function array_keys;
26
use function array_merge;
27
use function array_slice;
28
use function class_exists;
29
use function count;
30
use function implode;
31
use function interface_exists;
32
use function is_int;
33
use function max;
34
use function min;
35
use function sprintf;
36
use function usort;
37
38
/**
39
 * A compilation pass for event listeners.
40
 */
41
final class ListenerProviderPass implements CompilerPassInterface
42
{
43
    public const DEFAULT_PROVIDER_TAG = 'listener_provider';
44
    public const DEFAULT_LISTENER_TAG = 'event_listener';
45
    public const DEFAULT_PRIORITY = 0;
46
    public const ATTRIBUTE_LISTENER_TAG = 'listener_tag';
47
    public const ATTRIBUTE_EVENT = 'event';
48
    public const ATTRIBUTE_PRIORITY = 'priority';
49
    public const ATTRIBUTE_BEFORE = 'before';
50
    public const ATTRIBUTE_AFTER = 'after';
51
    public const PRIORITY_FIRST = 'first';
52
    public const PRIORITY_LAST = 'last';
53
54
    private const PLACEMENT_ATTRIBUTES = [
55
        self::ATTRIBUTE_PRIORITY,
56
        self::ATTRIBUTE_BEFORE,
57
        self::ATTRIBUTE_AFTER,
58
    ];
59
60
    /**
61
     * @var string
62
     */
63
    private $providerTag;
64
65
    /**
66
     * @param string $providerTag Tag identifying listener providers.
67
     */
68
    public function __construct(string $providerTag = self::DEFAULT_PROVIDER_TAG)
69
    {
70
        $this->providerTag = $providerTag;
71
    }
72
73
    /**
74
     * @inheritDoc
75
     */
76
    public function process(ContainerBuilder $container): void
77
    {
78
        foreach ($this->providerIterator($container) as $id => $listenerTag) {
79
            [ $mapping, $refMap ] = $this->collectListeners($container, $listenerTag);
80
81
            $container
82
                ->getDefinition($id)
83
                ->setSynthetic(false)
84
                ->setClass(ListenerProviderWithContainer::class)
85
                ->setArguments(
86
                    [
87
                        $mapping,
88
                        ServiceLocatorTagPass::register($container, $refMap),
89
                    ]
90
                );
91
        }
92
    }
93
94
    /**
95
     * @return iterable<string, string>
96
     */
97
    private function providerIterator(ContainerBuilder $container): iterable
98
    {
99
        foreach ($container->findTaggedServiceIds($this->providerTag, true) as $id => $tags) {
100
            $listener_tag = $tags[0][self::ATTRIBUTE_LISTENER_TAG] ?? self::DEFAULT_LISTENER_TAG;
101
102
            yield $id => $listener_tag;
103
        }
104
    }
105
106
    /**
107
     * @return array{0: array<class-string, string[]>, 1: array<string, TypedReference>}
108
     */
109
    private function collectListeners(ContainerBuilder $container, string $listenerTag): array
110
    {
111
        $listeners = $container->findTaggedServiceIds($listenerTag, true);
112
        $mapping = [];
113
        $refMap = [];
114
        $prioritiesByEvent = [];
115
        $beforeByEvent = [];
116
        $afterByEvent = [];
117
118
        foreach ($listeners as $id => $tags) {
119
            foreach ($tags as $attributes) {
120
                $class = $container->getDefinition($id)->getClass();
121
122
                if (!$class) {
123
                    throw new InvalidArgumentException("Missing class for listener '$id'.");
124
                }
125
126
                $refMap[$id] = new TypedReference($id, $class);
127
128
                $event = $this->extractEvent($attributes, $id);
129
                $mapping[$event][] = $id;
130
131
                $this->assertPlacement($attributes, $id);
132
133
                if (isset($attributes[self::ATTRIBUTE_BEFORE])) {
134
                    $relative = $attributes[self::ATTRIBUTE_BEFORE];
135
                    $this->assertRelative($listeners, $relative, $id);
136
                    $beforeByEvent[$event][$id] = $relative;
137
                } elseif (isset($attributes[self::ATTRIBUTE_AFTER])) {
138
                    $relative = $attributes[self::ATTRIBUTE_AFTER];
139
                    $this->assertRelative($listeners, $relative, $id);
140
                    $afterByEvent[$event][$id] = $relative;
141
                } else {
142
                    $prioritiesByEvent[$event][$id] = $this->extractPriority($attributes, $id);
143
                }
144
            }
145
        }
146
147
        return [
148
            $this->sortMapping($mapping, $prioritiesByEvent, $beforeByEvent, $afterByEvent),
149
            $refMap,
150
        ];
151
    }
152
153
    /**
154
     * @param array<string, mixed> $tag
155
     *
156
     * @return class-string
157
     */
158
    private function extractEvent(array $tag, string $id): string
159
    {
160
        $event = $tag[self::ATTRIBUTE_EVENT] ?? null;
161
162
        if (!$event) {
163
            $attribute = self::ATTRIBUTE_EVENT;
164
165
            throw new InvalidArgumentException(
166
                "Missing event type for listener '$id'."
167
                . " Try to specify the event using the attribute '$attribute'."
168
            );
169
        }
170
171
        if (!class_exists($event) && !interface_exists($event)) {
172
            throw new InvalidArgumentException(
173
                "Unable to load event class or interface '$event' for listener '$id'."
174
            );
175
        }
176
177
        return $event;
178
    }
179
180
    /**
181
     * @param array<string, mixed> $attributes
182
     */
183
    public function assertPlacement(array $attributes, string $id): void
184
    {
185
        $positions = array_intersect(self::PLACEMENT_ATTRIBUTES, array_keys($attributes));
186
187
        if (count($positions) > 1) {
188
            throw new LogicException(sprintf(
189
                "Invalid definition for listener '$id', can only specify one of: %s.",
190
                implode(', ', self::PLACEMENT_ATTRIBUTES)
191
            ));
192
        }
193
    }
194
195
    /**
196
     * @param array<string, mixed> $listeners
197
     */
198
    public function assertRelative(array $listeners, string $relative, string $id): void
199
    {
200
        if (!isset($listeners[$relative])) {
201
            throw new InvalidArgumentException("Undefined relative for listener '$id': $relative.");
202
        }
203
    }
204
205
    /**
206
     * @param array<string, mixed> $attributes
207
     *
208
     * @return int|string
209
     */
210
    private function extractPriority(array $attributes, string $id)
211
    {
212
        $priority = $attributes[self::ATTRIBUTE_PRIORITY] ?? self::DEFAULT_PRIORITY;
213
214
        if ($priority !== self::PRIORITY_FIRST && $priority !== self::PRIORITY_LAST && !is_int($priority)) {
215
            throw new InvalidArgumentException(
216
                "Invalid priority value for listener '$id': $priority."
217
                . " Valid values are 'first', 'last', or an integer."
218
            );
219
        }
220
221
        return $priority;
222
    }
223
224
    /**
225
     * @param array<class-string, string[]> $mapping
226
     *     Where _key_ is an Event and _value_ is an array of service identifiers.
227
     * @param array<class-string, array<string, int|string>> $prioritiesByEvent
228
     *     Where _key_ is an Event and _value_ is an array where _key_ is a service identifier and _value_ a priority.
229
     * @param array<class-string, array<string, string>> $beforeByEvent
230
     *     Where _key_ is an Event and _value_ is an array where _key_ and _value_ are both service identifiers.
231
     * @param array<class-string, array<string, string>> $afterByEvent
232
     *     Where _key_ is an Event and _value_ is an array where _key_ and _value_ are both service identifiers.
233
     *
234
     * @return array<class-string, string[]>
235
     *     Where _key_ is an Event and _value_ is an array of service identifiers, sorted.
236
     */
237
    private function sortMapping(
238
        array $mapping,
239
        array $prioritiesByEvent,
240
        array $beforeByEvent,
241
        array $afterByEvent
242
    ): array {
243
        foreach ($mapping as $event => &$listeners) {
244
            $listeners = $this->sortListeners(
245
                $listeners,
246
                $prioritiesByEvent[$event] ?? [],
247
                $beforeByEvent[$event] ?? [],
248
                $afterByEvent[$event] ?? []
249
            );
250
        }
251
252
        return $mapping;
253
    }
254
255
    /**
256
     * @param string[] $listeners
257
     *     An array of service identifiers.
258
     * @param array<string, int|string> $priorities
259
     *     Where _key_ is a service identifier and _value_ a priority.
260
     * @param array<string, string> $beforePositions
261
     *     Where _key_ and _value_ are both service identifiers.
262
     * @param array<string, string> $afterPositions
263
     *     Where _key_ and _value_ are both service identifiers.
264
     *
265
     * @return string[]
266
     *     An array of service identifiers, sorted.
267
     */
268
    private function sortListeners(
269
        array $listeners,
270
        array $priorities,
271
        array $beforePositions,
272
        array $afterPositions
273
    ): array {
274
        $priorities = $this->resolvePriorities($priorities);
275
        $listenersByPosition = array_flip($listeners);
276
        $listenersWithPriority = array_intersect_key($priorities, $listenersByPosition);
277
        $sortedListeners = array_fill_keys($this->sortListenersWithPriority($listenersWithPriority), 0);
278
279
        while ($beforePositions || $afterPositions) {
2 ignored issues
show
Bug Best Practice introduced by
The expression $afterPositions of type array<string,string> is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
Bug Best Practice introduced by
The expression $beforePositions of type array<string,string> is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
280
            $inserted = 0;
281
282
            foreach ($beforePositions as $id => $relative) {
283
                if (isset($sortedListeners[$relative])) {
284
                    $this->insertBeforeListener($sortedListeners, $id, $relative);
285
                    $inserted++;
286
                    unset($beforePositions[$id]);
287
                }
288
            }
289
290
            foreach ($afterPositions as $id => $relative) {
291
                if (isset($sortedListeners[$relative])) {
292
                    $this->insertAfterListener($sortedListeners, $id, $relative);
293
                    $inserted++;
294
                    unset($afterPositions[$id]);
295
                }
296
            }
297
298
            if (!$inserted) {
299
                throw new LogicException(sprintf(
300
                    "Unable to insert the following listeners: %s. Please check the position logic.",
301
                    implode(', ', array_merge(array_keys($beforePositions), array_keys($afterPositions)))
302
                ));
303
            }
304
        }
305
306
        // @phpstan-ignore-next-line
307
        return array_keys($sortedListeners);
308
    }
309
310
    /**
311
     * @param array<string, string|int> $priorities
312
     *
313
     * @return array<string, int>
314
     */
315
    private function resolvePriorities(array $priorities): array
316
    {
317
        $numericPriorities = array_filter($priorities, 'is_int');
318
        if (!$numericPriorities) {
1 ignored issue
show
Bug Best Practice introduced by
The expression $numericPriorities of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
319
            return [];
320
        }
321
322
        $min = min($numericPriorities);
323
        $max = max($numericPriorities);
324
325
        foreach ($priorities as &$priority) {
326
            if ($priority === self::PRIORITY_FIRST) {
327
                $priority = ++$max;
328
            } elseif ($priority === self::PRIORITY_LAST) {
329
                $priority = --$min;
330
            }
331
        }
332
333
        // @phpstan-ignore-next-line
334
        return $priorities;
335
    }
336
337
    /**
338
     * @param array<string, int> $listenersWithPriority
339
     *
340
     * @return string[]
341
     */
342
    private function sortListenersWithPriority(array $listenersWithPriority): array
343
    {
344
        $listeners = array_keys($listenersWithPriority);
345
        $positions = array_flip($listeners);
346
347
        usort(
348
            $listeners,
349
            function (string $a, string $b) use ($listenersWithPriority, $positions): int {
350
                $pa = $listenersWithPriority[$a];
351
                $pb = $listenersWithPriority[$b];
352
353
                if ($pa === $pb) {
354
                    // Same priority. Let's compare the original orders, which are ascending.
355
                    return $positions[$a] <=> $positions[$b];
356
                }
357
358
                // Priorities are descending.
359
                return $pb <=> $pa;
360
            }
361
        );
362
363
        return $listeners;
364
    }
365
366
    /**
367
     * @param array<string, mixed> $listeners Where _key_ is a service identifier.
368
     */
369
    public function insertBeforeListener(array &$listeners, string $id, string $relative): void
370
    {
371
        $positions = array_flip(array_keys($listeners));
372
        $p = $positions[$relative];
373
374
        if ($p === 0) {
375
            $listeners = [ $id => 0 ] + $listeners;
376
377
            return;
378
        }
379
380
        $listeners = array_slice($listeners, 0, $p)
381
            + [ $id => true ]
382
            + array_slice($listeners, $p);
383
    }
384
385
    /**
386
     * @param array<string, mixed> $listeners Where _key_ is a service identifier.
387
     */
388
    public function insertAfterListener(array &$listeners, string $id, string $relative): void
389
    {
390
        $positions = array_flip(array_keys($listeners));
391
        $p = $positions[$relative];
392
393
        if ($p + 1 === count($listeners)) {
394
            $listeners += [ $id => 0 ];
395
396
            return;
397
        }
398
399
        $listeners = array_slice($listeners, 0, $p + 1)
400
            + [ $id => true ]
401
            + array_slice($listeners, $p - 1);
402
    }
403
}
404