Test Failed
Pull Request — master (#37)
by Divine Niiquaye
02:41
created

DefinitionBuilder   F

Complexity

Total Complexity 78

Size/Duplication

Total Lines 436
Duplicated Lines 0 %

Importance

Changes 13
Bugs 0 Features 0
Metric Value
wmc 78
eloc 156
c 13
b 0
f 0
dl 0
loc 436
rs 2.16

20 Methods

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

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
39
    /** @var array<string,array<int,mixed>> */
40
    private array $classes = [];
41
42
    /** @var array<string,array<int,array<int,mixed>>> */
43
    private array $defaults = [];
44
45
    private ?string $definition = null;
46
47
    private ?string $directory = null;
48
49
    private bool $trackDefaults = false;
50
51
    public function __construct(AbstractContainer $container)
52
    {
53
        $this->container = $container;
54
    }
55
56
    public function __destruct()
57
    {
58
        if (!empty($this->classes)) {
59
            foreach ($this->classes as [$definition, $classes]) {
60
                // prepare for deep cloning
61
                $serializedDef = \serialize($definition);
62
63
                foreach ($classes as $resource) {
64
                    $this->container->set($resource, (\unserialize($serializedDef))->replace($resource, true));
65
                }
66
            }
67
68
            $this->classes = [];
69
        }
70
    }
71
72
    /**
73
     * Where all the magic happens.
74
     *
75
     * @param array<int,mixed> $arguments
76
     *
77
     * @return $this
78
     *
79
     * @throws \Throwable
80
     */
81
    public function __call(string $name, array $arguments)
82
    {
83
        if (!$id = $this->definition) {
84
            throw $this->createInitializingError(__METHOD__);
85
        }
86
87
        if ($this->trackDefaults) {
88
            $this->defaults[$id][] = [$name, $arguments];
89
        } else {
90
            try {
91
                $definition = (!isset($this->classes[$id]) ? $this->container->definition($id) : $this->classes[$id][0]);
92
93
                if (\method_exists($definition, $name)) {
94
                    $definition->{$name}(...$arguments);
95
                }
96
            } catch (\Error $e) {
97
                throw $this->createErrorException($name, $e);
98
            }
99
        }
100
101
        return $this;
102
    }
103
104
    /**
105
     * Set a config into container's parameter.
106
     *
107
     * @param mixed $value
108
     *
109
     * @return $this
110
     */
111
    public function parameter(string $name, $value)
112
    {
113
        $this->container->parameters[$name] = $value;
114
115
        return $this;
116
    }
117
118
    /**
119
     * Marks an alias id to service id.
120
     *
121
     * @return $this
122
     */
123
    public function alias(string $id, string $serviceId = null)
124
    {
125
        $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

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