Test Failed
Pull Request — master (#37)
by Divine Niiquaye
03:19
created

DefinitionBuilder   F

Complexity

Total Complexity 78

Size/Duplication

Total Lines 430
Duplicated Lines 0 %

Importance

Changes 15
Bugs 0 Features 0
Metric Value
eloc 155
c 15
b 0
f 0
dl 0
loc 430
rs 2.16
wmc 78

20 Methods

Rating   Name   Duplication   Size   Complexity  
A __call() 0 21 6
A defaults() 0 10 2
A parameter() 0 5 1
A set() 0 5 1
A __destruct() 0 13 4
B findResourcePath() 0 32 8
A alias() 0 5 1
A directory() 0 5 1
A decorate() 0 5 1
A namespaced() 0 20 4
A autowire() 0 25 6
A extend() 0 5 1
A __construct() 0 3 1
B doCreate() 0 27 10
A getContainer() 0 3 1
A instanceOf() 0 11 2
A createInitializingError() 0 3 1
A createErrorException() 0 12 3
B findClass() 0 31 8
C findClasses() 0 54 16

How to fix   Complexity   

Complex Class

Complex classes like DefinitionBuilder 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 DefinitionBuilder, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * This file is part of DivineNii opensource projects.
7
 *
8
 * PHP version 7.4 and above required
9
 *
10
 * @author    Divine Niiquaye Ibok <[email protected]>
11
 * @copyright 2021 DivineNii (https://divinenii.com/)
12
 * @license   https://opensource.org/licenses/BSD-3-Clause License
13
 *
14
 * For the full copyright and license information, please view the LICENSE
15
 * file that was distributed with this source code.
16
 */
17
18
namespace Rade\DI;
19
20
use Nette\Utils\{FileSystem, ObjectHelpers, Validators};
21
use Rade\DI\Definitions\DefinitionInterface;
22
use Symfony\Component\Config\Resource\{ClassExistenceResource, FileExistenceResource, FileResource, ResourceInterface};
23
24
/**
25
 * A builder specialized in creating homogeneous service definitions.
26
 *
27
 * This class has some performance impact and recommended to be used with ContainerBuilder class.
28
 *
29
 * @experimental in 1.0
30
 *
31
 * @method self|Definition autowire(string $id, Definitions\TypedDefinitionInterface|object|null $definition = null)
32
 *
33
 * @author Divine Niiquaye Ibok <[email protected]>
34
 */
35
final class DefinitionBuilder
36
{
37
    private AbstractContainer $container;
38
    private ?string $definition = null, $directory = null;
39
    private bool $trackDefaults = false;
40
41
    /** @var array<string,array<int,mixed>> */
42
    private array $classes = [];
43
44
    /** @var array<string,array<int,array<int,mixed>>> */
45
    private array $defaults = [];
46
47
    public function __construct(AbstractContainer $container)
48
    {
49
        $this->container = $container;
50
    }
51
52
    public function __destruct()
53
    {
54
        if (!empty($this->classes)) {
55
            foreach ($this->classes as [$definition, $classes]) {
56
                // prepare for deep cloning
57
                $serializedDef = \serialize($definition);
58
59
                foreach ($classes as $resource) {
60
                    $this->container->set($resource, (\unserialize($serializedDef))->replace($resource, true));
61
                }
62
            }
63
64
            $this->classes = [];
65
        }
66
    }
67
68
    /**
69
     * Where all the magic happens.
70
     *
71
     * @param array<int,mixed> $arguments
72
     *
73
     * @return $this
74
     *
75
     * @throws \Throwable
76
     */
77
    public function __call(string $name, array $arguments)
78
    {
79
        if (!$id = $this->definition) {
80
            throw $this->createInitializingError(__METHOD__);
81
        }
82
83
        if ($this->trackDefaults) {
84
            $this->defaults[$id][] = [$name, $arguments];
85
        } else {
86
            try {
87
                $definition = (!isset($this->classes[$id]) ? $this->container->definition($id) : $this->classes[$id][0]);
88
89
                if (\method_exists($definition, $name)) {
90
                    $definition->{$name}(...$arguments);
91
                }
92
            } catch (\Error $e) {
93
                throw $this->createErrorException($name, $e);
94
            }
95
        }
96
97
        return $this;
98
    }
99
100
    /**
101
     * Set a config into container's parameter.
102
     *
103
     * @param mixed $value
104
     *
105
     * @return $this
106
     */
107
    public function parameter(string $name, $value)
108
    {
109
        $this->container->parameters[$name] = $value;
110
111
        return $this;
112
    }
113
114
    /**
115
     * Marks an alias id to service id.
116
     *
117
     * @return $this
118
     */
119
    public function alias(string $id, string $serviceId = null)
120
    {
121
        $this->container->alias($id, $serviceId ?? $this->definition);
0 ignored issues
show
Bug introduced by
It seems like $serviceId ?? $this->definition can also be of type null; however, parameter $serviceId of Rade\DI\AbstractContainer::alias() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

121
        $this->container->alias($id, /** @scrutinizer ignore-type */ $serviceId ?? $this->definition);
Loading history...
122
123
        return $this;
124
    }
125
126
    /**
127
     * Enables autowiring.
128
     *
129
     * @param string                                           $id
130
     * @param array<int,string>                                $types
131
     * @param Definitions\TypedDefinitionInterface|object|null $definition
132
     *
133
     * @return Definition|$this
134
     */
135
    public function autowire(/* string $id, $definition or array $types */)
136
    {
137
        $arguments = \func_get_args();
138
139
        if (\is_string($arguments[0] ?? null)) {
140
            $this->doCreate($this->container->autowire($this->definition = $arguments[0], $arguments[1] ?? null));
141
142
            return $this;
143
        }
144
145
        if (!$id = $this->definition) {
146
            throw $this->createInitializingError(__FUNCTION__);
147
        }
148
149
        if ($this->trackDefaults) {
150
            $this->defaults[$id][] = [__FUNCTION__, $arguments];
151
        } else {
152
            $definition = !isset($this->classes[$id]) ? $this->container->definition($id) : $this->classes[$id][0];
153
154
            if ($definition instanceof Definitions\TypedDefinitionInterface) {
155
                $definition->autowire($arguments[0] ?? []);
156
            }
157
        }
158
159
        return $this;
160
    }
161
162
    /**
163
     * Set a service definition.
164
     *
165
     * @param DefinitionInterface|object|null $definition
166
     *
167
     * @return Definition|$this
168
     */
169
    public function set(string $id, object $definition = null)
170
    {
171
        $this->doCreate($this->container->set($this->definition = $id, $definition));
172
173
        return $this;
174
    }
175
176
    /**
177
     * Extends a service definition.
178
     *
179
     * @return Definition|$this
180
     */
181
    public function extend(string $id)
182
    {
183
        $this->doCreate($this->container->definition($this->definition = $id));
184
185
        return $this;
186
    }
187
188
    /**
189
     * Replaces old service with a new one, but keeps a reference of the old one as: service_id.inner.
190
     *
191
     * @param DefinitionInterface|object|null $definition
192
     *
193
     * @see Rade\DI\Traits\DefinitionTrait::decorate
194
     *
195
     * @return Definition|$this
196
     */
197
    public function decorate(string $id, object $definition = null)
198
    {
199
        $this->doCreate($this->container->decorate($this->definition = $id, $definition));
200
201
        return $this;
202
    }
203
204
    /**
205
     * Defines a set of defaults for following service definitions.
206
     *
207
     * @param bool $merge If true, new defaults will be merged into existing
208
     *
209
     * @return Definition|$this
210
     */
211
    public function defaults(bool $merge = true)
212
    {
213
        $this->trackDefaults = true;
214
        $this->definition = '#defaults';
215
216
        if (!$merge) {
217
            $this->defaults[$this->definition] = [];
218
        }
219
220
        return $this;
221
    }
222
223
    /**
224
     * Defines a set of defaults only for services whose class matches a defined one.
225
     *
226
     * @return Definition|$this
227
     */
228
    public function instanceOf(string $interfaceOrClass)
229
    {
230
        $this->trackDefaults = true;
231
232
        if (!Validators::isType($interfaceOrClass)) {
233
            throw new \RuntimeException(\sprintf('"%s" is set as an "instanceof" conditional, but it does not exist.', $interfaceOrClass));
234
        }
235
236
        $this->definition = $interfaceOrClass;
237
238
        return $this;
239
    }
240
241
    /**
242
     * Registers a set of classes as services using PSR-4 for discovery.
243
     *
244
     * @param string               $namespace The namespace prefix of classes in the scanned directory
245
     * @param string|null          $resource  The directory to look for classes, glob-patterns allowed
246
     * @param string|string[]|null $exclude   A globbed path of files to exclude or an array of globbed paths of files to exclude
247
     *
248
     * @return $this
249
     */
250
    public function namespaced(string $namespace, string $resource = null, $exclude = null)
251
    {
252
        if ('\\' !== @$namespace[-1]) {
253
            throw new \InvalidArgumentException(\sprintf('Namespace prefix must end with a "\\": "%s".', $namespace));
254
        }
255
256
        if (!\preg_match('/^(?:[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*+\\\\)++$/', $namespace)) {
257
            throw new \InvalidArgumentException(\sprintf('Namespace is not a valid PSR-4 prefix: "%s".', $namespace));
258
        }
259
260
        if (null !== $resource) {
261
            $resource = $this->container->parameter($this->directory . $resource);
262
        }
263
264
        $classes = $this->findClasses($namespace, $resource ?? $this->findResourcePath($namespace), (array) $exclude);
265
        $this->doCreate($definition = new Definition($this->definition = $namespace));
266
267
        $this->classes[$namespace] = [$definition, $classes];
268
269
        return $this;
270
    }
271
272
    /**
273
     * Set|Replace a directory for finding classes.
274
     *
275
     * @return $this
276
     */
277
    public function directory(string $path)
278
    {
279
        $this->directory = \rtrim($path, '\\/') . '/';
280
281
        return $this;
282
    }
283
284
    public function getContainer(): AbstractContainer
285
    {
286
        return $this->container;
287
    }
288
289
    private function doCreate(object $definition): void
290
    {
291
        $this->trackDefaults = false;
292
293
        foreach ($this->defaults as $offset => $defaultMethods) {
294
            if ('#defaults' !== $offset) {
295
                $class = $definition instanceof DefinitionInterface ? $definition->getEntity() : $definition;
296
297
                if (!(\is_string($class) || \is_object($class)) || !\is_subclass_of($class, $offset)) {
298
                    continue;
299
                }
300
            }
301
302
            foreach ($defaultMethods as [$defaultMethod, $defaultArguments]) {
303
                if (!\method_exists($definition, $defaultMethod)) {
304
                    continue;
305
                }
306
307
                try {
308
                    $definition->{$defaultMethod}(...$defaultArguments);
309
                } catch (\Error $e) {
310
                    throw $this->createErrorException($defaultMethod, $e);
311
                }
312
            }
313
        }
314
315
        $this->__destruct();
316
    }
317
318
    private function findResourcePath(string $namespace): string
319
    {
320
        foreach (\spl_autoload_functions() as $classLoader) {
321
            if (!\is_array($classLoader)) {
322
                continue;
323
            }
324
325
            if ($classLoader[0] instanceof \Composer\Autoload\ClassLoader) {
326
                $psr4Prefixes = $classLoader[0]->getPrefixesPsr4();
327
328
                foreach ($psr4Prefixes as $prefix => $paths) {
329
                    if (!\str_starts_with($namespace, $prefix)) {
330
                        continue;
331
                    }
332
333
                    foreach ($paths as $path) {
334
                        $namespacePostfix = '/' . \substr($namespace, \strlen($prefix));
335
                        $path = FileSystem::normalizePath($path . $namespacePostfix);
336
337
                        if (\file_exists($path)) {
338
                            $this->directory = \dirname($path) . '/';
339
340
                            return $path;
341
                        }
342
                    }
343
                }
344
345
                break;
346
            }
347
        }
348
349
        throw new \RuntimeException('PSR-4 autoloader file can not be found!');
350
    }
351
352
    /**
353
     * @param array<int,string> $excludePatterns
354
     *
355
     * @return array<int,string>
356
     *
357
     * @throws \ReflectionException
358
     */
359
    private function findClasses(string $namespace, string $pattern, array $excludePatterns): array
360
    {
361
        $classNames = [];
362
        $container = $this->container;
363
364
        foreach (\glob($pattern, \GLOB_BRACE) as $directory) {
365
            $directoryIterator = new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::FOLLOW_SYMLINKS);
366
            $files = \iterator_to_array(new \RecursiveIteratorIterator($directoryIterator));
367
368
            \uksort($files, 'strnatcmp');
369
370
            /** @var \SplFileInfo $info */
371
            foreach ($files as $path => $info) {
372
                $path = \str_replace('\\', '/', $path); // normalize Windows slashes
373
                $pathLength = 0;
374
375
                foreach ($excludePatterns as $excludePattern) {
376
                    $excludePattern = $container->parameter($this->directory . $excludePattern);
377
378
                    foreach (\glob($excludePattern, \GLOB_BRACE) ?: [$excludePattern] as $excludedPath) {
379
                        if (\str_starts_with($path, \str_replace('\\', '/', $excludedPath))) {
380
                            continue 3;
381
                        }
382
                    }
383
                }
384
385
                if (!\preg_match('/\\.php$/', $path, $m) || !$info->isReadable()) {
386
                    continue;
387
                }
388
389
                foreach (\explode('\\', $namespace, -1) as $namespaced) {
390
                    if ($pos = \strpos($path, $namespaced . '/')) {
391
                        $pathLength = +$pos + \strlen($namespaced . '/');
392
                    }
393
                }
394
395
                if (0 === $pathLength) {
396
                    $pathLength = \preg_match('/\w+\.php$/', $path, $l) ? \strpos($path, $l[0]) : 0;
397
                }
398
                $class = \str_replace('/', '\\', \substr($path, $pathLength, -\strlen($m[0])));
399
400
                if (null === $class = $this->findClass($container, $namespace . $class, $path, $pattern)) {
401
                    continue;
402
                }
403
                $classNames[] = $class;
404
405
                // track only for new & removed files
406
                if ($container instanceof ContainerBuilder && \interface_exists(ResourceInterface::class)) {
407
                    $container->addResource(new FileExistenceResource($path));
408
                }
409
            }
410
        }
411
412
        return $classNames;
413
    }
414
415
    private function findClass(AbstractContainer $container, string $class, string $path, string $pattern): ?string
416
    {
417
        if (!\preg_match('/^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*+(?:\\\\[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*+)*+$/', $class)) {
418
            return null;
419
        }
420
421
        try {
422
            $r = new \ReflectionClass($class);
423
        } catch (\Error | \ReflectionException $e) {
424
            if (\preg_match('/^Class .* not found$/', $e->getMessage())) {
425
                return null;
426
            }
427
428
            if ($e instanceof \ReflectionException) {
429
                throw new \InvalidArgumentException(\sprintf('Expected to find class "%s" in file "%s" while importing services from resource "%s", but it was not found! Check the namespace prefix used with the resource.', $class, $path, $pattern), 0, $e);
430
            }
431
432
            throw $e;
433
        }
434
435
        if ($container instanceof ContainerBuilder && \interface_exists(ResourceInterface::class)) {
436
            $container->addResource(new ClassExistenceResource($class, false));
437
            $container->addResource(new FileExistenceResource($rPath = $r->getFileName()));
438
            $container->addResource(new FileResource($rPath));
439
        }
440
441
        if ($r->isInstantiable()) {
442
            return $class;
443
        }
444
445
        return null;
446
    }
447
448
    private function createErrorException(string $name, \Throwable $e): \BadMethodCallException
449
    {
450
        if (!\str_starts_with($e->getMessage(), 'Call to undefined method')) {
451
            throw $e;
452
        }
453
454
        $hint = ObjectHelpers::getSuggestion(\array_merge(
455
            \get_class_methods($this),
456
            \get_class_methods(Definition::class),
457
        ), $name);
458
459
        return new \BadMethodCallException(\sprintf('Call to undefined method %s::%s()' . ($hint ? ", did you mean $hint()?" : '.'), __CLASS__, $name), 0, $e);
460
    }
461
462
    private function createInitializingError(string $name): \LogicException
463
    {
464
        return new \LogicException(\sprintf('Did you forgot to register a service via "set", "autowire", or "namespaced" methods\' before calling %s::%s().', __CLASS__, $name));
465
    }
466
}
467