Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
194 / 194
98.84% covered (success)
98.84%
171 / 173
28.85% covered (danger)
28.85%
73 / 253
89.47% covered (warning)
89.47%
17 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
Signature
100.00% covered (success)
100.00%
194 / 194
98.84% covered (success)
98.84%
171 / 173
28.85% covered (danger)
28.85%
73 / 253
100.00% covered (success)
100.00%
19 / 19
2749.50
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 of
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
12 / 12
35.29% covered (danger)
35.29%
6 / 17
100.00% covered (success)
100.00%
1 / 1
15.75
 kebab
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 arguments
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 options
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 asksForHelp
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
10 / 10
66.67% covered (warning)
66.67%
4 / 6
100.00% covered (success)
100.00%
1 / 1
5.93
 bind
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
13 / 13
21.43% covered (danger)
21.43%
6 / 28
100.00% covered (success)
100.00%
1 / 1
39.04
 match
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
6 / 6
75.00% covered (warning)
75.00%
3 / 4
100.00% covered (success)
100.00%
1 / 1
3.14
 add
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
9 / 9
50.00% covered (danger)
50.00%
4 / 8
100.00% covered (success)
100.00%
1 / 1
8.12
 type
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 addArgument
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 addOption
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
14 / 14
21.43% covered (danger)
21.43%
6 / 28
100.00% covered (success)
100.00%
1 / 1
30.77
 checkOption
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
14 / 14
88.89% covered (warning)
88.89%
8 / 9
100.00% covered (success)
100.00%
1 / 1
8.09
 inject
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
18 / 18
7.81% covered (danger)
7.81%
5 / 64
100.00% covered (success)
100.00%
1 / 1
58.14
 addGroup
100.00% covered (success)
100.00%
16 / 16
91.67% covered (success)
91.67%
11 / 12
12.50% covered (danger)
12.50%
2 / 16
100.00% covered (success)
100.00%
1 / 1
14.72
 checkArguments
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
8 / 8
66.67% covered (warning)
66.67%
4 / 6
100.00% covered (success)
100.00%
1 / 1
4.59
 parse
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
19 / 19
14.29% covered (danger)
14.29%
4 / 28
100.00% covered (success)
100.00%
1 / 1
48.30
 bindArguments
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
14 / 14
72.73% covered (warning)
72.73%
8 / 11
100.00% covered (success)
100.00%
1 / 1
7.99
 unknownOption
