Lines 94.59% 70 / 74
Functions and Methods 80.00% 8 / 10
Classes and Traits 0.00% 0 / 1
Name Lines Functions and Methods CRAP Classes and Traits
AppOptionResolver 94.59% 70 / 74 80.00% 8 / 10 37.22 0.00% 0 / 1
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 resolveOptionArguments 100.00% 29 / 29 100.00% 1 / 1 7
 findParameterForOption 100.00% 7 / 7 100.00% 1 / 1 3
 foldOptionName 100.00% 1 / 1 100.00% 1 / 1 1
 listConstructorParameters 100.00% 4 / 4 100.00% 1 / 1 2
 needsExplicitValue 100.00% 5 / 5 100.00% 1 / 1 5
 hasAutowiringAttribute 100.00% 3 / 3 100.00% 1 / 1 3
 namesAService 40.00% 2 / 5 0.00% 0 / 1 7.46
 coerceToParameterType 100.00% 14 / 14 100.00% 1 / 1 8
 escapeParameterPlaceholders 80.00% 4 / 5 0.00% 0 / 1 3.07
1<?php
2
3declare(strict_types=1);
4
5namespace Stewart\Runtime\Container;
6
7use ReflectionAttribute;
8use ReflectionClass;
9use ReflectionIntersectionType;
10use ReflectionNamedType;
11use ReflectionParameter;
12use ReflectionType;
13use ReflectionUnionType;
14use Stewart\Runtime\Config\BooleanSpelling;
15use Stewart\Runtime\Exception\ContainerError;
16use Stewart\Runtime\Exception\ContainerException;
17use Stewart\Runtime\Ipc\WorkerApp;
18use Stewart\Support\Text\ClosestNameFinder;
19use Symfony\Component\DependencyInjection\Attribute\Autowire;
20use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;
21use Symfony\Component\DependencyInjection\Attribute\Lazy;
22
23final readonly class AppOptionResolver
24{
25    private const array COERCIBLE_TYPES = ['bool', 'int', 'float'];
26
27    public function __construct(private ClosestNameFinder $closestNameFinder) {}
28
29    /**
30     * @return array<string, mixed>
31     * @throws ContainerException
32     */
33    public function resolveOptionArguments(WorkerApp $app): array
34    {
35        if (!class_exists($app->class)) {
36            throw ContainerException::createForReason(ContainerError::AppClassMissing, appId: $app->id->value, class: $app->class);
37        }
38
39        $parameters = $this->listConstructorParameters($app->class);
40        $arguments = [];
41        $optionsByParameter = [];
42
43        foreach ($app->options as $option => $value) {
44            $option = (string) $option;
45            $parameter = $this->findParameterForOption($app, $parameters, $option) ?? throw ContainerException::appOptionUnknown(
46                $app->id,
47                $option,
48                $app->class,
49                $this->closestNameFinder->findClosestName($option, array_keys($parameters)),
50            );
51            $name = $parameter->getName();
52
53            if (isset($optionsByParameter[$name])) {
54                throw ContainerException::createForReason(
55                    ContainerError::AppOptionRepeated,
56                    appId: $app->id->value,
57                    parameter: $name,
58                    class: $app->class,
59                    firstOption: $optionsByParameter[$name],
60                    secondOption: $option,
61                );
62            }
63
64            $optionsByParameter[$name] = $option;
65            $arguments['$' . $name] = $this->escapeParameterPlaceholders($this->coerceToParameterType($app, $parameter, $value));
66        }
67
68        foreach ($parameters as $name => $parameter) {
69            if (!isset($optionsByParameter[$name]) && $this->needsExplicitValue($parameter)) {
70                throw ContainerException::createForReason(ContainerError::AppOptionMissing, appId: $app->id->value, option: $name, class: $app->class);
71            }
72        }
73
74        return $arguments;
75    }
76
77    /**
78     * @param array<string, ReflectionParameter> $parameters
79     * @throws ContainerException
80     */
81    private function findParameterForOption(WorkerApp $app, array $parameters, string $option): ?ReflectionParameter
82    {
83        if (isset($parameters[$option])) {
84            return $parameters[$option];
85        }
86
87        $folded = self::foldOptionName($option);
88        $matches = array_values(array_filter($parameters, static fn(ReflectionParameter $parameter): bool => self::foldOptionName($parameter->getName()) === $folded));
89
90        if (\count($matches) > 1) {
91            throw ContainerException::appOptionAmbiguous($app->id, $option, $app->class, array_map(static fn(ReflectionParameter $parameter): string => $parameter->getName(), $matches));
92        }
93
94        return $matches[0] ?? null;
95    }
96
97    // Environment variable names lose case and word breaks, so MOTION_SENSOR has to reach $motionSensor.
98    private static function foldOptionName(string $name): string
99    {
100        return strtolower(str_replace(['_', '-'], '', $name));
101    }
102
103    /**
104     * @param class-string $class
105     * @return array<string, ReflectionParameter>
106     */
107    private function listConstructorParameters(string $class): array
108    {
109        $parameters = [];
110
111        foreach (new ReflectionClass($class)->getConstructor()?->getParameters() ?? [] as $parameter) {
112            $parameters[$parameter->getName()] = $parameter;
113        }
114
115        return $parameters;
116    }
117
118    // Mirrors Symfony's AutowirePass: a parameter with no service type and no default needs a configured value.
119    private function needsExplicitValue(ReflectionParameter $parameter): bool
120    {
121        return !$parameter->isVariadic()
122            && !$parameter->isDefaultValueAvailable()
123            && !$parameter->isOptional()
124            && !$this->hasAutowiringAttribute($parameter)
125            && !$this->namesAService($parameter->getType());
126    }
127
128    private function hasAutowiringAttribute(ReflectionParameter $parameter): bool
129    {
130        return $parameter->getAttributes(Autowire::class, ReflectionAttribute::IS_INSTANCEOF) !== []
131            || $parameter->getAttributes(Lazy::class, ReflectionAttribute::IS_INSTANCEOF) !== []
132            || $parameter->getAttributes(AutowireDecorated::class) !== [];
133    }
134
135    private function namesAService(?ReflectionType $type): bool
136    {
137        if ($type instanceof ReflectionNamedType) {
138            return !$type->isBuiltin();
139        }
140
141        if ($type instanceof ReflectionUnionType || $type instanceof ReflectionIntersectionType) {
142            return array_any($type->getTypes(), $this->namesAService(...));
143        }
144
145        return false;
146    }
147
148    /** @throws ContainerException */
149    private function coerceToParameterType(WorkerApp $app, ReflectionParameter $parameter, mixed $value): mixed
150    {
151        $type = $parameter->getType();
152
153        if (!\is_string($value) || !$type instanceof ReflectionNamedType || !\in_array($type->getName(), self::COERCIBLE_TYPES, true)) {
154            return $value;
155        }
156
157        $coerced = match ($type->getName()) {
158            'bool' => BooleanSpelling::tryParseBoolean($value),
159            'int' => filter_var(trim($value), \FILTER_VALIDATE_INT, \FILTER_NULL_ON_FAILURE),
160            default => is_numeric(trim($value)) ? (float) trim($value) : null,
161        };
162
163        return $coerced ?? throw ContainerException::createForReason(
164            ContainerError::AppOptionInvalid,
165            appId: $app->id->value,
166            option: $parameter->getName(),
167            value: $value,
168            expectedType: $type->getName(),
169        );
170    }
171
172    // Symfony resolves %name% in arguments; app options are literals.
173    private function escapeParameterPlaceholders(mixed $value): mixed
174    {
175        if (\is_string($value)) {
176            return str_replace('%', '%%', $value);
177        }
178
179        if (\is_array($value)) {
180            return array_map($this->escapeParameterPlaceholders(...), $value);
181        }
182
183        return $value;
184    }
185}