Completed
Push — feature/logger ( 1c2c4d...c4d378 )
by Todd
03:49 queued 02:00
created

Cli::parseShortParam()   C

Complexity

Conditions 7
Paths 18

Size

Total Lines 41
Code Lines 25

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 9
CRAP Score 7

Importance

Changes 1
Bugs 0 Features 1
Metric Value
c 1
b 0
f 1
dl 0
loc 41
ccs 9
cts 9
cp 1
rs 6.7273
cc 7
eloc 25
nc 18
nop 2
crap 7
1
<?php
2
3
namespace Garden\Cli;
4
5
/**
6
 * A general purpose command line parser.
7
 *
8
 * @author Todd Burry <[email protected]>
9
 * @license MIT
10
 * @copyright 2010-2014 Vanilla Forums Inc.
11
 */
12
class Cli {
13
    /// Constants ///
14
15
    const META = '__meta';
16
    const ARGS = '__args';
17
18
    /// Properties ///
19
    /**
20
     * @var array All of the schemas, indexed by command pattern.
21
     */
22
    protected $commandSchemas;
23
24
    /**
25
     * @var array A pointer to the current schema.
26
     */
27
    protected $currentSchema;
28
29
    /**
30
     * @var bool Whether or not to format output with console codes.
31
     */
32
    public $format = true;
33
34
    protected static $types = [
35
//        '=' => 'base64',
0 ignored issues
show
Unused Code Comprehensibility introduced by
58% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
36
        'i' => 'integer',
37
        's' => 'string',
38
//        'f' => 'float',
0 ignored issues
show
Unused Code Comprehensibility introduced by
58% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
39
        'b' => 'boolean',
40
//        'ts' => 'timestamp',
0 ignored issues
show
Unused Code Comprehensibility introduced by
50% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
41
//        'dt' => 'datetime'
42
    ];
43
44
45
    /// Methods ///
46
47
    /**
48
     * Creates a {@link Cli} instance representing a command line parser for a given schema.
49
     */
50 32
    public function __construct() {
51 32
        $this->commandSchemas = ['*' => [Cli::META => []]];
52
53
        // Select the current schema.
54 32
        $this->currentSchema =& $this->commandSchemas['*'];
55 32
    }
56
57
    /**
58
     * Add an argument to an {@link Args} object, checking for a correct name.
59
     *
60
     * @param array $schema The schema for the args.
61
     * @param Args $args The args object to add the argument to.
62
     * @param $arg The value of the argument.
63
     */
64 1
    private function addArg(array $schema, Args $args, $arg) {
65
        $argsCount = count($args->getArgs());
66 1
        $schemaArgs = isset($schema[self::META][self::ARGS]) ? array_keys($schema[self::META][self::ARGS]) : [];
67 1
        $name = isset($schemaArgs[$argsCount]) ? $schemaArgs[$argsCount] : $argsCount;
68
69
        $args->addArg($arg, $name);
70
    }
71
72
    /**
73
     * Construct and return a new {@link Cli} object.
74
     *
75
     * This method is mainly here so that an entire cli schema can be created and defined with fluent method calls.
76
     *
77
     * @return Cli Returns a new Cli object.
78
     */
79
    public static function create() {
80
        return new Cli();
81
    }
82
83
    /**
84
     * Breaks a cell into several lines according to a given width.
85
     *
86
     * @param string $text The text of the cell.
87
     * @param int $width The width of the cell.
88
     * @param bool $addSpaces Whether or not to right-pad the cell with spaces.
89
     * @return array Returns an array of strings representing the lines in the cell.
90
     */
91
    public static function breakLines($text, $width, $addSpaces = true) {
92
        $rawLines = explode("\n", $text);
93
        $lines = [];
94
95
        foreach ($rawLines as $line) {
96
            // Check to see if the line needs to be broken.
97
            $sublines = static::breakString($line, $width, $addSpaces);
98
            $lines = array_merge($lines, $sublines);
99
        }
100
101
        return $lines;
102
    }
103
104
    /**
105
     * Breaks a line of text according to a given width.
106
     *
107
     * @param string $line The text of the line.
108
     * @param int $width The width of the cell.
109
     * @param bool $addSpaces Whether or not to right pad the lines with spaces.
110
     * @return array Returns an array of lines, broken on word boundaries.
111
     */
112
    protected static function breakString($line, $width, $addSpaces = true) {
113
        $words = explode(' ', $line);
114
        $result = [];
115
116
        $line = '';
117
        foreach ($words as $word) {
118
            $candidate = trim($line.' '.$word);
119
120
            // Check for a new line.
121
            if (strlen($candidate) > $width) {
122
                if ($line === '') {
123
                    // The word is longer than a line.
124
                    if ($addSpaces) {
125
                        $result[] = substr($candidate, 0, $width);
126
                    } else {
127
                        $result[] = $candidate;
128
                    }
129
                } else {
130
                    if ($addSpaces) {
131
                        $line .= str_repeat(' ', $width - strlen($line));
132
                    }
133
134
                    // Start a new line.
135
                    $result[] = $line;
136
                    $line = $word;
137
                }
138
            } else {
139
                $line = $candidate;
140
            }
141
        }
142
143
        // Add the remaining line.
144
        if ($line) {
145
            if ($addSpaces) {
146
                $line .= str_repeat(' ', $width - strlen($line));
147
            }
148
149
            // Start a new line.
150
            $result[] = $line;
151
        }
152
153
        return $result;
154
    }
155
156
    /**
157
     * Sets the description for the current command.
158
     *
159
     * @param string $str The description for the current schema or null to get the current description.
160
     * @return Cli Returns this class for fluent calls.
161
     */
162
    public function description($str = null) {
163
        return $this->meta('description', $str);
164
    }
165
166
    /**
167
     * Determines whether or not the schema has a command.
168
     *
169
     * @param string $name Check for the specific command name.
170
     * @return bool Returns true if the schema has a command.
171
     */
172
    public function hasCommand($name = '') {
173
        if ($name) {
174
            return array_key_exists($name, $this->commandSchemas);
175
        } else {
176
            foreach ($this->commandSchemas as $pattern => $opts) {
177
                if (strpos($pattern, '*') === false) {
178
                    return true;
179
                }
180
            }
181
            return false;
182
        }
183
    }
184
185
    /**
186
     * Determines whether a command has options.
187
     *
188
     * @param string $command The name of the command or an empty string for any command.
189
     * @return bool Returns true if the command has options. False otherwise.
190
     */
191 6
    public function hasOptions($command = '') {
192 6
        if ($command) {
193
            $def = $this->getSchema($command);
194
            return $this->hasOptionsDef($def);
195
        } else {
196 4
            foreach ($this->commandSchemas as $pattern => $def) {
197
                if ($this->hasOptionsDef($def)) {
198 4
                    return true;
199
                }
200 3
            }
201
        }
202
        return false;
203
    }
204
205
    /**
206
     * Determines whether or not a command definition has options.
207
     *
208
     * @param array $commandDef The command definition as returned from {@link Cli::getSchema()}.
209
     * @return bool Returns true if the command def has options or false otherwise.
210
     */
211
    protected function hasOptionsDef($commandDef) {
212
        return count($commandDef) > 1 || (count($commandDef) > 0 && !isset($commandDef[Cli::META]));
213
    }
214
215
    /**
216
     * Determines whether or not a command has args.
217
     *
218
     * @param string $command The command name to check.
219
     * @return int Returns one of the following.
220
     * - 0: The command has no args.
221
     * - 1: The command has optional args.
222
     * - 2: The command has required args.
223
     */
224 6
    public function hasArgs($command = '') {
225 6
        $args = null;
226
227 6
        if ($command) {
228
            // Check to see if the specific command has args.
229
            $def = $this->getSchema($command);
230 2
            if (isset($def[Cli::META][Cli::ARGS])) {
231 2
                $args = $def[Cli::META][Cli::ARGS];
232
            }
233
        } else {
234 4
            foreach ($this->commandSchemas as $pattern => $def) {
235 2
                if (isset($def[Cli::META][Cli::ARGS])) {
236 2
                    $args = $def[Cli::META][Cli::ARGS];
237
                }
238
            }
239 4
            if (!empty($args)) {
240 2
                return 1;
241
            }
242 2
        }
243
244 4
        if (!$args || empty($args)) {
245 2
            return 0;
246
        }
247
248
        foreach ($args as $arg) {
249
            if (!Cli::val('required', $arg)) {
250 1
                return 1;
251
            }
252 1
        }
253 1
        return 2;
254
    }
255
256
    /**
257
     * Finds our whether a pattern is a command.
258
     *
259
     * @param string $pattern The pattern being evaluated.
260
     * @return bool Returns `true` if `$pattern` is a command, `false` otherwise.
261
     */
262
    public static function isCommand($pattern) {
263
        return strpos($pattern, '*') === false;
264
    }
265
266
    /**
267
     * Parses and validates a set of command line arguments the schema.
268
     *
269
     * @param array $argv The command line arguments a form compatible with the global `$argv` variable.
270
     *
271
     * Note that the `$argv` array must have at least one element and it must represent the path to the command that
272
     * invoked the command. This is used to write usage information.
273
     * @param bool $exit Whether to exit the application when there is an error or when writing help.
274
     * @return Args|null Returns an {@see Args} instance when a command should be executed
275
     * or `null` when one should not be executed.
276
     * @throws \Exception Throws an exception when {@link $exit} is false and the help or errors need to be displayed.
277
     */
278 32
    public function parse($argv = null, $exit = true) {
279
        // Only format commands if we are exiting.
280 32
        $this->format = $exit;
281 32
        if (!$exit) {
282
            ob_start();
283
        }
284
285
        $args = $this->parseRaw($argv);
286
287
        $hasCommand = $this->hasCommand();
288
289
290 28
        if ($hasCommand && !$args->getCommand()) {
291
            // If no command is given then write a list of commands.
292
            $this->writeUsage($args);
293
            $this->writeCommands();
294 2
            $result = null;
295
        } elseif ($args->getOpt('help') || $args->getOpt('?')) {
296
            // Write the help.
297
            $this->writeUsage($args);
298
            $this->writeHelp($args->getCommand());
299 4
            $result = null;
300
        } else {
301
            // Validate the arguments against the schema.
302
            $validArgs = $this->validate($args);
303 26
            $result = $validArgs;
304 6
        }
305 32
        if (!$exit) {
306
            $output = ob_get_clean();
307 31
            if ($result === null) {
308
                throw new \Exception(trim($output));
309
            }
310 1
        } elseif ($result === null) {
311
            exit();
1 ignored issue
show
Coding Style Compatibility introduced by
The method parse() contains an exit expression.

An exit expression should only be used in rare cases. For example, if you write a short command line script.

In most cases however, using an exit expression makes the code untestable and often causes incompatibilities with other libraries. Thus, unless you are absolutely sure it is required here, we recommend to refactor your code to avoid its usage.

Loading history...
312 17
        }
313 18
        return $result;
314 1
    }
315
316
    /**
317
     * Parse an array of arguments.
318
     *
319
     * If the first item in the array is in the form of a command (no preceding - or --),
320
     * 'command' is filled with its value.
321
     *
322
     * @param array $argv An array of arguments passed in a form compatible with the global `$argv` variable.
323
     * @return Args Returns the raw parsed arguments.
324
     * @throws \Exception Throws an exception when {@see $argv} isn't an array.
325
     */
326 32
    protected function parseRaw($argv = null) {
1 ignored issue
show
Coding Style introduced by
parseRaw uses the super-global variable $GLOBALS which is generally not recommended.

Instead of super-globals, we recommend to explicitly inject the dependencies of your class. This makes your code less dependent on global state and it becomes generally more testable:

// Bad
class Router
{
    public function generate($path)
    {
        return $_SERVER['HOST'].$path;
    }
}

// Better
class Router
{
    private $host;

    public function __construct($host)
    {
        $this->host = $host;
    }

    public function generate($path)
    {
        return $this->host.$path;
    }
}

class Controller
{
    public function myAction(Request $request)
    {
        // Instead of
        $page = isset($_GET['page']) ? intval($_GET['page']) : 1;

        // Better (assuming you use the Symfony2 request)
        $page = $request->query->get('page', 1);
    }
}
Loading history...
327 32
        if ($argv === null) {
328
            $argv = $GLOBALS['argv'];
329
        }
330
331
        if (!is_array($argv)) {
332
            throw new \Exception(__METHOD__ . " expects an array", 400);
333
        }
334
335
        $path = array_shift($argv);
336
        $hasCommand = $this->hasCommand();
337
338
        $parsed = new Args();
339
        $parsed->setMeta('path', $path);
340
        $parsed->setMeta('filename', basename($path));
341
342
        if (count($argv)) {
343
            // Get possible command.
344
            if (substr($argv[0], 0, 1) != '-') {
345
                $arg0 = array_shift($argv);
346 4
                if ($hasCommand) {
347
                    $parsed->setCommand($arg0);
348
                } else {
349
                    $schema = $this->getSchema($parsed->getCommand());
350
                    $this->addArg($schema, $parsed, $arg0);
351 2
                }
352
            }
353
            // Get the data types for all of the commands.
354 32
            if (!isset($schema)) {
355
                $schema = $this->getSchema($parsed->getCommand());
356
            }
357 32
            $types = [];
358
            foreach ($schema as $sName => $sRow) {
359 2
                if ($sName === Cli::META) {
360 32
                    continue;
361
                }
362
363
                $type = Cli::val('type', $sRow, 'string');
364 2
                $types[$sName] = $type;
365 2
                if (isset($sRow['short'])) {
366 3
                    $types[$sRow['short']] = $type;
367
                }
368
            }
369
370
            // Parse opts.
371
            for ($i = 0; $i < count($argv); $i++) {
0 ignored issues
show
Performance Best Practice introduced by
It seems like you are calling the size function count() as part of the test condition. You might want to compute the size beforehand, and not on each iteration.

If the size of the collection does not change during the iteration, it is generally a good practice to compute it beforehand, and not on each iteration:

for ($i=0; $i<count($array); $i++) { // calls count() on each iteration
}

// Better
for ($i=0, $c=count($array); $i<$c; $i++) { // calls count() just once
}
Loading history...
372 17
                $str = $argv[$i];
373
374 17
                if ($str === '--') {
375
                    // --
376
                    $i++;
377
                    break;
378
                } elseif (strlen($str) > 2 && substr($str, 0, 2) == '--') {
379
                    // --foo
380
                    $str = substr($str, 2);
381
                    $parts = explode('=', $str);
382 14
                    $key = $parts[0];
383
384
                    // Does not have an =, so choose the next arg as its value,
385
                    // unless it is defined as 'boolean' in which case there is no
386
                    // value to seek in next arg
387
                    if (count($parts) == 1 && isset($argv[$i + 1]) && preg_match('/^--?.+/', $argv[$i + 1]) == 0) {
388 5
                        $v = $argv[$i + 1];
389
                        if (Cli::val($key, $types) == 'boolean') {
390
                            if (in_array($v, ['0', '1', 'true', 'false', 'on', 'off', 'yes', 'no'])) {
391
                                // The next arg looks like a boolean to me.
392 1
                                $i++;
393
                            } else {
394
                                // Next arg is not a boolean: set the flag on, and use next arg in its own iteration
395
                                $v = true;
396 1
                            }
397
                        } else {
398 4
                            $i++;
399 1
                        }
400
                    } elseif (count($parts) == 2) {// Has a =, so pick the second piece
401 6
                        $v = $parts[1];
402
                    } else {
403 8
                        $v = true;
404 10
                    }
405
                    $parsed->setOpt($key, $v);
406
                } elseif (strlen($str) == 2 && $str[0] == '-') {
407
                    // -a
408
409 7
                    $key = $str[1];
410
                    $type = Cli::val($key, $types, 'boolean');
411 7
                    $v = null;
412
413 7
                    if (isset($argv[$i + 1])) {
414
                        // Try and be smart about the next arg.
415 4
                        $nextArg = $argv[$i + 1];
416
417 4
                        if ($type === 'boolean') {
418
                            if ($this->isStrictBoolean($nextArg)) {
419
                                // The next arg looks like a boolean to me.
420 1
                                $v = $nextArg;
421 1
                                $i++;
422
                            } else {
423 2
                                $v = true;
424 1
                            }
425
                        } elseif (!preg_match('/^--?.+/', $argv[$i + 1])) {
426
                            // The next arg is not an opt.
427 3
                            $v = $nextArg;
428 3
                            $i++;
429
                        } else {
430
                            // The next arg is another opt.
431
                            $v = null;
432 4
                        }
433
                    }
434
435 7
                    if ($v === null) {
436
                        $v = Cli::val($type, ['boolean' => true, 'integer' => 1, 'string' => '']);
437
                    }
438
439
                    $parsed->setOpt($key, $v);
440
                } elseif (strlen($str) > 1 && $str[0] == '-') {
441
                    // -abcdef
442
                    for ($j = 1; $j < strlen($str); $j++) {
443 10
                        $opt = $str[$j];
444
                        $remaining = substr($str, $j + 1);
445
                        $type = Cli::val($opt, $types, 'boolean');
446
447
                        // Check for an explicit equals sign.
448
                        if (substr($remaining, 0, 1) === '=') {
449
                            $remaining = substr($remaining, 1);
450 2
                            if ($type === 'boolean') {
451
                                // Bypass the boolean flag checking below.
452
                                $parsed->setOpt($opt, $remaining);
453
                                break;
454
                            }
455
                        }
456
457 10
                        if ($type === 'boolean') {
458
                            if (preg_match('`^([01])`', $remaining, $matches)) {
459
                                // Treat the 0 or 1 as a true or false.
460
                                $parsed->setOpt($opt, $matches[1]);
461
                                $j += strlen($matches[1]);
462
                            } else {
463
                                // Treat the option as a flag.
464
                                $parsed->setOpt($opt, true);
465 1
                            }
466 12
                        } elseif ($type === 'string') {
467
                            // Treat the option as a set with no = sign.
468
                            $parsed->setOpt($opt, $remaining);
469 12
                            break;
470 1
                        } elseif ($type === 'integer') {
471
                            if (preg_match('`^(\d+)`', $remaining, $matches)) {
472
                                // Treat the option as a set with no = sign.
473
                                $parsed->setOpt($opt, $matches[1]);
474
                                $j += strlen($matches[1]);
475
                            } else {
476
                                // Treat the option as either multiple flags.
477
                                $optVal = $parsed->getOpt($opt, 0);
478
                                $parsed->setOpt($opt, $optVal + 1);
479 2
                            }
480
                        } else {
481
                            // This should not happen unless we've put a bug in our code.
482
                            throw new \Exception("Invalid type $type for $opt.", 500);
483 1
                        }
484 9
                    }
485
                } else {
486
                    // End of opts
487 1
                    break;
488 16
                }
489 1
            }
490
491
            // Grab the remaining args.
492
            for (; $i < count($argv); $i++) {
0 ignored issues
show
Performance Best Practice introduced by
It seems like you are calling the size function count() as part of the test condition. You might want to compute the size beforehand, and not on each iteration.

If the size of the collection does not change during the iteration, it is generally a good practice to compute it beforehand, and not on each iteration:

for ($i=0; $i<count($array); $i++) { // calls count() on each iteration
}

// Better
for ($i=0, $c=count($array); $i<$c; $i++) { // calls count() just once
}
Loading history...
493
                $this->addArg($schema, $parsed, $argv[$i]);
494 1
            }
495
        }
496
497 32
        return $parsed;
498
    }
499
500
    /**
501
     * Validates arguments against the schema.
502
     *
503
     * @param Args $args The arguments that were returned from {@link Cli::parseRaw()}.
504
     * @return Args|null
505
     */
506 26
    public function validate(Args $args) {
507 26
        $isValid = true;
508
        $command = $args->getCommand();
509
        $valid = new Args($command);
510
        $schema = $this->getSchema($command);
511
        ksort($schema);
512
513
//        $meta = $schema[Cli::META];
0 ignored issues
show
Unused Code Comprehensibility introduced by
50% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
514 26
        unset($schema[Cli::META]);
515
        $opts = $args->getOpts();
516 26
        $missing = [];
517
518
        // Check to see if the command is correct.
519 26
        if ($command && !$this->hasCommand($command) && $this->hasCommand()) {
520
            echo $this->red("Invalid command: $command.".PHP_EOL);
521
            $isValid = false;
522
        }
523
524
        // Add the args.
525
        $valid->setArgs($args->getArgs());
526
527
        foreach ($schema as $key => $definition) {
528
            // No Parameter (default)
0 ignored issues
show
Unused Code Comprehensibility introduced by
38% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
529
            $type = Cli::val('type', $definition, 'string');
530
531
            if (isset($opts[$key])) {
532
                // Check for --key.
533 8
                $value = $opts[$key];
534
                if ($this->validateType($value, $type, $key, $definition)) {
535
                    $valid->setOpt($key, $value);
536
                } else {
537 3
                    $isValid = false;
538 5
                }
539 8
                unset($opts[$key]);
540
            } elseif (isset($definition['short']) && isset($opts[$definition['short']])) {
541
                // Check for -s.
542 10
                $value = $opts[$definition['short']];
543
                if ($this->validateType($value, $type, $key, $definition)) {
544
                    $valid->setOpt($key, $value);
545
                } else {
546 2
                    $isValid = false;
547 10
                }
548 10
                unset($opts[$definition['short']]);
549 3
            } elseif (isset($opts['no-'.$key])) {
550
                // Check for --no-key.
551 3
                $value = $opts['no-'.$key];
552
553 3
                if ($type !== 'boolean') {
554
                    echo $this->red("Cannot apply the --no- prefix on the non boolean --$key.".PHP_EOL);
555 1
                    $isValid = false;
556
                } elseif ($this->validateType($value, $type, $key, $definition)) {
557
                    $valid->setOpt($key, !$value);
558
                } else {
559 1
                    $isValid = false;
560 2
                }
561 3
                unset($opts['no-'.$key]);
562 3
            } elseif ($definition['required']) {
563
                // The key was not supplied. Is it required?
564 1
                $missing[$key] = true;
565
                $valid->setOpt($key, false);
566 7
            }
567 1
        }
568
569
        if (count($missing)) {
570 2
            $isValid = false;
571
            foreach ($missing as $key => $v) {
572
                echo $this->red("Missing required option: $key".PHP_EOL);
573
            }
574
        }
575
576
        if (count($opts)) {
577
            $isValid = false;
578
            foreach ($opts as $key => $v) {
579
                echo $this->red("Invalid option: $key".PHP_EOL);
580
            }
581
        }
582
583 26
        if ($isValid) {
584 18
            return $valid;
585
        } else {
586 8
            echo PHP_EOL;
587 8
            return null;
588
        }
589 26
    }
590
591
    /**
592
     * Gets the full cli schema.
593
     *
594
     * @param string $command The name of the command. This can be left blank if there is no command.
595
     * @return array Returns the schema that matches the command.
596
     */
597
    public function getSchema($command = '') {
598
        $result = [];
599
        foreach ($this->commandSchemas as $pattern => $opts) {
600
            if (fnmatch($pattern, $command)) {
601
                $result = array_replace_recursive($result, $opts);
602
            }
603
        }
604
        return $result;
605
    }
606
607
    /**
608
     * Gets/sets the value for a current meta item.
609
     *
610
     * @param string $name The name of the meta key.
611
     * @param mixed $value Set a new value for the meta key.
612
     * @return Cli|mixed Returns the current value of the meta item or `$this` for fluent setting.
613
     */
614 6
    public function meta($name, $value = null) {
615 6
        if ($value !== null) {
616 6
            $this->currentSchema[Cli::META][$name] = $value;
617 6
            return $this;
618
        }
619
        if (!isset($this->currentSchema[Cli::META][$name])) {
620
            return null;
621
        }
622
        return $this->currentSchema[Cli::META][$name];
623
    }
624
625
    /**
626
     * Adds an option (opt) to the current schema.
627
     *
628
     * @param string $name The long name(s) of the parameter.
629
     * You can use either just one name or a string in the form 'long:short' to specify the long and short name.
630
     * @param string $description A human-readable description for the column.
631
     * @param bool $required Whether or not the opt is required.
632
     * @param string $type The type of parameter.
633
     * This must be one of string, bool, integer.
634
     * @return Cli Returns this object for fluent calls.
635
     * @throws \Exception Throws an exception when the type is invalid.
636
     */
637 25
    public function opt($name, $description, $required = false, $type = 'string') {
638
        switch ($type) {
639 2
            case 'str':
640 2
            case 'string':
641 25
                $type = 'string';
642 25
                break;
643 3
            case 'bool':
644 3
            case 'boolean':
645
                $type = 'boolean';
646
                break;
647 21
            case 'int':
648 21
            case 'integer':
649 21
                $type = 'integer';
650 21
                break;
651
            default:
652
                throw new \Exception("Invalid type: $type. Must be one of string, boolean, or integer.", 422);
653
        }
654
655
        // Break the name up into its long and short form.
656
        $parts = explode(':', $name, 2);
657 2
        $long = $parts[0];
658
        $short = static::val(1, $parts, '');
659
660 2
        $this->currentSchema[$long] = ['description' => $description, 'required' => $required, 'type' => $type, 'short' => $short];
661 2
        return $this;
662 5
    }
663
664
    /**
665
     * Define an arg on the current command.
666
     *
667
     * @param string $name The name of the arg.
668
     * @param string $description The arg description.
669
     * @param bool $required Whether or not the arg is required.
670
     * @return Cli Returns $this for fluent calls.
671
     */
672 4
    public function arg($name, $description, $required = false) {
673 4
        $this->currentSchema[Cli::META][Cli::ARGS][$name] =
674 4
            ['description' => $description, 'required' => $required];
675 4
        return $this;
676
    }
677
678
    /**
679
     * Selects the current command schema name.
680
     *
681
     * @param string $pattern The command pattern.
682
     * @return Cli Returns $this for fluent calls.
683
     */
684 2
    public function command($pattern) {
685 2
        if (!isset($this->commandSchemas[$pattern])) {
686 2
            $this->commandSchemas[$pattern] = [Cli::META => []];
687
        }
688 2
        $this->currentSchema =& $this->commandSchemas[$pattern];
689
690 2
        return $this;
691
    }
692
693
694
    /**
695
     * Determine weather or not a value can be represented as a boolean.
696
     *
697
     * This method is sort of like {@link Cli::validateType()} but requires a more strict check of a boolean value.
698
     *
699
     * @param mixed $value The value to test.
700
     * @return bool
701
     */
702 2
    protected function isStrictBoolean($value, &$boolValue = null) {
703 1
        if ($value === true || $value === false) {
704
            $boolValue = $value;
705
            return true;
706
        } elseif (in_array($value, ['0', 'false', 'off', 'no'])) {
707 1
            $boolValue = false;
708 1
            return true;
709
        } elseif (in_array($value, ['1', 'true', 'on', 'yes'])) {
710
            $boolValue = true;
711
            return true;
712
        } else {
713 2
            $boolValue = null;
714 2
            return false;
715
        }
716 1
    }
717
718
    /**
719
     * Set the schema for a command.
720
     *
721
     * The schema array uses a short syntax so that commands can be specified as quickly as possible.
722
     * This schema is the exact same as those provided to {@link Schema::create()}.
723
     * The basic format of the array is the following:
724
     *
725
     * ```
726
     * [
727
     *     type:name[:shortCode][?],
728
     *     type:name[:shortCode][?],
729
     *     ...
730
     * ]
731
     * ```
732
     *
733
     * @param array $schema The schema array.
734
     */
735
    public function schema(array $schema) {
736
        $parsed = static::parseSchema($schema);
737
738
        $this->currentSchema = array_replace($this->currentSchema, $parsed);
739
    }
740
741
    /**
742
     * Bold some text.
743
     *
744
     * @param string $text The text to format.
745
     * @return string Returns the text surrounded by formatting commands.
746
     */
747
    public function bold($text) {
748
        return $this->formatString($text, ["\033[1m", "\033[0m"]);
749
    }
750
751
    /**
752
     * Bold some text.
753
     *
754
     * @param string $text The text to format.
755
     * @return string Returns the text surrounded by formatting commands.
756
     */
757
    public static function boldText($text) {
758
        return "\033[1m{$text}\033[0m";
759
    }
760
761
    /**
762
     * Make some text red.
763
     *
764
     * @param string $text The text to format.
765
     * @return string Returns  text surrounded by formatting commands.
766
     */
767
    public function red($text) {
768
        return $this->formatString($text, ["\033[1;31m", "\033[0m"]);
769
    }
770
771
    /**
772
     * Make some text red.
773
     *
774
     * @param string $text The text to format.
775
     * @return string Returns  text surrounded by formatting commands.
776
     */
777
    public static function redText($text) {
778
        return "\033[1;31m{$text}\033[0m";
779
    }
780
781
    /**
782
     * Make some text green.
783
     *
784
     * @param string $text The text to format.
785
     * @return string Returns  text surrounded by formatting commands.
786
     */
787
    public function green($text) {
788
        return $this->formatString($text, ["\033[1;32m", "\033[0m"]);
789
    }
790
791
    /**
792
     * Make some text green.
793
     *
794
     * @param string $text The text to format.
795
     * @return string Returns  text surrounded by formatting commands.
796
     */
797
    public static function greenText($text) {
798
        return "\033[1;32m{$text}\033[0m";
799
    }
800
801
    /**
802
     * Make some text blue.
803
     *
804
     * @param string $text The text to format.
805
     * @return string Returns  text surrounded by formatting commands.
806
     */
807
    public function blue($text) {
808
        return $this->formatString($text, ["\033[1;34m", "\033[0m"]);
809
    }
810
811
    /**
812
     * Make some text blue.
813
     *
814
     * @param string $text The text to format.
815
     * @return string Returns  text surrounded by formatting commands.
816
     */
817
    public static function blueText($text) {
818
        return "\033[1;34m{$text}\033[0m";
819
    }
820
821
    /**
822
     * Make some text purple.
823
     *
824
     * @param string $text The text to format.
825
     * @return string Returns  text surrounded by formatting commands.
826
     */
827
    public function purple($text) {
828
        return $this->formatString($text, ["\033[0;35m", "\033[0m"]);
829
    }
830
831
    /**
832
     * Make some text purple.
833
     *
834
     * @param string $text The text to format.
835
     * @return string Returns  text surrounded by formatting commands.
836
     */
837
    public static function purpleText($text) {
838
        return "\033[0;35m{$text}\033[0m";
839
    }
840
841
    /**
842
     * Format some text for the console.
843
     *
844
     * @param string $text The text to format.
845
     * @param array $wrap The format to wrap in the form ['before', 'after'].
846
     * @return string Returns the string formatted according to {@link Cli::$format}.
847
     */
848 7
    protected function formatString($text, array $wrap) {
849 7
        if ($this->format) {
850
            return "{$wrap[0]}$text{$wrap[1]}";
851
        } else {
852 7
            return $text;
853
        }
854 7
    }
855
856
    /**
857
     * Sleep for a number of seconds, echoing out a dot on each second.
858
     *
859
     * @param int $seconds The number of seconds to sleep.
860
     */
861
    public static function sleep($seconds) {
862
        for ($i = 0; $i < $seconds; $i++) {
863
            sleep(1);
864
            echo '.';
865
        }
866
    }
867
868
    /**
869
     * Validate the type of a value and coerce it into the proper type.
870
     *
871
     * @param mixed &$value The value to validate.
872
     * @param string $type One of: bool, int, string.
873
     * @param string $name The name of the option if you want to print an error message.
874
     * @param array|null $def The option def if you want to print an error message.
875
     * @return bool Returns `true` if the value is the correct type.
876
     * @throws \Exception Throws an exception when {@see $type} is not a known value.
877
     */
878 20
    protected function validateType(&$value, $type, $name = '', $def = null) {
879
        switch ($type) {
880 8
            case 'boolean':
881
                if (is_bool($value)) {
882 6
                    $valid = true;
883 6
                } elseif ($value === 0) {
884
                    // 0 doesn't work well with in_array() so check it separately.
885
                    $value = false;
886
                    $valid = true;
887
                } elseif (in_array($value, [null, '', '0', 'false', 'no', 'disabled'])) {
888 5
                    $value = false;
889 5
                    $valid = true;
890
                } elseif (in_array($value, [1, '1', 'true', 'yes', 'enabled'])) {
891 2
                    $value = true;
892 2
                    $valid = true;
893
                } else {
894 3
                    $valid = false;
895 3
                }
896 6
                break;
897 13
            case 'integer':
898
                if (is_numeric($value)) {
899 6
                    $value = (int)$value;
900 6
                    $valid = true;
901
                } else {
902 3
                    $valid = false;
903 6
                }
904 9
                break;
905 20
            case 'string':
906 20
                $value = (string)$value;
907 20
                $valid = true;
908 20
                break;
909
            default:
910
                throw new \Exception("Unknown type: $type.", 400);
911
        }
912
913 8
        if (!$valid && $name) {
914
            $short = static::val('short', (array)$def);
915 6
            $nameStr = "--$name".($short ? " (-$short)" : '');
916
            echo $this->red("The value of $nameStr is not a valid $type.".PHP_EOL);
917
        }
918
919 8
        return $valid;
920
    }
921
922
    /**
923
     * Writes a lis of all of the commands.
924
     */
925 2
    protected function writeCommands() {
926
        echo static::bold("COMMANDS").PHP_EOL;
927
928
        $table = new Table();
929 2
        foreach ($this->commandSchemas as $pattern => $schema) {
930
            if (static::isCommand($pattern)) {
931
                $table
932 1
                    ->row()
933 1
                    ->cell($pattern)
934
                    ->cell(Cli::val('description', Cli::val(Cli::META, $schema), ''));
935
            }
936
        }
937
        $table->write();
938 2
    }
939
940
    /**
941
     * Writes the cli help.
942
     *
943
     * @param string $command The name of the command or blank if there is no command.
944
     */
945 4
    public function writeHelp($command = '') {
946
        $schema = $this->getSchema($command);
947
        $this->writeSchemaHelp($schema);
948 4
    }
949
950
    /**
951
     * Writes the help for a given schema.
952
     *
953
     * @param array $schema A command line scheme returned from {@see Cli::getSchema()}.
954
     */
955 4
    protected function writeSchemaHelp($schema) {
956
        // Write the command description.
957
        $meta = Cli::val(Cli::META, $schema, []);
958
        $description = Cli::val('description', $meta);
959
960 4
        if ($description) {
961
            echo implode("\n", Cli::breakLines($description, 80, false)).PHP_EOL.PHP_EOL;
962
        }
963
964 4
        unset($schema[Cli::META]);
965
966
        // Add the help.
967
        $schema['help'] = [
968
            'description' => 'Display this help.',
969
            'type' => 'boolean',
970
            'short' => '?'
971 4
        ];
972
973
        echo Cli::bold('OPTIONS').PHP_EOL;
974
975
        ksort($schema);
976
977
        $table = new Table();
978 4
        $table->format = $this->format;
979
980
        foreach ($schema as $key => $definition) {
981
            $table->row();
982
983
            // Write the keys.
984
            $keys = "--{$key}";
985
            if ($shortKey = Cli::val('short', $definition, false)) {
986 1
                $keys .= ", -$shortKey";
987
            }
988
            if (Cli::val('required', $definition)) {
989
                $table->bold($keys);
990
            } else {
991
                $table->cell($keys);
992 1
            }
993
994
            // Write the description.
995
            $table->cell(Cli::val('description', $definition, ''));
996
        }
997
998
        $table->write();
999 4
        echo PHP_EOL;
1000
1001
        $args = Cli::val(Cli::ARGS, $meta, []);
1002 4
        if (!empty($args)) {
1003
            echo Cli::bold('ARGUMENTS').PHP_EOL;
1004
1005
            $table = new Table();
1006 2
            $table->format = $this->format;
1007
1008
            foreach ($args as $argName => $arg) {
1009
                $table->row();
1010
1011
                if (Cli::val('required', $arg)) {
1012
                    $table->bold($argName);
1013
                } else {
1014
                    $table->cell($argName);
1015 1
                }
1016
1017
                $table->cell(Cli::val('description', $arg, ''));
1018
            }
1019
            $table->write();
1020 2
            echo PHP_EOL;
1021
        }
1022 4
    }
1023
1024
    /**
1025
     * Writes the basic usage information of the command.
1026
     *
1027
     * @param Args $args The parsed args returned from {@link Cli::parseRaw()}.
1028
     */
1029 6
    protected function writeUsage(Args $args) {
1030
        if ($filename = $args->getMeta('filename')) {
1031
            $schema = $this->getSchema($args->getCommand());
1032
            unset($schema[Cli::META]);
1033
1034
            echo static::bold("usage: ").$filename;
1035
1036
            if ($this->hasCommand()) {
1037
                if ($args->getCommand() && isset($this->commandSchemas[$args->getCommand()])) {
1038
                    echo ' '.$args->getCommand();
1039
1040
                } else {
1041 2
                    echo ' <command>';
1042 2
                }
1043
            }
1044
1045
            if ($this->hasOptions($args->getCommand())) {
1046 6
                echo " [<options>]";
1047
            }
1048
1049
            if ($hasArgs = $this->hasArgs($args->getCommand())) {
1050 4
                echo $hasArgs === 2 ? " <args>" : " [<args>]";
1051
            }
1052
1053 6
            echo PHP_EOL.PHP_EOL;
1054
        }
1055
    }
1056
1057
    /**
1058
     * Parse a schema in short form into a full schema array.
1059
     *
1060
     * @param array $arr The array to parse into a schema.
1061
     * @return array The full schema array.
1062
     * @throws \InvalidArgumentException Throws an exception when an item in the schema is invalid.
1063
     */
1064 1
    public static function parseSchema(array $arr) {
1065 1
        $result = [];
1066
1067
        foreach ($arr as $key => $value) {
1068
            if (is_int($key)) {
1069
                if (is_string($value)) {
1070
                    // This is a short param value.
1071
                    $param = static::parseShortParam($value);
1072 1
                    $name = $param['name'];
1073 1
                    $result[$name] = $param;
1074
                } else {
1075
                    throw new \InvalidArgumentException("Schema at position $key is not a valid param.", 500);
1076 1
                }
1077
            } else {
1078
                // The parameter is defined in the key.
1079
                $param = static::parseShortParam($key, $value);
1080
                $name = $param['name'];
1081
1082
                if (is_array($value)) {
1083
                    // The value describes a bit more about the schema.
1084
                    switch ($param['type']) {
1085
                        case 'array':
1086
                            if (isset($value['items'])) {
1087
                                // The value includes array schema information.
1088
                                $param = array_replace($param, $value);
1089
                            } else {
1090
                                // The value is a schema of items.
1091
                                $param['items'] = $value;
1092
                            }
1093
                            break;
1094
                        case 'object':
1095
                            // The value is a schema of the object.
1096
                            $param['properties'] = static::parseSchema($value);
1097
                            break;
1098
                        default:
1099
                            $param = array_replace($param, $value);
1100
                            break;
1101
                    }
1102
                } elseif (is_string($value)) {
1103
                    if ($param['type'] === 'array') {
1104
                        // Check to see if the value is the item type in the array.
1105
                        if (isset(self::$types[$value])) {
1106
                            $arrType = self::$types[$value];
1107
                        } elseif (($index = array_search($value, self::$types)) !== false) {
1108
                            $arrType = self::$types[$value];
1109
                        }
1110
1111
                        if (isset($arrType)) {
1112
                            $param['items'] = ['type' => $arrType];
1113
                        } else {
1114
                            $param['description'] = $value;
1115
                        }
1116
                    } else {
1117
                        // The value is the schema description.
1118
                        $param['description'] = $value;
1119
                    }
1120
                }
1121
1122
                $result[$name] = $param;
1123 1
            }
1124
        }
1125
1126 1
        return $result;
1127 1
    }
1128
1129
    /**
1130
     * Parse a short parameter string into a full array parameter.
1131
     *
1132
     * @param string $str The short parameter string to parse.
1133
     * @param array $other An array of other information that might help resolve ambiguity.
1134
     * @return array Returns an array in the form [name, [param]].
1135
     * @throws \InvalidArgumentException Throws an exception if the short param is not in the correct format.
1136
     */
1137 1
    protected static function parseShortParam($str, $other = []) {
1138
        // Is the parameter optional?
1139
        if (substr($str, -1) === '?') {
1140
            $required = false;
1141
            $str = substr($str, 0, -1);
1142
        } else {
1143 1
            $required = true;
1144
        }
1145
1146
        // Check for a type.
1147
        $parts = explode(':', $str);
1148
1149
        if (count($parts) === 1) {
1150 1
            if (isset($other['type'])) {
1151
                $type = $other['type'];
1152
            } else {
1153 1
                $type = 'string';
1154
            }
1155 1
            $name = $parts[0];
1156
        } else {
1157
            $name = $parts[1];
1158
1159
            if (isset(self::$types[$parts[0]])) {
1160
                $type = self::$types[$parts[0]];
1161
            } else {
1162
                throw new \InvalidArgumentException("Invalid type {$parts[1]} for field $name.", 500);
1163
            }
1164
1165
            if (isset($parts[2])) {
1166 1
                $short = $parts[2];
1167
            }
1168 1
        }
1169
1170
        $result = ['name' => $name, 'type' => $type, 'required' => $required];
1171
1172
        if (isset($short)) {
1173 1
            $result['short'] = $short;
1174
        }
1175
1176
        return $result;
1177 1
    }
1178
1179
    /**
1180
     * Safely get a value out of an array.
1181
     *
1182
     * This function uses optimizations found in the [facebook libphputil library](https://github.com/facebook/libphutil).
1183
     *
1184
     * @param string|int $key The array key.
1185
     * @param array $array The array to get the value from.
1186
     * @param mixed $default The default value to return if the key doesn't exist.
1187
     * @return mixed The item from the array or `$default` if the array key doesn't exist.
1188
     */
1189 3
    public static function val($key, array $array, $default = null) {
1190
        // isset() is a micro-optimization - it is fast but fails for null values.
1191
        if (isset($array[$key])) {
1192
            return $array[$key];
1193
        }
1194
1195
        // Comparing $default is also a micro-optimization.
1196
        if ($default === null || array_key_exists($key, $array)) {
1197 3
            return null;
1198
        }
1199
1200 3
        return $default;
1201
    }
1202
}
1203