100.00% covered (success)
100.00%
10 / 10
92.86% covered (success)
92.86%
13 / 14
25.00% covered (danger)
25.00%
5 / 20
100.00% covered (success)
100.00%
1 / 1
15.55
1<?php
2
3declare(strict_types=1);
4
5namespace Celema\Console;
6
7use Celema\Console\Exception\InvalidUsage;
8use ReflectionClass;
9use ReflectionMethod;
10use ReflectionNamedType;
11use ReflectionParameter;
12use ValueError;
13
14/**
15 * The interface a command declares with its `__invoke()` parameters.
16 *
17 * Parameters typed `Args` or `Io` are injected; `#[Arg]` and `#[Opt]`
18 * parameters receive the converted command-line input and form the
19 * command's complete interface, together with the `#[Opt]` constructor
20 * parameters of option groups. Declaration errors surface when the
21 * signature is read, input errors when it is bound.
22 *
23 * @internal
24 */
25final class Signature
26{
27    /** @var list<Argument> */
28    private array $arguments = [];
29
30    /**
31     * Keyed by long name.
32     *
33     * @var array<string, Option>
34     */
35    private array $options = [];
36
37    /**
38     * The long names keyed by short name.
39     *
40     * @var array<string, string>
41     */
42    private array $aliases = [];
43
44    /**
45     * The long names of negatable flags keyed by their `--no-` name.
46     *
47     * @var array<string, string>
48     */
49    private array $negations = [];
50
51    /**
52     * The names of the injected parameters, keyed by class.
53     *
54     * @var array<string, string>
55     */
56    private array $injected = [];
57
58    /**
59     * The option group classes, keyed by parameter name.
60     *
61     * @var array<string, class-string>
62     */
63    private array $groups = [];
64
65    /**
66     * The names of all `__invoke()` parameters, in declaration order.
67     *
68     * @var list<string>
69     */
70    private array $parameters = [];
71
72    private function __construct(
73        private readonly string $full,
74    ) {}
75
76    /**
77     * Reads the signature of a command class.
78     *
79     * @param class-string $class
80     * @param string $full The command's full name, for error messages
81     */
82    public static function of(string $class, string $full): self
83    {
84        $command = new ReflectionClass($class);
85
86        if (!$command->hasMethod('__invoke')) {
87            throw new ValueError("Command '{$full}' is not callable");
88        }
89
90        $method = $command->getMethod('__invoke');
91        $return = $method->getReturnType();
92
93        if (!$return instanceof ReflectionNamedType || $return->getName() !== 'int' || $return->allowsNull()) {
94            throw new ValueError("Command '{$full}' must declare the return type int");
95        }
96
97        $signature = new self($full);
98
99        foreach ($method->getParameters() as $parameter) {
100            $signature->add($parameter);
101        }
102
103        $signature->checkArguments();
104
105        return $signature;
106    }
107
108    /**
109     * Converts a parameter name to the kebab-case of command-line names.
110     *
111     * A word ends at an underscore, at a capital after a lowercase letter
112     * or digit, and at the last capital of an acronym: `HTMLOnly` is
113     * `html-only`.
114     */
115    public static function kebab(string $name): string
116    {
117        $words = preg_replace(
118            '/(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])|(?<=[a-zA-Z0-9])_+(?=[a-zA-Z0-9])/',
119            replacement: '-',
120            subject: $name,
121        );
122
123        return strtolower($words ?? $name);
124    }
125
126    /** @return list<Argument> */
127    public function arguments(): array
128    {
129        return $this->arguments;
130    }
131
132    /** @return array<string, Option> Keyed by long name */
133    public function options(): array
134    {
135        return $this->options;
136    }
137
138    /**
139     * Whether the tokens ask the runner for the help screen, with `--help`
140     * or `-h`.
141     *
142     * A command declaring `--help` answers both itself, and a `-h` declared
143     * as the short name of another option stays that option's.
144     *
145     * @throws InvalidUsage
146     *
147     * @param list<string> $tokens
148     */
149    public function asksForHelp(array $tokens): bool
150    {
151        if (array_key_exists('--help', $this->options)) {
152            return false;
153        }
154
155        // Parsing replaces a declared `-h` with its long name.
156        [$args] = $this->parse($tokens);
157
158        foreach (['--help', '-h'] as $name) {
159            if (!$args->has($name)) {
160                continue;
161            }
162
163            if ($args->opts($name) !== []) {
164                throw new InvalidUsage("Option '{$name}' does not accept a value");
165            }
166
167            return true;
168        }
169
170        return false;
171    }
172
173    /**
174     * Validates the command-line tokens against the declarations and
175     * converts them to the named arguments of `__invoke()`.
176     *
177     * @throws InvalidUsage
178     *
179     * Absent options and optional arguments are left out, so the
180     * parameters' defaults apply.
181     *
182     * @param list<string> $tokens
183     * @return array<string, mixed>
184     */
185    public function bind(array $tokens, Io $io): array
186    {
187        [$args, $counts, $bare] = $this->parse($tokens);
188        $values = [];
189
190        foreach ($this->injected as $class => $name) {
191            $values[$name] = $class === Args::class ? $args : $io;
192        }
193
194        $grouped = [];
195
196        foreach ($args->names() as $name) {
197            $long = $this->negations[$name] ?? $name;
198            $option = $this->options[$long] ?? throw new InvalidUsage($this->unknownOption($name));
199
200            if ($long !== $name && $args->has($long)) {
201                throw new InvalidUsage("Option '{$long}' cannot be combined with '{$name}'");
202            }
203
204            $value = $option->value($args, $name, $counts[$name], array_key_exists($name, $bare));
205
206            if ($option->group === null) {
207                $values[$option->parameter->name] = $value;
208            } else {
209                $grouped[$option->group][$option->parameter->name] = $value;
210            }
211        }
212
213        foreach ($this->groups as $name => $class) {
214            /** @psalm-suppress MixedMethodCall The group class is known only at runtime */
215            $values[$name] = new $class(...$grouped[$name] ?? []);
216        }
217
218        return [...$values, ...$this->bindArguments($args->positionals())];
219    }
220
221    /**
222     * Renames the bound values to the parameters of the command's own
223     * `__invoke()`, matching them by position.
224     *
225     * The values are bound to the registered class, but a factory may
226     * return a subclass whose override renames the parameters.
227     *
228     * @param array<string, mixed> $values
229     * @return array<string, mixed>
230     *
231     * @psalm-suppress MixedAssignment The values are of any parameter type
232     */
233    public function match(array $values, object $command): array
234    {
235        $parameters = new ReflectionMethod($command, '__invoke')->getParameters();
236        $matched = [];
237
238        foreach ($this->parameters as $position => $name) {
239            if (array_key_exists($name, $values)) {
240                $matched[$parameters[$position]->name] = $values[$name];
241            }
242        }
243
244        return $matched;
245    }
246
247    private function add(ReflectionParameter $parameter): void
248    {
249        $this->parameters[] = $parameter->name;
250        $arg = $parameter->getAttributes(Arg::class)[0] ?? null;
251        $opt = $parameter->getAttributes(Opt::class)[0] ?? null;
252
253        if ($arg !== null && $opt !== null) {
254            throw new ValueError(
255                "Command '{$this->full}' parameter \${$parameter->name} cannot carry both #[Arg] and #[Opt]",
256            );
257        }
258
259        if ($arg !== null) {
260            $this->addArgument(new Argument($parameter, $arg->newInstance(), $this->type($parameter)));
261
262            return;
263        }
264
265        if ($opt !== null) {
266            $this->addOption(new Option($parameter, $opt->newInstance(), $this->type($parameter)));
267
268            return;
269        }
270
271        $this->inject($parameter);
272    }
273
274    private function type(ReflectionParameter $parameter): Type
275    {
276        if ($parameter->isVariadic()) {
277            throw new ValueError(
278                "Command '{$this->full}' parameter \${$parameter->name} cannot be variadic; declare it as array",
279            );
280        }
281
282        return (
283            Type::of($parameter)
284                ?? throw new ValueError(
285                    "Command '{$this->full}' parameter \${$parameter->name} must be declared as "
286                        . 'string, int, float, bool, array, or a backed enum',
287                )
288        );
289    }
290
291    private function addArgument(Argument $argument): void
292    {
293        if ($argument->type->name === 'bool') {
294            throw new ValueError(
295                "Command '{$this->full}' argument '<{$argument->name}>' cannot be a bool; declare a flag with #[Opt]",
296            );
297        }
298
299        $this->arguments[] = $argument;
300    }
301
302    private function addOption(Option $option): void
303    {
304        $this->checkOption($option);
305
306        foreach ([$option->name, $option->opt->short, $option->negation ?? ''] as $name) {
307            if (
308                array_key_exists($name, $this->options)
309                || array_key_exists($name, $this->aliases)
310                || array_key_exists($name, $this->negations)
311            ) {
312                throw new ValueError("Command '{$this->full}' declares the option name '{$name}' twice");
313            }
314        }
315
316        $this->options[$option->name] = $option;
317
318        if ($option->opt->short !== '') {
319            $this->aliases[$option->opt->short] = $option->name;
320        }
321
322        if ($option->negation !== null) {
323            $this->negations[$option->negation] = $option->name;
324        }
325    }
326
327    private function checkOption(Option $option): void
328    {
329        $parameter = $option->parameter;
330        $prefix = "Command '{$this->full}'";
331
332        // PHP also drops the default of a parameter declared before a
333        // required one.
334        if (!$parameter->isDefaultValueAvailable()) {
335            throw new ValueError("{$prefix} option '{$option->name}' needs a default value");
336        }
337
338        $bare = $option->opt->bare;
339
340        if ($option->type->name === 'bool') {
341            if ($option->opt->value !== '' || $bare !== null) {
342                throw new ValueError("{$prefix} flag '{$option->name}' takes no value");
343            }
344        } elseif ($bare !== null) {
345            if ($option->type->name === 'array') {
346                throw new ValueError("{$prefix} repeatable option '{$option->name}' cannot have a bare value");
347            }
348
349            if ($option->type->convert($bare) === null) {
350                throw new ValueError(
351                    "{$prefix} option '{$option->name}' needs {$option->type->expected()} as bare value, got '{$bare}'",
352                );
353            }
354        }
355    }
356
357    private function inject(ReflectionParameter $parameter): void
358    {
359        $type = $parameter->getType();
360        $class = $type instanceof ReflectionNamedType && !$type->allowsNull() && !$parameter->isVariadic()
361            ? $type->getName()
362            : '';
363
364        if ($class !== Args::class && $class !== Io::class) {
365            $this->addGroup($parameter, $class);
366        } elseif (array_key_exists($class, $this->injected)) {
367            $short = $class === Args::class ? 'Args' : 'Io';
368
369            throw new ValueError("Command '{$this->full}' declares more than one {$short} parameter");
370        } else {
371            $this->injected[$class] = $parameter->name;
372        }
373    }
374
375    /**
376     * Adds the options of an option group: a class whose constructor
377     * parameters all carry `#[Opt]`.
378     */
379    private function addGroup(ReflectionParameter $parameter, string $class): void
380    {
381        $constructor = class_exists($class) ? new ReflectionClass($class)->getConstructor() : null;
382        $members = $constructor?->getParameters() ?? [];
383
384        if (!array_any(
385            $members,
386            static fn(ReflectionParameter $member): bool => $member->getAttributes(Opt::class) !== [],
387        )) {
388            throw new ValueError(
389                "Command '{$this->full}' parameter \${$parameter->name} must be declared as Args, Io, "
390                    . 'or an option group, or carry #[Arg] or #[Opt]',
391            );
392        }
393
394        foreach ($members as $member) {
395            $opt = $member->getAttributes(Opt::class)[0] ?? throw new ValueError(
396                "Command '{$this->full}' option group {$class} parameter \${$member->name} must carry #[Opt]",
397            );
398
399            $this->addOption(new Option($member, $opt->newInstance(), $this->type($member), $parameter->name));
400        }
401
402        /** @var class-string $class Checked by class_exists() */
403        $this->groups[$parameter->name] = $class;
404    }
405
406    private function checkArguments(): void
407    {
408        $last = count($this->arguments) - 1;
409
410        foreach ($this->arguments as $index => $argument) {
411            if ($argument->variadic() && $index < $last) {
412                throw new ValueError(
413                    "Command '{$this->full}' declares an argument after the variadic '<{$argument->name}>'",
414                );
415            }
416        }
417    }
418
419    /**
420     * Parses the tokens, replacing declared short names with their long
421     * names.
422     *
423     * Also counts how often each name occurs and notes which occur bare,
424     * without a value, since Args merges the occurrences: two bare
425     * `--worker` must not pass for one value, nor `--host --host=x` for a
426     * valued one. Positionals are noted too, but only option names are
427     * looked up.
428     *
429     * @param list<string> $tokens
430     * @return array{Args, array<string, int>, array<string, true>}
431     */
432    private function parse(array $tokens): array
433    {
434        $normalized = [];
435        $counts = [];
436        $bare = [];
437        $literal = false;
438
439        foreach ($tokens as $token) {
440            // Aliasing stops at the `--` separator; Args reads every
441            // later token as a positional. The token is command-line
442            // input, not a secret.
443            // @mago-expect lint:no-insecure-comparison
444            if ($literal || $token === '--') {
445                $literal = true;
446                $normalized[] = $token;
447
448                continue;
449            }
450
451            $separator = strpos(haystack: $token, needle: '=');
452            $name = $separator === false
453                ? $token
454                : substr(string: $token, offset: 0, length: $separator);
455            $long = $this->aliases[$name] ?? null;
456            $key = $long ?? $name;
457            $counts[$key] = ($counts[$key] ?? 0) + 1;
458
459            if ($separator === false) {
460                $bare[$key] = true;
461            }
462
463            $normalized[] = $long === null
464                ? $token
465                : $long . ($separator === false ? '' : substr(string: $token, offset: $separator));
466        }
467
468        return [new Args($normalized), $counts, $bare];
469    }
470
471    /**
472     * @param list<string> $positionals
473     * @return array<string, mixed>
474     */
475    private function bindArguments(array $positionals): array
476    {
477        $required = count(array_filter(
478            $this->arguments,
479            static fn(Argument $argument): bool => !$argument->optional(),
480        ));
481        $count = count($positionals);
482
483        // PHP makes every parameter before a required one required, so the
484        // required arguments always lead.
485        if ($count < $required) {
486            throw new InvalidUsage("Missing required argument '<{$this->arguments[$count]->name}>'");
487        }
488
489        $values = [];
490
491        foreach ($this->arguments as $index => $argument) {
492            if ($argument->variadic()) {
493                $rest = array_slice($positionals, $index);
494
495                if ($rest !== []) {
496                    $values[$argument->parameter->name] = $rest;
497                }
498
499                return $values;
500            }
501
502            if ($index < $count) {
503                $values[$argument->parameter->name] = $argument->convert($positionals[$index]);
504            }
505        }
506
507        $surplus = $positionals[count($this->arguments)] ?? null;
508
509        if ($surplus !== null) {
510            throw new InvalidUsage("Unexpected argument '{$surplus}'");
511        }
512
513        return $values;
514    }
515
516    private function unknownOption(string $name): string
517    {
518        $negated = str_starts_with($name, '--no-') ? '--' . substr($name, offset: 5) : '';
519
520        if (array_key_exists($negated, $this->options)) {
521            return "Option '{$negated}' cannot be negated";
522        }
523
524        $message = "Unknown option '{$name}'";
525
526        // PHP turns a numeric short name like '-1' into an integer key.
527        $shorts = array_map(strval(...), array_keys($this->aliases));
528        $candidates = [...array_keys($this->options), ...array_keys($this->negations), ...$shorts];
529
530        // The runner answers these unless the command declares `--help`.
531        if (!array_key_exists('--help', $this->options)) {
532            array_push($candidates, '--help', '-h');
533        }
534
535        $best = Suggestion::find($name, $candidates, ignore: '-');
536
537        return $best === null ? $message : "{$message}. Did you mean '{$best}'?";
538    }
539}