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}

Branches

Below are the source code lines that represent each code branch as identified by Xdebug. Please note a branch is not necessarily coterminous with a line, a line may contain multiple branches and therefore show up more than once. Please also be aware that some branches may be implicit rather than explicit, e.g. an if statement always has an else as part of its logical flow even if you didn't write one.

Signature->__construct
73        private readonly string $full,
74    ) {}
Signature->add
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) {
253        if ($arg !== null && $opt !== null) {
253        if ($arg !== null && $opt !== null) {
254            throw new ValueError(
255                "Command '{$this->full}' parameter \${$parameter->name} cannot carry both #[Arg] and #[Opt]",
259        if ($arg !== null) {
260            $this->addArgument(new Argument($parameter, $arg->newInstance(), $this->type($parameter)));
261
262            return;
265        if ($opt !== null) {
266            $this->addOption(new Option($parameter, $opt->newInstance(), $this->type($parameter)));
267
268            return;
271        $this->inject($parameter);
272    }
Signature->addArgument
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]",
299        $this->arguments[] = $argument;
300    }
Signature->addGroup
379    private function addGroup(ReflectionParameter $parameter, string $class): void
380    {
381        $constructor = class_exists($class) ? new ReflectionClass($class)->getConstructor() : null;
381        $constructor = class_exists($class) ? new ReflectionClass($class)->getConstructor() : null;
381        $constructor = class_exists($class) ? new ReflectionClass($class)->getConstructor() : null;
381        $constructor = class_exists($class) ? new ReflectionClass($class)->getConstructor() : null;
381        $constructor = class_exists($class) ? new ReflectionClass($class)->getConstructor() : null;
381        $constructor = class_exists($class) ? new ReflectionClass($class)->getConstructor() : null;
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) !== [],
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]',
394        foreach ($members as $member) {
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]",
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    }
Signature->addOption
302    private function addOption(Option $option): void
303    {
304        $this->checkOption($option);
305
306        foreach ([$option->name, $option->opt->short, $option->negation ?? ''] as $name) {
306        foreach ([$option->name, $option->opt->short, $option->negation ?? ''] as $name) {
308                array_key_exists($name, $this->options)
309                || array_key_exists($name, $this->aliases)
309                || array_key_exists($name, $this->aliases)
310                || array_key_exists($name, $this->negations)
310                || array_key_exists($name, $this->negations)
312                throw new ValueError("Command '{$this->full}' declares the option name '{$name}' twice");
306        foreach ([$option->name, $option->opt->short, $option->negation ?? ''] as $name) {
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) {
322        if ($option->negation !== null) {
323            $this->negations[$option->negation] = $option->name;
324        }
325    }
325    }
Signature->arguments
129        return $this->arguments;
130    }
Signature->asksForHelp
149    public function asksForHelp(array $tokens): bool
150    {
151        if (array_key_exists('--help', $this->options)) {
152            return false;
156        [$args] = $this->parse($tokens);
157
158        foreach (['--help', '-h'] as $name) {
158        foreach (['--help', '-h'] as $name) {
159            if (!$args->has($name)) {
160                continue;
163            if ($args->opts($name) !== []) {
164                throw new InvalidUsage("Option '{$name}' does not accept a value");
167            return true;
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    }
Signature->bind
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) {
190        foreach ($this->injected as $class => $name) {
190        foreach ($this->injected as $class => $name) {
191            $values[$name] = $class === Args::class ? $args : $io;
191            $values[$name] = $class === Args::class ? $args : $io;
191            $values[$name] = $class === Args::class ? $args : $io;
190        foreach ($this->injected as $class => $name) {
191            $values[$name] = $class === Args::class ? $args : $io;
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) {
196        foreach ($args->names() as $name) {
197            $long = $this->negations[$name] ?? $name;
198            $option = $this->options[$long] ?? throw new InvalidUsage($this->unknownOption($name));
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) {
213        foreach ($this->groups as $name => $class) {
213        foreach ($this->groups as $name => $class) {
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    }
Signature->bindArguments
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}>'");
489        $values = [];
490
491        foreach ($this->arguments as $index => $argument) {
491        foreach ($this->arguments as $index => $argument) {
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;
499                return $values;
502            if ($index < $count) {
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]);
491        foreach ($this->arguments as $index => $argument) {
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}'");
513        return $values;
514    }
Signature->checkArguments
408        $last = count($this->arguments) - 1;
409
410        foreach ($this->arguments as $index => $argument) {
410        foreach ($this->arguments as $index => $argument) {
410        foreach ($this->arguments as $index => $argument) {
411            if ($argument->variadic() && $index < $last) {
411            if ($argument->variadic() && $index < $last) {
411            if ($argument->variadic() && $index < $last) {
412                throw new ValueError(
413                    "Command '{$this->full}' declares an argument after the variadic '<{$argument->name}>'",
410        foreach ($this->arguments as $index => $argument) {
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    }
Signature->checkOption
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");
338        $bare = $option->opt->bare;
339
340        if ($option->type->name === 'bool') {
341            if ($option->opt->value !== '' || $bare !== null) {
341            if ($option->opt->value !== '' || $bare !== null) {
341            if ($option->opt->value !== '' || $bare !== null) {
342                throw new ValueError("{$prefix} flag '{$option->name}' takes no value");
340        if ($option->type->name === 'bool') {
344        } elseif ($bare !== null) {
345            if ($option->type->name === 'array') {
346                throw new ValueError("{$prefix} repeatable option '{$option->name}' cannot have a bare value");
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}'",
355    }
Signature->inject
357    private function inject(ReflectionParameter $parameter): void
358    {
359        $type = $parameter->getType();
360        $class = $type instanceof ReflectionNamedType && !$type->allowsNull() && !$parameter->isVariadic()
360        $class = $type instanceof ReflectionNamedType && !$type->allowsNull() && !$parameter->isVariadic()
360        $class = $type instanceof ReflectionNamedType && !$type->allowsNull() && !$parameter->isVariadic()
360        $class = $type instanceof ReflectionNamedType && !$type->allowsNull() && !$parameter->isVariadic()
360        $class = $type instanceof ReflectionNamedType && !$type->allowsNull() && !$parameter->isVariadic()
361            ? $type->getName()
360        $class = $type instanceof ReflectionNamedType && !$type->allowsNull() && !$parameter->isVariadic()
361            ? $type->getName()
362            : '';
360        $class = $type instanceof ReflectionNamedType && !$type->allowsNull() && !$parameter->isVariadic()
361            ? $type->getName()
362            : '';
363
364        if ($class !== Args::class && $class !== Io::class) {
364        if ($class !== Args::class && $class !== Io::class) {
364        if ($class !== Args::class && $class !== Io::class) {
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';
367            $short = $class === Args::class ? 'Args' : 'Io';
367            $short = $class === Args::class ? 'Args' : 'Io';
367            $short = $class === Args::class ? 'Args' : 'Io';
368
369            throw new ValueError("Command '{$this->full}' declares more than one {$short} parameter");
371            $this->injected[$class] = $parameter->name;
372        }
373    }
373    }
Signature->kebab
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    }
Signature->match
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) {
238        foreach ($this->parameters as $position => $name) {
238        foreach ($this->parameters as $position => $name) {
239            if (array_key_exists($name, $values)) {
238        foreach ($this->parameters as $position => $name) {
239            if (array_key_exists($name, $values)) {
240                $matched[$parameters[$position]->name] = $values[$name];
238        foreach ($this->parameters as $position => $name) {
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    }
Signature->of
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");
90        $method = $command->getMethod('__invoke');
91        $return = $method->getReturnType();
92
93        if (!$return instanceof ReflectionNamedType || $return->getName() !== 'int' || $return->allowsNull()) {
93        if (!$return instanceof ReflectionNamedType || $return->getName() !== 'int' || $return->allowsNull()) {
93        if (!$return instanceof ReflectionNamedType || $return->getName() !== 'int' || $return->allowsNull()) {
93        if (!$return instanceof ReflectionNamedType || $return->getName() !== 'int' || $return->allowsNull()) {
93        if (!$return instanceof ReflectionNamedType || $return->getName() !== 'int' || $return->allowsNull()) {
94            throw new ValueError("Command '{$full}' must declare the return type int");
97        $signature = new self($full);
98
99        foreach ($method->getParameters() as $parameter) {
99        foreach ($method->getParameters() as $parameter) {
99        foreach ($method->getParameters() as $parameter) {
100            $signature->add($parameter);
99        foreach ($method->getParameters() as $parameter) {
100            $signature->add($parameter);
101        }
102
103        $signature->checkArguments();
104
105        return $signature;
106    }
Signature->options
135        return $this->options;
136    }
Signature->parse
432    private function parse(array $tokens): array
433    {
434        $normalized = [];
435        $counts = [];
436        $bare = [];
437        $literal = false;
438
439        foreach ($tokens as $token) {
439        foreach ($tokens as $token) {
444            if ($literal || $token === '--') {
444            if ($literal || $token === '--') {
444            if ($literal || $token === '--') {
445                $literal = true;
446                $normalized[] = $token;
447
448                continue;
451            $separator = strpos(haystack: $token, needle: '=');
452            $name = $separator === false
453                ? $token
452            $name = $separator === false
453                ? $token
454                : substr(string: $token, offset: 0, length: $separator);
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
463            $normalized[] = $long === null
464                ? $token
465                : $long . ($separator === false ? '' : substr(string: $token, offset: $separator));
465                : $long . ($separator === false ? '' : substr(string: $token, offset: $separator));
465                : $long . ($separator === false ? '' : substr(string: $token, offset: $separator));
463            $normalized[] = $long === null
464                ? $token
465                : $long . ($separator === false ? '' : substr(string: $token, offset: $separator));
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
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    }
Signature->type
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",
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    }
Signature->unknownOption
516    private function unknownOption(string $name): string
517    {
518        $negated = str_starts_with($name, '--no-') ? '--' . substr($name, offset: 5) : '';
518        $negated = str_starts_with($name, '--no-') ? '--' . substr($name, offset: 5) : '';
518        $negated = str_starts_with($name, '--no-') ? '--' . substr($name, offset: 5) : '';
518        $negated = str_starts_with($name, '--no-') ? '--' . substr($name, offset: 5) : '';
518        $negated = str_starts_with($name, '--no-') ? '--' . substr($name, offset: 5) : '';
518        $negated = str_starts_with($name, '--no-') ? '--' . substr($name, offset: 5) : '';
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";
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: '-');
535        $best = Suggestion::find($name, $candidates, ignore: '-');
536
537        return $best === null ? $message : "{$message}. Did you mean '{$best}'?";
537        return $best === null ? $message : "{$message}. Did you mean '{$best}'?";
537        return $best === null ? $message : "{$message}. Did you mean '{$best}'?";
537        return $best === null ? $message : "{$message}. Did you mean '{$best}'?";
538    }
{closure:/workspace/celema/console/src/Signature.php:386-386}
386            static fn(ReflectionParameter $member): bool => $member->getAttributes(Opt::class) !== [],
{closure:/workspace/celema/console/src/Signature.php:479-479}
479            static fn(Argument $argument): bool => !$argument->optional(),