Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
103 / 103
96.34% covered (success)
96.34%
79 / 82
22.92% covered (danger)
22.92%
22 / 96
80.00% covered (warning)
80.00%
8 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Runner
100.00% covered (success)
100.00%
103 / 103
96.34% covered (success)
96.34%
79 / 82
22.92% covered (danger)
22.92%
22 / 96
100.00% covered (success)
100.00%
10 / 10
735.64
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
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
 orderCommands
100.00% covered (success)
100.00%
21 / 21
88.89% covered (warning)
88.89%
16 / 18
0.00% covered (danger)
0.00%
0 / 36
100.00% covered (success)
100.00%
1 / 1
7
 showHelp
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
7 / 7
20.00% covered (danger)
20.00%
1 / 5
100.00% covered (success)
100.00%
1 / 1
7.61
 showCommands
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
14 / 14
0.00% covered (danger)
0.00%
0 / 24
100.00% covered (success)
100.00%
1 / 1
6
 run
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
17 / 17
90.00% covered (success)
90.00%
9 / 10
100.00% covered (success)
100.00%
1 / 1
9.08
 echoGroup
100.00% covered (success)
100.00%
2 / 2
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
 echoCommand
100.00% covered (success)
100.00%
4 / 4
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
 showAmbiguousMessage
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
4 / 4
33.33% covered (danger)
33.33%
1 / 3
100.00% covered (success)
100.00%
1 / 1
3.19
 getCommand
100.00% covered (success)
100.00%
9 / 9
92.31% covered (success)
92.31%
12 / 13
41.67% covered (danger)
41.67%
5 / 12
100.00% covered (success)
100.00%
1 / 1
13.15
 runCommand
