Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
62 / 62
97.47% covered (success)
97.47%
77 / 79
14.36% covered (danger)
14.36%
29 / 202
87.50% covered (warning)
87.50%
7 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
Help
100.00% covered (success)
100.00%
62 / 62
97.47% covered (success)
97.47%
77 / 79
14.36% covered (danger)
14.36%
29 / 202
100.00% covered (success)
100.00%
8 / 8
804.52
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
 showFor
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
13 / 13
25.00% covered (danger)
25.00%
4 / 16
100.00% covered (success)
100.00%
1 / 1
15.55
 showSummary
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
 showArguments
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
6 / 6
50.00% covered (danger)
50.00%
2 / 4
100.00% covered (success)
100.00%
1 / 1
4.12
 showOptions
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
18 / 18
26.67% covered (danger)
26.67%
4 / 15
100.00% covered (success)
100.00%
1 / 1
33.24
 argumentName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 showDescription
100.00% covered (success)
100.00%
11 / 11
90.48% covered (success)
90.48%
19 / 21
4.86% covered (danger)
4.86%
7 / 144
100.00% covered (success)
100.00%
1 / 1
49.20
 text
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
13 / 13
38.89% covered (danger)
38.89%
7 / 18
100.00% covered (success)
100.00%
1 / 1
18.18
1<?php
2
3declare(strict_types=1);
4
5namespace Celema\Console;
6
7use BackedEnum;
8use ReflectionParameter;
9
10/**
11 * Renders a command's help screen from its declaration, for the runner's
12 * `help <command>` and `--help`.
13 *
14 * @internal
15 */
16final class Help
17{
18    public function __construct(
19        private readonly Io $io,
20        private readonly string $script,
21    ) {}
22
23    /**
24     * Renders help for a command instance or class from its `#[Command]`
25     * attribute and `__invoke()` signature.
26     *
27     * @param class-string|object $command
28     */
29    public function showFor(object|string $command): void
30    {
31        $class = is_object($command) ? $command::class : $command;
32        $meta = Command::of($class);
33        $signature = Signature::of($class, $meta->full());
34        $arguments = $signature->arguments();
35        $options = $signature->options();
36
37        $usage = $meta->full();
38
39        foreach ($arguments as $argument) {
40            $name = $this->argumentName($argument);
41            $usage .= $argument->optional() ? " [{$name}]" : " {$name}";
42        }
43
44        $this->showSummary($meta->description, $usage . ($options === [] ? '' : ' [options]'));
45        $this->showArguments($arguments);
46        $this->showOptions($options);
47    }
48
49    /**
50     * Renders the description, which is markup, and the usage line: the
51     * top of a help screen, and all of it for the runner's built-ins.
52     */
53    public function showSummary(string $description, string $usage): void
54    {
55        if ($description !== '') {
56            $this->io->line('<yellow>Description:</yellow>');
57            $this->io->line('  ' . $description . "\n");
58        }
59
60        $this->io->line('<yellow>Usage:</yellow>');
61        $this->io->line('  php %s %s', $this->script, $usage);
62    }
63
64    /** @param list<Argument> $arguments */
65    private function showArguments(array $arguments): void
66    {
67        if ($arguments === []) {
68            return;
69        }
70
71        $this->io->line("\n<yellow>Arguments:</yellow>");
72
73        foreach ($arguments as $argument) {
74            $this->io->line('    <green>%s</green>', $this->argumentName($argument));
75            $this->showDescription($argument->arg->description, $argument->parameter, $argument->type);
76        }
77    }
78
79    /** @param array<string, Option> $options */
80    private function showOptions(array $options): void
81    {
82        if ($options === []) {
83            return;
84        }
85
86        $this->io->line("\n<yellow>Options:</yellow>");
87
88        foreach ($options as $option) {
89            $suffix = match (true) {
90                $option->type->name === 'bool' => '',
91                $option->opt->bare !== null => "[=<{$option->label()}>]",
92                default => "=<{$option->label()}>",
93            };
94
95            $short = $option->opt->short;
96            $long = $option->negation === null ? $option->name : '--[no-]' . substr($option->name, offset: 2);
97            $flags = $short === '' ? $long . $suffix : "{$short}{$suffix}, {$long}{$suffix}";
98
99            $this->io->line('    <green>%s</green>', $flags);
100            $this->showDescription(
101                $option->opt->description,
102                $option->parameter,
103                $option->type,
104                repeatable: $option->type->name === 'array',
105            );
106        }
107    }
108
109    private function argumentName(Argument $argument): string
110    {
111        return "<{$argument->name}>" . ($argument->variadic() ? '...' : '');
112    }
113
114    /**
115     * Renders the description, which is markup, followed by the choices,
116     * the mark of a repeatable option, and the default.
117     */
118    private function showDescription(
119        string $description,
120        ReflectionParameter $parameter,
121        Type $type,
122        bool $repeatable = false,
123    ): void {
124        $parts = $description === '' ? [] : [$description];
125        $choices = $type->choices();
126
127        if ($choices !== []) {
128            $parts[] = '[choices: ' . $this->io->escape(implode(', ', $choices)) . ']';
129        }
130
131        if ($repeatable) {
132            $parts[] = '[repeatable]';
133        }
134
135        $default = $parameter->isDefaultValueAvailable() ? $this->text($parameter->getDefaultValue()) : '';
136
137        if ($default !== '') {
138            $parts[] = '[default: ' . $this->io->escape($default) . ']';
139        }
140
141        if ($parts !== []) {
142            $this->io->line($this->io->indent(implode(' ', $parts), 8, 80));
143        }
144    }
145
146    /**
147     * Renders a default value; null, false, and arrays render empty.
148     */
149    private function text(mixed $value): string
150    {
151        if ($value instanceof BackedEnum) {
152            $value = $value->value;
153        }
154
155        return match (true) {
156            $value === true => 'true',
157            is_string($value) || is_int($value) || is_float($value) => (string) $value,
158            default => '',
159        };
160    }
161}