DependencyResolver::getParametersToResolve()   A
last analyzed

Complexity

Conditions 3
Paths 3

Size

Total Lines 12
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 3

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 7
c 1
b 0
f 0
dl 0
loc 12
ccs 8
cts 8
cp 1
rs 10
cc 3
nc 3
nop 1
crap 3
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Gacela\Container;
6
7
use Closure;
8
use Gacela\Container\Attribute\Inject;
9
use Gacela\Container\Exception\CircularDependencyException;
10
use Gacela\Container\Exception\DependencyInvalidArgumentException;
11
use Gacela\Container\Exception\DependencyNotFoundException;
12
use ReflectionClass;
13
use ReflectionFunction;
14
use ReflectionMethod;
15
use ReflectionNamedType;
16
use ReflectionParameter;
17
18
use function count;
19
use function is_callable;
20
use function is_object;
21
use function is_string;
22
23
final class DependencyResolver
24
{
25
    /** @var array<class-string, ReflectionClass<object>> */
0 ignored issues
show
Documentation Bug introduced by
The doc comment array<class-string, ReflectionClass<object>> at position 2 could not be parsed: Unknown type name 'class-string' at position 2 in array<class-string, ReflectionClass<object>>.
Loading history...
26
    private array $reflectionCache = [];
27
28
    /** @var array<class-string, ?ReflectionMethod> */
0 ignored issues
show
Documentation Bug introduced by
The doc comment array<class-string, ?ReflectionMethod> at position 2 could not be parsed: Unknown type name 'class-string' at position 2 in array<class-string, ?ReflectionMethod>.
Loading history...
29
    private array $constructorCache = [];
30
31
    /** @var array<class-string, bool> */
0 ignored issues
show
Documentation Bug introduced by
The doc comment array<class-string, bool> at position 2 could not be parsed: Unknown type name 'class-string' at position 2 in array<class-string, bool>.
Loading history...
32
    private array $resolvingStack = [];
33
34
    /** @var array<string, bool> */
35
    private array $classExistsCache = [];
36
37
    /** @var array<string, bool> */
38
    private array $interfaceExistsCache = [];
39
40
    /** @var list<class-string> */
41
    private array $buildStack = [];
42
43
    /**
44
     * @param array<class-string,class-string|callable|object> $bindings
45
     * @param array<string, array<class-string, class-string|callable|object>> $contextualBindings
46
     */
47 59
    public function __construct(
48
        private array $bindings = [],
49
        private array &$contextualBindings = [],
50
    ) {
51 59
    }
52
53
    /**
54
     * @param class-string|Closure $toResolve
0 ignored issues
show
Documentation Bug introduced by
The doc comment class-string|Closure at position 0 could not be parsed: Unknown type name 'class-string' at position 0 in class-string|Closure.
Loading history...
55
     *
56
     * @return list<mixed>
57
     */
58 59
    public function resolveDependencies(string|Closure $toResolve): array
59
    {
60
        // Track which class is being resolved for contextual bindings
61 59
        if (is_string($toResolve)) {
62 47
            $this->buildStack[] = $toResolve;
63
        }
64
65
        try {
66
            /** @var list<mixed> $dependencies */
67 59
            $dependencies = [];
68
69 59
            $parameters = $this->getParametersToResolve($toResolve);
70
71 59
            foreach ($parameters as $parameter) {
72
                /** @psalm-suppress MixedAssignment */
73 46
                $dependencies[] = $this->resolveDependenciesRecursively($parameter);
74
            }
75
76 49
            return $dependencies;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $dependencies returns the type Gacela\Container\list which is incompatible with the type-hinted return array.
Loading history...
77
        } finally {
78 59
            if (is_string($toResolve)) {
79 59
                array_pop($this->buildStack);
0 ignored issues
show
Bug Best Practice introduced by
In this branch, the function will implicitly return null which is incompatible with the type-hinted return array. Consider adding a return statement or allowing null as return value.

For hinted functions/methods where all return statements with the correct type are only reachable via conditions, ?null? gets implicitly returned which may be incompatible with the hinted type. Let?s take a look at an example:

interface ReturnsInt {
    public function returnsIntHinted(): int;
}

class MyClass implements ReturnsInt {
    public function returnsIntHinted(): int
    {
        if (foo()) {
            return 123;
        }
        // here: null is implicitly returned
    }
}
Loading history...
80
            }
0 ignored issues
show
Bug Best Practice introduced by
The function implicitly returns null when the if condition on line 78 is false. This is incompatible with the type-hinted return array. Consider adding a return statement or allowing null as return value.

For hinted functions/methods where all return statements with the correct type are only reachable via conditions, ?null? gets implicitly returned which may be incompatible with the hinted type. Let?s take a look at an example:

interface ReturnsInt {
    public function returnsIntHinted(): int;
}

class MyClass implements ReturnsInt {
    public function returnsIntHinted(): int
    {
        if (foo()) {
            return 123;
        }
        // here: null is implicitly returned
    }
}
Loading history...
81
        }
82
    }
83
84
    /**
85
     * @param class-string|Closure $toResolve
0 ignored issues
show
Documentation Bug introduced by
The doc comment class-string|Closure at position 0 could not be parsed: Unknown type name 'class-string' at position 0 in class-string|Closure.
Loading history...
86
     *
87
     * @return list<ReflectionParameter>
88
     */
89 59
    private function getParametersToResolve(Closure|string $toResolve): array
90
    {
91 59
        if (is_string($toResolve)) {
92 47
            $constructor = $this->getConstructor($toResolve);
93 47
            if (!$constructor) {
94 12
                return [];
95
            }
96 36
            return $constructor->getParameters();
0 ignored issues
show
Bug Best Practice introduced by
The expression return $constructor->getParameters() returns the type ReflectionParameter[] which is incompatible with the documented return type Gacela\Container\list.
Loading history...
97
        }
98
99 12
        $reflection = new ReflectionFunction($toResolve);
100 12
        return $reflection->getParameters();
0 ignored issues
show
Bug Best Practice introduced by
The expression return $reflection->getParameters() returns the type ReflectionParameter[] which is incompatible with the documented return type Gacela\Container\list.
Loading history...
101
    }
102
103
    /**
104
     * @param class-string $className
105
     */
106 52
    private function getConstructor(string $className): ?ReflectionMethod
107
    {
108 52
        if (!isset($this->constructorCache[$className])) {
109 52
            $reflection = new ReflectionClass($className);
110 52
            $this->constructorCache[$className] = $reflection->getConstructor();
111
        }
112
113 52
        return $this->constructorCache[$className];
114
    }
115
116 46
    private function resolveDependenciesRecursively(ReflectionParameter $parameter): mixed
117
    {
118 46
        $this->checkInvalidArgumentParam($parameter);
119
120
        // Check for #[Inject] attribute
121 42
        $attributes = $parameter->getAttributes(Inject::class);
122 42
        if (count($attributes) > 0) {
123
            /** @var Inject $inject */
124 3
            $inject = $attributes[0]->newInstance();
125 3
            if ($inject->implementation !== null) {
126 2
                return $this->resolveClass($inject->implementation);
127
            }
128
        }
129
130
        /** @var ReflectionNamedType $paramType */
131 41
        $paramType = $parameter->getType();
132
133
        /** @var class-string $paramTypeName */
134 41
        $paramTypeName = $paramType->getName();
135 41
        if ($parameter->isDefaultValueAvailable()) {
136 19
            return $parameter->getDefaultValue();
137
        }
138
139 36
        return $this->resolveClass($paramTypeName);
140
    }
141
142 46
    private function checkInvalidArgumentParam(ReflectionParameter $parameter): void
143
    {
144 46
        if (!$parameter->hasType()) {
145 2
            $chain = $this->getResolutionChain();
146 2
            throw DependencyInvalidArgumentException::noParameterTypeFor($parameter->getName(), $chain);
147
        }
148
149
        /** @var ReflectionNamedType $paramType */
150 44
        $paramType = $parameter->getType();
151 44
        $paramTypeName = $paramType->getName();
152
153 44
        if ($this->isScalar($paramTypeName) && !$parameter->isDefaultValueAvailable()) {
154
            /** @var ReflectionClass<object> $reflectionClass */
155 3
            $reflectionClass = $parameter->getDeclaringClass();
156 3
            $chain = $this->getResolutionChain();
157 3
            throw DependencyInvalidArgumentException::unableToResolve($paramTypeName, $reflectionClass->getName(), $chain);
158
        }
159
    }
160
161
    /**
162
     * @return list<string>
163
     */
164 5
    private function getResolutionChain(): array
165
    {
166 5
        return array_keys($this->resolvingStack);
167
    }
168
169 44
    private function isScalar(string $paramTypeName): bool
170
    {
171 44
        return !$this->classExists($paramTypeName)
172 44
            && !$this->interfaceExists($paramTypeName);
173
    }
174
175 44
    private function classExists(string $className): bool
176
    {
177 44
        if (!isset($this->classExistsCache[$className])) {
178 44
            $this->classExistsCache[$className] = class_exists($className);
179
        }
180
181 44
        return $this->classExistsCache[$className];
182
    }
183
184 37
    private function interfaceExists(string $interfaceName): bool
185
    {
186 37
        if (!isset($this->interfaceExistsCache[$interfaceName])) {
187 37
            $this->interfaceExistsCache[$interfaceName] = interface_exists($interfaceName);
188
        }
189
190 37
        return $this->interfaceExistsCache[$interfaceName];
191
    }
192
193
    /**
194
     * @param class-string $paramTypeName
195
     */
196 37
    private function resolveClass(string $paramTypeName): mixed
197
    {
198
        // Check for contextual binding first
199 37
        $contextualBinding = $this->getContextualBinding($paramTypeName);
200 37
        if ($contextualBinding !== null) {
201 5
            if (is_callable($contextualBinding)) {
202
                /** @psalm-suppress MixedFunctionCall */
203 1
                return $contextualBinding();
204
            }
205
206 4
            if (is_object($contextualBinding)) {
207 1
                return $contextualBinding;
208
            }
209
210
            // It's a class string - use it instead of the interface
211
            /** @var class-string $contextualBinding */
212 3
            $paramTypeName = $contextualBinding;
213
        }
214
215
        /** @var mixed $bindClass */
216 35
        $bindClass = $this->bindings[$paramTypeName] ?? null;
217 35
        if (is_callable($bindClass)) {
218 1
            return $bindClass();
219
        }
220
221 34
        if (is_object($bindClass)) {
222 3
            return $bindClass;
223
        }
224
225 31
        $this->checkCircularDependency($paramTypeName);
226
227 31
        $reflection = $this->resolveReflectionClass($paramTypeName);
228
        // Use the concrete class name for caching, not the original parameter type
229 28
        $constructor = $this->getConstructor($reflection->getName());
230 28
        if ($constructor === null) {
231 13
            return $reflection->newInstance();
232
        }
233
234 16
        return $this->resolveInnerDependencies($constructor, $reflection);
235
    }
236
237
    /**
238
     * @param class-string $className
239
     */
240 31
    private function checkCircularDependency(string $className): void
241
    {
242 31
        if (isset($this->resolvingStack[$className])) {
243 2
            $chain = array_keys($this->resolvingStack);
244 2
            $chain[] = $className;
245 2
            throw CircularDependencyException::create($chain);
246
        }
247
    }
248
249
    /**
250
     * @param class-string $paramTypeName
251
     *
252
     * @return ReflectionClass<object>
253
     */
254 31
    private function resolveReflectionClass(string $paramTypeName): ReflectionClass
255
    {
256 31
        if (!isset($this->reflectionCache[$paramTypeName])) {
257 31
            $reflection = new ReflectionClass($paramTypeName);
258
259 31
            if (!$reflection->isInstantiable()) {
260
                /** @var mixed $concreteClass */
261 10
                $concreteClass = $this->bindings[$reflection->getName()] ?? null;
262
263 10
                if ($concreteClass !== null) {
264
                    /** @var class-string $concreteClass */
265 7
                    $reflection = new ReflectionClass($concreteClass);
266
                } else {
267 3
                    $suggestions = FuzzyMatcher::findSimilar(
268 3
                        $reflection->getName(),
269 3
                        array_keys($this->bindings),
270 3
                    );
271 3
                    throw DependencyNotFoundException::mapNotFoundForClassName(
272 3
                        $reflection->getName(),
273 3
                        $suggestions,
274 3
                    );
275
                }
276
            }
277
278 28
            $this->reflectionCache[$paramTypeName] = $reflection;
279
        }
280
281 28
        return $this->reflectionCache[$paramTypeName];
282
    }
283
284
    /**
285
     * @param ReflectionClass<object> $reflection
286
     */
287 16
    private function resolveInnerDependencies(ReflectionMethod $constructor, ReflectionClass $reflection): object
288
    {
289 16
        $className = $reflection->getName();
290 16
        $this->resolvingStack[$className] = true;
291
292
        try {
293
            /** @var list<mixed> $innerDependencies */
294 16
            $innerDependencies = [];
295
296 16
            foreach ($constructor->getParameters() as $constructorParameter) {
297 16
                $paramType = $constructorParameter->getType();
298 16
                if ($paramType) {
299
                    /** @psalm-suppress MixedAssignment */
300 16
                    $innerDependencies[] = $this->resolveDependenciesRecursively($constructorParameter);
301
                }
302
            }
303
304 14
            return $reflection->newInstanceArgs($innerDependencies);
0 ignored issues
show
Bug introduced by
$innerDependencies of type Gacela\Container\list is incompatible with the type array expected by parameter $args of ReflectionClass::newInstanceArgs(). ( Ignorable by Annotation )

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

304
            return $reflection->newInstanceArgs(/** @scrutinizer ignore-type */ $innerDependencies);
Loading history...
305
        } finally {
306 16
            unset($this->resolvingStack[$className]);
307
        }
308
    }
309
310
    /**
311
     * Get contextual binding for the current build context.
312
     *
313
     * @param class-string $abstract
314
     *
315
     * @return class-string|callable|object|null
0 ignored issues
show
Documentation Bug introduced by
The doc comment class-string|callable|object|null at position 0 could not be parsed: Unknown type name 'class-string' at position 0 in class-string|callable|object|null.
Loading history...
316
     */
317 37
    private function getContextualBinding(string $abstract): mixed
318
    {
319
        // Check the build stack for contextual bindings
320
        // Start from the end (most specific context) to the beginning
321 37
        for ($i = count($this->buildStack) - 1; $i >= 0; --$i) {
322 29
            $concrete = $this->buildStack[$i];
323 29
            if (isset($this->contextualBindings[$concrete][$abstract])) {
324 5
                return $this->contextualBindings[$concrete][$abstract];
325
            }
326
        }
327
328 33
        return null;
329
    }
330
}
331