100.00% covered (success)
100.00%
4 / 4
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
1<?php
2
3declare(strict_types=1);
4
5namespace Celemas\Cli;
6
7use Throwable;
8use ValueError;
9
10/**
11 * @api
12 */
13final class Runner
14{
15    protected const AMBIGUOUS = 1;
16    protected const NOTFOUND = 2;
17
18    // The commands ordered by group and name
19    private array $toc = [];
20
21    // The commands indexed by name only
22    private array $list = [];
23    private Output $output;
24    private int $longestName = 0;
25
26    public function __construct(
27        Commands $commands,
28        string $output = 'php://output',
29        private bool $debug = false,
30    ) {
31        $this->output = new Output($output);
32        $this->orderCommands($commands);
33    }
34
35    public function orderCommands(Commands $commands): void
36    {
37        $groups = [];
38
39        foreach ($commands->get() as $command) {
40            $name = strtolower($command->name());
41            $prefix = $command->prefix();
42
43            if (!array_key_exists($prefix, $groups)) {
44                $group = $command->group();
45                $group = $group === '' ? 'General' : $group;
46                $groups[$prefix] = [
47                    'title' => $prefix === '' ? 'General' : $group,
48                    'commands' => [],
49                ];
50            }
51
52            $groups[$prefix]['commands'][$name] = $command;
53
54            $this->list[$name][] = $command;
55
56            $len = strlen($prefix . ':' . $command->name());
57            $this->longestName = $len > $this->longestName ? $len : $this->longestName;
58        }
59
60        ksort($groups);
61
62        foreach ($groups as $name => $group) {
63            $commands = $group['commands'];
64            ksort($commands);
65            $group['commands'] = $commands;
66            $this->toc[$name] = $group;
67        }
68    }
69
70    public function showHelp(): int
71    {
72        $script = $_SERVER['argv'][0] ?? '';
73        $this->output->echo($this->output->color('Usage:', 'brown') . "\n");
74        $this->output->echo("  php {$script} [prefix:]command [arguments]\n\n");
75        $this->output->echo("Prefixes are optional if the command is unambiguous.\n\n");
76        $this->output->echo("Available commands:\n");
77        $this->echoGroup('General');
78        $this->echoCommand('', 'commands', 'Lists all available commands');
79        $this->echoCommand('', 'help', 'Displays this overview');
80
81        foreach ($this->toc as $group) {
82            $this->echoGroup($group['title']);
83
84            foreach ($group['commands'] as $name => $command) {
85                $this->echoCommand($command->prefix(), $name, $command->description());
86            }
87        }
88
89        return 0;
90    }
91
92    /**
93     * Displays a list of all available commands.
94     *
95     * With and without namespace/group. If a command appears in more than
96     * one namespace, e. g. foo:cmd and bar:cmd, only the namespaced ones
97     * will be displayed.
98     */
99    public function showCommands(): int
100    {
101        $list = [];
102
103        foreach ($this->toc as $group) {
104            foreach ($group['commands'] as $command) {
105                $prefix = $command->prefix();
106
107                if ($prefix) {
108                    $key = "{$prefix}:" . $command->name();
109                    $list[$key] = ($list[$key] ?? 0) + 1;
110                }
111
112                $name = $command->name();
113                $list[$name] = ($list[$name] ?? 0) + 1;
114            }
115        }
116
117        ksort($list);
118
119        foreach ($list as $name => $count) {
120            if ($count === 1) {
121                $this->output->echo("{$name}\n");
122            }
123        }
124
125        return 0;
126    }
127
128    public function run(): int|string
129    {
130        try {
131            $argv = $_SERVER['argv'] ?? [];
132
133            $arg = $argv[1] ?? null;
134
135            if ($arg !== null) {
136                $cmd = strtolower($arg);
137                $isHelpCall = false;
138
139                if ($cmd === 'help') {
140                    $isHelpCall = true;
141
142                    $arg = $argv[2] ?? null;
143
144                    if ($arg === null) {
145                        return $this->showHelp();
146                    }
147
148                    $cmd = strtolower($arg);
149                }
150
151                if ($cmd === 'commands') {
152                    return $this->showCommands();
153                }
154
155                try {
156                    return $this->runCommand($this->getCommand($cmd), $isHelpCall);
157                } catch (ValueError $e) {
158                    if ($e->getCode() === self::AMBIGUOUS) {
159                        return $this->showAmbiguousMessage($cmd);
160                    }
161
162                    throw $e;
163                }
164            }
165
166            return $this->showHelp();
167        } catch (Throwable $e) {
168            $this->output->echo("Error while running command '");
169            $this->output->echo($_SERVER['argv'][1] ?? '<no command given>');
170            $this->output->echo("':\n\n" . $e->getMessage() . "\n");
171
172            if ($this->debug) {
173                $this->output->echoln("\nTraceback:", 'yellow');
174                $this->output->echoln($e->getTraceAsString());
175            }
176
177            return 1;
178        }
179    }
180
181    private function echoGroup(string $title): void
182    {
183        $g = $this->output->color($title, 'brown');
184        $this->output->echo("\n{$g}\n");
185    }
186
187    private function echoCommand(string $prefix, string $name, string $desc): void
188    {
189        $prefix = $prefix ? $prefix . ':' : '';
190        $name = $this->output->color($name, 'green');
191
192        // The added magic number takes colorization into
193        // account as it lengthens the string.
194        $prefixedName = str_pad($prefix . $name, $this->longestName + 13);
195        $this->output->echoln("  {$prefixedName}{$desc}");
196    }
197
198    private function showAmbiguousMessage(string $cmd): int
199    {
200        $this->output->echo("Ambiguous command. Please add the group name:\n\n");
201        asort($this->list[$cmd]);
202
203        foreach ($this->list[$cmd] as $command) {
204            $prefix = $this->output->color($command->prefix(), 'brown');
205            $name = strtolower($command->name());
206            $this->output->echoln("  {$prefix}:{$name}");
207        }
208
209        return 1;
210    }
211
212    private function getCommand(string $cmd): Command
213    {
214        if (array_key_exists($cmd, $this->list)) {
215            if (count($this->list[$cmd]) === 1) {
216                return $this->list[$cmd][0];
217            }
218
219            throw new ValueError('Ambiguous command', self::AMBIGUOUS);
220        }
221
222        if (str_contains($cmd, ':')) {
223            [$group, $name] = explode(':', $cmd);
224
225            if (
226                array_key_exists($group, $this->toc) && array_key_exists($name, $this->toc[$group]['commands'])
227            ) {
228                return $this->toc[$group]['commands'][$name];
229            }
230        }
231
232        throw new ValueError('Command not found', self::NOTFOUND);
233    }
234
235    private function runCommand(Command $command, bool $isHelpCall): int|string
236    {
237        if ($isHelpCall) {
238            $command->output($this->output)->help();
239
240            return 0;
241        }
242
243        return $command->output($this->output)->run();
244    }
245}