Completed
Push — master ( 178356...89490d )
by Jitendra
10s
created

Application::commandFor()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 11
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 5
nc 1
nop 1
dl 0
loc 11
rs 10
c 0
b 0
f 0
1
<?php
2
3
/*
4
 * This file is part of the PHP-CLI package.
5
 *
6
 * (c) Jitendra Adhikari <[email protected]>
7
 *     <https://github.com/adhocore>
8
 *
9
 * Licensed under MIT license.
10
 */
11
12
namespace Ahc\Cli;
13
14
use Ahc\Cli\Exception\InvalidArgumentException;
15
use Ahc\Cli\Helper\OutputHelper;
16
use Ahc\Cli\Input\Command;
17
use Ahc\Cli\IO\Interactor;
18
19
/**
20
 * A cli application.
21
 *
22
 * @author  Jitendra Adhikari <[email protected]>
23
 * @license MIT
24
 *
25
 * @link    https://github.com/adhocore/cli
26
 */
27
class Application
28
{
29
    /** @var Command[] */
30
    protected $commands = [];
31
32
    /** @var array Raw argv sent to parse() */
33
    protected $argv = [];
34
35
    /** @var array Command aliases [alias => cmd] */
36
    protected $aliases = [];
37
38
    /** @var string */
39
    protected $name;
40
41
    /** @var string App version */
42
    protected $version = '0.0.1';
43
44
    /** @var string Ascii art logo */
45
    protected $logo = '';
46
47
    protected $default = '__default__';
48
49
    /** @var Interactor */
50
    protected $io;
51
52
    public function __construct(string $name, string $version = '', callable $onExit = null)
53
    {
54
        $this->name    = $name;
55
        $this->version = $version;
56
57
        // @codeCoverageIgnoreStart
58
        $this->onExit = $onExit ?? function ($exitCode = 0) {
0 ignored issues
show
Bug Best Practice introduced by
The property onExit does not exist. Although not strictly required by PHP, it is generally a best practice to declare properties explicitly.
Loading history...
59
            exit($exitCode);
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
60
        };
61
        // @codeCoverageIgnoreEnd
62
63
        $this->command('__default__', 'Default command', '', true)->on([$this, 'showHelp'], 'help');
64
    }
65
66
    /**
67
     * Get the name.
68
     *
69
     * @return string
70
     */
71
    public function name(): string
72
    {
73
        return $this->name;
74
    }
75
76
    /**
77
     * Get the version.
78
     *
79
     * @return string
80
     */
81
    public function version(): string
82
    {
83
        return $this->version;
84
    }
85
86
    /**
87
     * Get the commands.
88
     *
89
     * @return Command[]
90
     */
91
    public function commands(): array
92
    {
93
        $commands = $this->commands;
94
95
        unset($commands['__default__']);
96
97
        return $commands;
98
    }
99
100
    /**
101
     * Get the raw argv.
102
     *
103
     * @return array
104
     */
105
    public function argv(): array
106
    {
107
        return $this->argv;
108
    }
109
110
    /**
111
     * Sets or gets the ASCII art logo.
112
     *
113
     * @param string|null $logo
114
     *
115
     * @return string|self
116
     */
117
    public function logo(string $logo = null)
118
    {
119
        if (\func_num_args() === 0) {
120
            return $this->logo;
121
        }
122
123
        $this->logo = $logo;
124
125
        return $this;
126
    }
127
128
    /**
129
     * Add a command by its name desc alias etc.
130
     *
131
     * @param string $name
132
     * @param string $desc
133
     * @param string $alias
134
     * @param bool   $allowUnknown
135
     * @param bool   $default
136
     *
137
     * @return Command
138
     */
139
    public function command(
140
        string $name,
141
        string $desc = '',
142
        string $alias = '',
143
        bool $allowUnknown = false,
144
        bool $default = false
145
    ): Command {
146
        $command = new Command($name, $desc, $allowUnknown, $this);
147
148
        $this->add($command, $alias, $default);
149
150
        return $command;
151
    }
152
153
    /**
154
     * Add a prepred command.
155
     *
156
     * @param Command $command
157
     * @param string  $alias
158
     * @param bool    $default
159
     *
160
     * @return self
161
     */
162
    public function add(Command $command, string $alias = '', bool $default = false): self
163
    {
164
        $name = $command->name();
165
166
        if ($this->commands[$name] ?? $this->aliases[$name] ?? $this->commands[$alias] ?? $this->aliases[$alias] ?? null) {
167
            throw new InvalidArgumentException(\sprintf('Command "%s" already added', $name));
168
        }
169
170
        if ($alias) {
171
            $command->alias($alias);
172
            $this->aliases[$alias] = $name;
173
        }
174
175
        if ($default) {
176
            $this->default = $name;
177
        }
178
179
        $this->commands[$name] = $command->version($this->version)->onExit($this->onExit)->bind($this);
180
181
        return $this;
182
    }
183
184
    /**
185
     * Gets matching command for given argv.
186
     *
187
     * @param array $argv
188
     *
189
     * @return Command
190
     */
191
    public function commandFor(array $argv): Command
192
    {
193
        $argv += [null, null, null];
194
195
        return
196
             // cmd
197
            $this->commands[$argv[1]]
198
            // cmd alias
199
            ?? $this->commands[$this->aliases[$argv[1]] ?? null]
200
            // default.
201
            ?? $this->commands[$this->default];
202
    }
203
204
    /**
205
     * Gets or sets io.
206
     *
207
     * @param Interactor|null $io
208
     *
209
     * @return Interactor|self
210
     */
211
    public function io(Interactor $io = null)
212
    {
213
        if ($io || !$this->io) {
214
            $this->io = $io ?? new Interactor;
215
        }
216
217
        if (\func_num_args() === 0) {
218
            return $this->io;
219
        }
220
221
        return $this;
222
    }
223
224
    /**
225
     * Parse the arguments via the matching command but dont execute action..
226
     *
227
     * @param array $argv Cli arguments/options.
228
     *
229
     * @return Command The matched and parsed command (or default)
230
     */
231
    public function parse(array $argv): Command
232
    {
233
        $this->argv = $argv;
234
235
        $command = $this->commandFor($argv);
236
        $aliases = $this->aliasesFor($command);
237
238
        // Eat the cmd name!
239
        foreach ($argv as $i => $arg) {
240
            if (\in_array($arg, $aliases)) {
241
                unset($argv[$i]);
242
243
                break;
244
            }
245
246
            if ($arg[0] === '-') {
247
                break;
248
            }
249
        }
250
251
        return $command->parse($argv);
252
    }
253
254
    /**
255
     * Handle the request, invoke action and call exit handler.
256
     *
257
     * @param array $argv
258
     *
259
     * @return mixed
260
     */
261
    public function handle(array $argv)
262
    {
263
        if (\count($argv) < 2) {
264
            return $this->showHelp();
265
        }
266
267
        $exitCode = 255;
268
269
        try {
270
            $command = $this->parse($argv);
271
            $this->doAction($command);
272
            $exitCode = 0;
273
        } catch (\Throwable $e) {
274
            $this->outputHelper()->printTrace($e);
275
        }
276
277
        return ($this->onExit)($exitCode);
278
    }
279
280
    /**
281
     * Get aliases for given command.
282
     *
283
     * @param Command $command
284
     *
285
     * @return array
286
     */
287
    protected function aliasesFor(Command $command): array
288
    {
289
        $aliases = [$name = $command->name()];
290
291
        foreach ($this->aliases as $alias => $command) {
292
            if (\in_array($name, [$alias, $command])) {
293
                $aliases[] = $alias;
294
                $aliases[] = $command;
295
            }
296
        }
297
298
        return $aliases;
299
    }
300
301
    /**
302
     * Show help of all commands.
303
     *
304
     * @return mixed
305
     */
306
    public function showHelp()
307
    {
308
        $writer = $this->io()->writer();
309
        $header = "{$this->name}, version {$this->version}";
310
        $footer = 'Run `<command> --help` for specific help';
311
312
        if ($this->logo) {
313
            $writer->write($this->logo, true);
314
        }
315
316
        $this->outputHelper()->showCommandsHelp($this->commands(), $header, $footer);
317
318
        return ($this->onExit)();
319
    }
320
321
    protected function outputHelper(): OutputHelper
322
    {
323
        $writer = $this->io()->writer();
324
325
        return new OutputHelper($writer);
326
    }
327
328
    /**
329
     * Invoke command action.
330
     *
331
     * @param Command $command
332
     *
333
     * @return mixed
334
     */
335
    protected function doAction(Command $command)
336
    {
337
        if ($command->name() === '__default__') {
338
            return $this->notFound();
339
        }
340
341
        // Let the command collect more data (if missing or needs confirmation)
342
        $command->interact($this->io());
343
344
        if (!$command->action() && !\method_exists($command, 'execute')) {
345
            return;
346
        }
347
348
        $params = [];
349
        $values = $command->values();
350
        // We prioritize action to be in line with commander.js!
351
        $action = $command->action() ?? [$command, 'execute'];
352
353
        foreach ($this->getActionParameters($action) as $param) {
354
            $params[] = $values[$param->getName()] ?? null;
355
        }
356
357
        return $action(...$params);
358
    }
359
360
    /**
361
     * Command not found handler.
362
     *
363
     * @return mixed
364
     */
365
    protected function notFound()
366
    {
367
        $closest   = [];
368
        $attempted = $this->argv[1];
369
        $available = \array_keys($this->commands() + $this->aliases);
370
371
        foreach ($available as $cmd) {
372
            $lev = \levenshtein($attempted, $cmd);
373
            if ($lev > 0 || $lev < 5) {
374
                $closest[$cmd] = $lev;
375
            }
376
        }
377
378
        $this->io()->error("Command $attempted not found", true);
379
        if ($closest) {
380
            \asort($closest);
381
            $closest = \key($closest);
382
            $this->io()->bgRed("Did you mean $closest?", true);
383
        }
384
385
        return ($this->onExit)(127);
386
    }
387
388
    protected function getActionParameters(callable $action): array
389
    {
390
        $reflex = \is_array($action)
391
            ? (new \ReflectionClass($action[0]))->getMethod($action[1])
392
            : new \ReflectionFunction($action);
393
394
        return $reflex->getParameters();
395
    }
396
}
397