Passed
Pull Request — master (#1124)
by
unknown
10:24
created

Parser::getParseSource()   B

Complexity

Conditions 7
Paths 28

Size

Total Lines 26
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 17
CRAP Score 7

Importance

Changes 0
Metric Value
eloc 16
dl 0
loc 26
ccs 17
cts 17
cp 1
rs 8.8333
c 0
b 0
f 0
cc 7
nc 28
nop 1
crap 7
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Spiral\Console\Configurator\Attribute;
6
7
use Spiral\Attributes\AttributeReader;
8
use Spiral\Attributes\ReaderInterface;
9
use Spiral\Console\Attribute\Argument;
10
use Spiral\Console\Attribute\AsCommand;
11
use Spiral\Console\Attribute\AsInput;
12
use Spiral\Console\Attribute\Option;
13
use Spiral\Console\Command;
14
use Spiral\Console\Configurator\CommandDefinition;
15
use Spiral\Console\Exception\ConfiguratorException;
16
use Symfony\Component\Console\Attribute\AsCommand as SymfonyAsCommand;
17
use Symfony\Component\Console\Input\InputArgument;
18
use Symfony\Component\Console\Input\InputInterface;
19
use Symfony\Component\Console\Input\InputOption;
20
21
/**
22
 * @internal
23
 */
24
final class Parser
25
{
26 154
    public function __construct(
27
        private readonly ReaderInterface $reader = new AttributeReader()
28
    ) {
29 154
    }
30
31 113
    public function hasCommandAttribute(\ReflectionClass $reflection): bool
32
    {
33 113
        return $this->reader->firstClassMetadata($reflection, AsCommand::class) !== null ||
34 113
            $reflection->getAttributes(SymfonyAsCommand::class) !== [];
35
    }
36
37 122
    public function parse(\ReflectionClass $reflection): CommandDefinition
38
    {
39 122
        $attribute = $this->reader->firstClassMetadata($reflection, AsCommand::class);
40
41 122
        if ($attribute === null) {
42 1
            $attribute = $reflection->getAttributes(SymfonyAsCommand::class)[0]->newInstance();
43
        }
44
45 122
        $parseSourceReflection = $this->getParseSource($reflection);
46
47 122
        return new CommandDefinition(
48 122
            name: $attribute->name,
49 122
            arguments: $this->parseArguments($parseSourceReflection),
50 122
            options: $this->parseOptions($parseSourceReflection),
51 122
            description: $attribute->description,
52 122
            help: $attribute instanceof AsCommand ? $attribute->help : null
53 122
        );
54
    }
55
56 106
    public function fillProperties(Command $command, InputInterface $input): void
57
    {
58 106
        $reflection = new \ReflectionClass($command);
59
60 106
        foreach ($reflection->getProperties() as $property) {
61 106
            $attribute = $this->reader->firstPropertyMetadata($property, Argument::class);
62 106
            if ($attribute === null) {
63 106
                continue;
64
            }
65
66 37
            if ($input->hasArgument($attribute->name ?? $property->getName())) {
67 36
                $property->setValue(
68 36
                    $command,
69 36
                    $this->typecast($input->getArgument($attribute->name ?? $property->getName()), $property)
70 36
                );
71
            }
72
        }
73
74 106
        foreach ($reflection->getProperties() as $property) {
75 106
            $attribute = $this->reader->firstPropertyMetadata($property, Option::class);
76 106
            if ($attribute === null) {
77 105
                continue;
78
            }
79
80 40
            if ($input->hasOption($attribute->name ?? $property->getName())) {
81 39
                $value = $this->typecast($input->getOption($attribute->name ?? $property->getName()), $property);
82
83 38
                if ($value !== null || $this->getPropertyType($property)->allowsNull()) {
84 37
                    $property->setValue($command, $value);
85
                }
86
            }
87
        }
88
    }
89
90
    /**
91
     * Get the method that should be used to parse the command.
92
     *
93
     * This either can be the command itself, of the `#[AsInput]` parameter of the `perform` or `__invoke` method.
94
     */
95 122
    private function getParseSource(\ReflectionClass $reflection): \ReflectionClass
96
    {
97 122
        $method = $reflection->hasMethod('perform')
98 78
            ? $reflection->getMethod('perform')
99 72
            : ($reflection->hasMethod('__invoke')
100 29
                ? $reflection->getMethod('__invoke')
101 72
                : null);
102
103 122
        if ($method === null) {
104 43
            return $reflection;
105
        }
106
107 79
        $parameter = null;
108 79
        foreach ($method->getParameters() as $param) {
109 68
            $attribute = $this->reader->firstParameterMetadata($param, AsInput::class);
110 68
            if ($attribute !== null) {
111 2
                $parameter = $param;
112 2
                break;
113
            }
114
        }
115
116 79
        if ($parameter === null) {
117 77
            return $reflection;
118
        }
119
120 2
        return new \ReflectionClass($parameter->getType()->getName());
121
    }
122
123 122
    private function parseArguments(\ReflectionClass $reflection): array
124
    {
125 122
        $result = [];
126 122
        $arrayArgument = null;
127 122
        foreach ($reflection->getProperties() as $property) {
128 122
            $attribute = $this->reader->firstPropertyMetadata($property, Argument::class);
129 122
            if ($attribute === null) {
130 108
                continue;
131
            }
132
133 84
            $type = $this->getPropertyType($property);
134
135 81
            $isOptional = $property->hasDefaultValue() || $type->allowsNull();
136 81
            $isArray = $type->getName() === 'array';
137 81
            $mode = match (true) {
138 81
                $isArray && !$isOptional => InputArgument::IS_ARRAY | InputArgument::REQUIRED,
139 79
                $isArray && $isOptional => InputArgument::IS_ARRAY | InputArgument::OPTIONAL,
140 78
                $isOptional => InputArgument::OPTIONAL,
141 76
                default => InputArgument::REQUIRED
142 81
            };
143
144 81
            $argument = new InputArgument(
145 81
                name: $attribute->name ?? $property->getName(),
146 81
                mode: $mode,
147 81
                description: (string) $attribute->description,
148 81
                default: $property->hasDefaultValue() ? $property->getDefaultValue() : null,
149 81
                suggestedValues: $attribute->suggestedValues
150 81
            );
151
152 81
            if ($arrayArgument !== null && $isArray) {
153 1
                throw new ConfiguratorException('There must be only one array argument!');
154
            }
155
156
            // It must be used at the end of the argument list.
157 81
            if ($isArray) {
158 5
                $arrayArgument = $argument;
159 5
                continue;
160
            }
161 78
            $result[] = $argument;
162
        }
163
164 118
        if ($arrayArgument !== null) {
165 4
            $result[] = $arrayArgument;
166
        }
167
168 118
        return $result;
169
    }
170
171 118
    private function parseOptions(\ReflectionClass $reflection): array
172
    {
173 118
        $result = [];
174 118
        foreach ($reflection->getProperties() as $property) {
175 118
            $attribute = $this->reader->firstPropertyMetadata($property, Option::class);
176 118
            if ($attribute === null) {
177 97
                continue;
178
            }
179
180 95
            $type = $this->getPropertyType($property);
181 92
            $mode = $attribute->mode;
182
183 92
            if ($mode === null) {
184 75
                $mode = $this->guessOptionMode($type, $property);
185
            }
186
187 92
            if ($mode === InputOption::VALUE_NONE || $mode === InputOption::VALUE_NEGATABLE) {
188 69
                if ($type->getName() !== 'bool') {
189
                    throw new ConfiguratorException(
190
                        'Options properties with mode `VALUE_NONE` or `VALUE_NEGATABLE` must be bool!'
191
                    );
192
                }
193
            }
194
195 92
            $hasDefaultValue = $attribute->mode !== InputOption::VALUE_NONE && $property->hasDefaultValue();
196
197 92
            $result[] = new InputOption(
198 92
                name: $attribute->name ?? $property->getName(),
199 92
                shortcut: $attribute->shortcut,
200 92
                mode: $mode,
201 92
                description: (string) $attribute->description,
202 92
                default: $hasDefaultValue ? $property->getDefaultValue() : null,
203 92
                suggestedValues: $attribute->suggestedValues
204 92
            );
205
        }
206
207 115
        return $result;
208
    }
209
210 41
    private function typecast(mixed $value, \ReflectionProperty $property): mixed
211
    {
212 41
        $type = $property->hasType() ? $property->getType() : null;
213
214 41
        if (!$type instanceof \ReflectionNamedType || $value === null) {
215 37
            return $value;
216
        }
217
218 39
        if (!$type->isBuiltin() && \enum_exists($type->getName())) {
219
            try {
220
                /** @var class-string<\BackedEnum> $enum */
221 2
                $enum = $type->getName();
222
223 2
                return $enum::from($value);
224 1
            } catch (\Throwable) {
225 1
                throw new ConfiguratorException(\sprintf('Wrong option value. Allowed options: `%s`.', \implode(
226 1
                    '`, `',
227 1
                    \array_map(static fn (\BackedEnum $item): string => (string) $item->value, $enum::cases())
0 ignored issues
show
Bug introduced by
Accessing value on the interface BackedEnum suggest that you code against a concrete implementation. How about adding an instanceof check?
Loading history...
228 1
                )));
229
            }
230
        }
231
232 37
        return match ($type->getName()) {
233 2
            'int' => (int) $value,
234 37
            'string' => (string) $value,
235 23
            'bool' => (bool) $value,
236 2
            'float' => (float) $value,
237 18
            'array' => (array) $value,
238 37
            default => $value
239 37
        };
240
    }
241
242 111
    private function getPropertyType(\ReflectionProperty $property): \ReflectionNamedType
243
    {
244 111
        if (!$property->hasType()) {
245
            throw new ConfiguratorException(
246
                \sprintf('Please, specify the type for the `%s` property!', $property->getName())
247
            );
248
        }
249
250 111
        $type = $property->getType();
251
252 111
        if ($type instanceof \ReflectionIntersectionType) {
0 ignored issues
show
Bug introduced by
The type ReflectionIntersectionType was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
253 2
            throw new ConfiguratorException(\sprintf('Invalid type for the `%s` property.', $property->getName()));
254
        }
255
256 109
        if ($type instanceof \ReflectionUnionType) {
257 2
            foreach ($type->getTypes() as $type) {
258 2
                if ($type instanceof \ReflectionNamedType && $type->isBuiltin()) {
259 2
                    return $type;
260
                }
261
            }
262
        }
263
264 107
        if ($type instanceof \ReflectionNamedType && !$type->isBuiltin() && \enum_exists($type->getName())) {
265 2
            return $type;
266
        }
267
268 105
        if ($type instanceof \ReflectionNamedType && $type->isBuiltin() && $type->getName() !== 'object') {
269 101
            return $type;
270
        }
271
272 4
        throw new ConfiguratorException(\sprintf('Invalid type for the `%s` property.', $property->getName()));
273
    }
274
275
    /**
276
     * @return int<0, 31>
277
     */
278 75
    private function guessOptionMode(\ReflectionNamedType $type, \ReflectionProperty $property): int
279
    {
280 75
        $isOptional = $type->allowsNull() || $property->hasDefaultValue();
281
282
        return match (true) {
283 75
            $type->getName() === 'bool' => InputOption::VALUE_NEGATABLE,
284 75
            $type->getName() === 'array' && $isOptional => InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY,
285 73
            $type->getName() === 'array' && !$isOptional => InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
286 72
            $type->allowsNull() || $property->hasDefaultValue() => InputOption::VALUE_OPTIONAL,
287 75
            default => InputOption::VALUE_REQUIRED
288
        };
289
    }
290
}
291