Passed
Pull Request — master (#1057)
by Maxim
12:01
created

Parser::parse()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 14
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 10
CRAP Score 3

Importance

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