Completed
Push — feature/guess-cli-format ( 6a8bbf )
by Todd
02:21
created

Cli::arg()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 1

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 0
loc 5
ccs 4
cts 4
cp 1
rs 9.4286
cc 1
eloc 4
nc 1
nop 3
crap 1
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
    protected $formatOutput;
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
56 32
        $this->formatOutput = $this->guessFormatOutput();
57 32
    }
58
59
    /**
60
     * Backwards compatibility for the **format** property.
61
     *
62
     * @param string $name Must be **format**.
63
     * @return bool|null Returns {@link getFormatOutput()} or null if {@link $name} isn't **format**.
64
     */
65
    public function __get($name) {
66
        if ($name === 'format') {
67
            trigger_error("Cli->format is deprecated. Use Cli->getFormatOutput() instead.", E_USER_DEPRECATED);
68
            return $this->getFormatOutput();
69
        }
70
        return null;
71
    }
72
73
    /**
74
     * Backwards compatibility for the **format** property.
75
     *
76
     * @param string $name Must be **format**.
77
     * @param bool $value One of **true** or **false**.
78
     */
79
    public function __set($name, $value) {
80
        if ($name === 'format') {
81
            trigger_error("Cli->format is deprecated. Use Cli->setFormatOutput() instead.", E_USER_DEPRECATED);
82
            $this->setFormatOutput($value);
83
        }
84
    }
85
86
    /**
87
     * Get whether or not output should be formatted.
88
     *
89
     * @return boolean Returns **true** if output should be formatted or **false** otherwise.
90
     */
91
    public function getFormatOutput() {
92
        return $this->formatOutput;
93
    }
94
95
    /**
96
     * Set whether or not output should be formatted.
97
     *
98
     * @param boolean $formatOutput Whether or not to format output.
99
     * @return LogFormatter Returns `$this` for fluent calls.
100
     */
101
    public function setFormatOutput($formatOutput) {
102
        $this->formatOutput = $formatOutput;
103
        return $this;
104
    }
105
106
    /**
107
     * Add an argument to an {@link Args} object, checking for a correct name.
108
     *
109
     * @param array $schema The schema for the args.
110
     * @param Args $args The args object to add the argument to.
111
     * @param $arg The value of the argument.
112
     */
113 2
    private function addArg(array $schema, Args $args, $arg) {
114 2
        $argsCount = count($args->getArgs());
115 2
        $schemaArgs = isset($schema[self::META][self::ARGS]) ? array_keys($schema[self::META][self::ARGS]) : [];
116 2
        $name = isset($schemaArgs[$argsCount]) ? $schemaArgs[$argsCount] : $argsCount;
117
118 2
        $args->addArg($arg, $name);
119 2
    }
120
121
    /**
122
     * Construct and return a new {@link Cli} object.
123
     *
124
     * This method is mainly here so that an entire cli schema can be created and defined with fluent method calls.
125
     *
126
     * @return Cli Returns a new Cli object.
127
     */
128 2
    public static function create() {
129 2
        return new Cli();
130
    }
131
132
    /**
133
     * Breaks a cell into several lines according to a given width.
134
     *
135
     * @param string $text The text of the cell.
136
     * @param int $width The width of the cell.
137
     * @param bool $addSpaces Whether or not to right-pad the cell with spaces.
138
     * @return array Returns an array of strings representing the lines in the cell.
139
     */
140 6
    public static function breakLines($text, $width, $addSpaces = true) {
141 6
        $rawLines = explode("\n", $text);
142 6
        $lines = [];
143
144 6
        foreach ($rawLines as $line) {
145
            // Check to see if the line needs to be broken.
146 6
            $sublines = static::breakString($line, $width, $addSpaces);
147 6
            $lines = array_merge($lines, $sublines);
148 6
        }
149
150 6
        return $lines;
151
    }
152
153
    /**
154
     * Breaks a line of text according to a given width.
155
     *
156
     * @param string $line The text of the line.
157
     * @param int $width The width of the cell.
158
     * @param bool $addSpaces Whether or not to right pad the lines with spaces.
159
     * @return array Returns an array of lines, broken on word boundaries.
160
     */
161 6
    protected static function breakString($line, $width, $addSpaces = true) {
162 6
        $words = explode(' ', $line);
163 6
        $result = [];
164
165 6
        $line = '';
166 6
        foreach ($words as $word) {
167 6
            $candidate = trim($line.' '.$word);
168
169
            // Check for a new line.
170 6
            if (strlen($candidate) > $width) {
171 2
                if ($line === '') {
172
                    // The word is longer than a line.
173
                    if ($addSpaces) {
174
                        $result[] = substr($candidate, 0, $width);
175
                    } else {
176
                        $result[] = $candidate;
177
                    }
178
                } else {
179 2
                    if ($addSpaces) {
180
                        $line .= str_repeat(' ', $width - strlen($line));
181
                    }
182
183
                    // Start a new line.
184 2
                    $result[] = $line;
185 2
                    $line = $word;
186
                }
187 2
            } else {
188 6
                $line = $candidate;
189
            }
190 6
        }
191
192
        // Add the remaining line.
193 6
        if ($line) {
194 6
            if ($addSpaces) {
195 6
                $line .= str_repeat(' ', $width - strlen($line));
196 6
            }
197
198
            // Start a new line.
199 6
            $result[] = $line;
200 6
        }
201
202 6
        return $result;
203
    }
204
205
    /**
206
     * Sets the description for the current command.
207
     *
208
     * @param string $str The description for the current schema or null to get the current description.
209
     * @return Cli Returns this class for fluent calls.
210
     */
211 8
    public function description($str = null) {
212 8
        return $this->meta('description', $str);
213
    }
214
215
    /**
216
     * Determines whether or not the schema has a command.
217
     *
218
     * @param string $name Check for the specific command name.
219
     * @return bool Returns true if the schema has a command.
220
     */
221 32
    public function hasCommand($name = '') {
222 32
        if ($name) {
223
            return array_key_exists($name, $this->commandSchemas);
224
        } else {
225 32
            foreach ($this->commandSchemas as $pattern => $opts) {
226 32
                if (strpos($pattern, '*') === false) {
227 4
                    return true;
228
                }
229 32
            }
230 28
            return false;
231
        }
232
    }
233
234
    /**
235
     * Determines whether a command has options.
236
     *
237
     * @param string $command The name of the command or an empty string for any command.
238
     * @return bool Returns true if the command has options. False otherwise.
239
     */
240 6
    public function hasOptions($command = '') {
241 6
        if ($command) {
242 2
            $def = $this->getSchema($command);
243 2
            return $this->hasOptionsDef($def);
244
        } else {
245 4
            foreach ($this->commandSchemas as $pattern => $def) {
246 4
                if ($this->hasOptionsDef($def)) {
247 4
                    return true;
248
                }
249 1
            }
250
        }
251
        return false;
252
    }
253
254
    /**
255
     * Determines whether or not a command definition has options.
256
     *
257
     * @param array $commandDef The command definition as returned from {@link Cli::getSchema()}.
258
     * @return bool Returns true if the command def has options or false otherwise.
259
     */
260 6
    protected function hasOptionsDef($commandDef) {
261 6
        return count($commandDef) > 1 || (count($commandDef) > 0 && !isset($commandDef[Cli::META]));
262
    }
263
264
    /**
265
     * Determines whether or not a command has args.
266
     *
267
     * @param string $command The command name to check.
268
     * @return int Returns one of the following.
269
     * - 0: The command has no args.
270
     * - 1: The command has optional args.
271
     * - 2: The command has required args.
272
     */
273 6
    public function hasArgs($command = '') {
274 6
        $args = null;
275
276 6
        if ($command) {
277
            // Check to see if the specific command has args.
278 2
            $def = $this->getSchema($command);
279 2
            if (isset($def[Cli::META][Cli::ARGS])) {
280 2
                $args = $def[Cli::META][Cli::ARGS];
281 2
            }
282 2
        } else {
283 4
            foreach ($this->commandSchemas as $pattern => $def) {
284 4
                if (isset($def[Cli::META][Cli::ARGS])) {
285 2
                    $args = $def[Cli::META][Cli::ARGS];
286 2
                }
287 4
            }
288 4
            if (!empty($args)) {
289 2
                return 1;
290
            }
291
        }
292
293 4
        if (!$args || empty($args)) {
294 2
            return 0;
295
        }
296
297 2
        foreach ($args as $arg) {
298 2
            if (!Cli::val('required', $arg)) {
299 1
                return 1;
300
            }
301 1
        }
302 1
        return 2;
303
    }
304
305
    /**
306
     * Finds our whether a pattern is a command.
307
     *
308
     * @param string $pattern The pattern being evaluated.
309
     * @return bool Returns `true` if `$pattern` is a command, `false` otherwise.
310
     */
311 2
    public static function isCommand($pattern) {
312 2
        return strpos($pattern, '*') === false;
313
    }
314
315
    /**
316
     * Parses and validates a set of command line arguments the schema.
317
     *
318
     * @param array $argv The command line arguments a form compatible with the global `$argv` variable.
319
     *
320
     * Note that the `$argv` array must have at least one element and it must represent the path to the command that
321
     * invoked the command. This is used to write usage information.
322
     * @param bool $exit Whether to exit the application when there is an error or when writing help.
323
     * @return Args|null Returns an {@see Args} instance when a command should be executed
324
     * or `null` when one should not be executed.
325
     * @throws \Exception Throws an exception when {@link $exit} is false and the help or errors need to be displayed.
326
     */
327 32
    public function parse($argv = null, $exit = true) {
328
        // Only format commands if we are exiting.
329 32
        $this->formatOutput = $exit;
330 32
        if (!$exit) {
331 31
            ob_start();
332 31
        }
333
334 32
        $args = $this->parseRaw($argv);
335
336 32
        $hasCommand = $this->hasCommand();
337
338
339 32
        if ($hasCommand && !$args->getCommand()) {
340
            // If no command is given then write a list of commands.
341 2
            $this->writeUsage($args);
342 2
            $this->writeCommands();
343 2
            $result = null;
344 32
        } elseif ($args->getOpt('help') || $args->getOpt('?')) {
345
            // Write the help.
346 4
            $this->writeUsage($args);
347 4
            $this->writeHelp($args->getCommand());
348 4
            $result = null;
349 4
        } else {
350
            // Validate the arguments against the schema.
351 26
            $validArgs = $this->validate($args);
352 26
            $result = $validArgs;
353
        }
354 32
        if (!$exit) {
355 31
            $output = ob_get_clean();
356 31
            if ($result === null) {
357 14
                throw new \Exception(trim($output));
358
            }
359 18
        } elseif ($result === null) {
360
            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...
361
        }
362 18
        return $result;
363
    }
364
365
    /**
366
     * Parse an array of arguments.
367
     *
368
     * If the first item in the array is in the form of a command (no preceding - or --),
369
     * 'command' is filled with its value.
370
     *
371
     * @param array $argv An array of arguments passed in a form compatible with the global `$argv` variable.
372
     * @return Args Returns the raw parsed arguments.
373
     * @throws \Exception Throws an exception when {@see $argv} isn't an array.
374
     */
375 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...
376 32
        if ($argv === null) {
377
            $argv = $GLOBALS['argv'];
378
        }
379
380 32
        if (!is_array($argv)) {
381
            throw new \Exception(__METHOD__ . " expects an array", 400);
382
        }
383
384 32
        $path = array_shift($argv);
385 32
        $hasCommand = $this->hasCommand();
386
387 32
        $parsed = new Args();
388 32
        $parsed->setMeta('path', $path);
389 32
        $parsed->setMeta('filename', basename($path));
390
391 32
        if (count($argv)) {
392
            // Get possible command.
393 32
            if (substr($argv[0], 0, 1) != '-') {
394 4
                $arg0 = array_shift($argv);
395 4
                if ($hasCommand) {
396 2
                    $parsed->setCommand($arg0);
397 2
                } else {
398 2
                    $schema = $this->getSchema($parsed->getCommand());
399 2
                    $this->addArg($schema, $parsed, $arg0);
400
                }
401 4
            }
402
            // Get the data types for all of the commands.
403 32
            if (!isset($schema)) {
404 30
                $schema = $this->getSchema($parsed->getCommand());
405 30
            }
406 32
            $types = [];
407 32
            foreach ($schema as $sName => $sRow) {
408 32
                if ($sName === Cli::META) {
409 32
                    continue;
410
                }
411
412 30
                $type = Cli::val('type', $sRow, 'string');
413 30
                $types[$sName] = $type;
414 30
                if (isset($sRow['short'])) {
415 30
                    $types[$sRow['short']] = $type;
416 30
                }
417 32
            }
418
419
            // Parse opts.
420 32
            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...
421 32
                $str = $argv[$i];
422
423 32
                if ($str === '--') {
424
                    // --
425
                    $i++;
426
                    break;
427 32
                } elseif (strlen($str) > 2 && substr($str, 0, 2) == '--') {
428
                    // --foo
429 18
                    $str = substr($str, 2);
430 18
                    $parts = explode('=', $str);
431 18
                    $key = $parts[0];
432
433
                    // Does not have an =, so choose the next arg as its value,
434
                    // unless it is defined as 'boolean' in which case there is no
435
                    // value to seek in next arg
436 18
                    if (count($parts) == 1 && isset($argv[$i + 1]) && preg_match('/^--?.+/', $argv[$i + 1]) == 0) {
437 5
                        $v = $argv[$i + 1];
438 5
                        if (Cli::val($key, $types) == 'boolean') {
439 1
                            if (in_array($v, ['0', '1', 'true', 'false', 'on', 'off', 'yes', 'no'])) {
440
                                // The next arg looks like a boolean to me.
441 1
                                $i++;
442 1
                            } else {
443
                                // Next arg is not a boolean: set the flag on, and use next arg in its own iteration
444
                                $v = true;
445
                            }
446 1
                        } else {
447 4
                            $i++;
448
                        }
449 18
                    } elseif (count($parts) == 2) {// Has a =, so pick the second piece
450 8
                        $v = $parts[1];
451 8
                    } else {
452 9
                        $v = true;
453
                    }
454 18
                    $parsed->setOpt($key, $v);
455 32
                } elseif (strlen($str) == 2 && $str[0] == '-') {
456
                    // -a
457
458 8
                    $key = $str[1];
459 8
                    $type = Cli::val($key, $types, 'boolean');
460 8
                    $v = null;
461
462 8
                    if (isset($argv[$i + 1])) {
463
                        // Try and be smart about the next arg.
464 5
                        $nextArg = $argv[$i + 1];
465
466 5
                        if ($type === 'boolean') {
467 2
                            if ($this->isStrictBoolean($nextArg)) {
468
                                // The next arg looks like a boolean to me.
469 1
                                $v = $nextArg;
470 1
                                $i++;
471 1
                            } else {
472 2
                                $v = true;
473
                            }
474 5
                        } elseif (!preg_match('/^--?.+/', $argv[$i + 1])) {
475
                            // The next arg is not an opt.
476 4
                            $v = $nextArg;
477 4
                            $i++;
478 4
                        } else {
479
                            // The next arg is another opt.
480
                            $v = null;
481
                        }
482 5
                    }
483
484 8
                    if ($v === null) {
485 3
                        $v = Cli::val($type, ['boolean' => true, 'integer' => 1, 'string' => '']);
486 3
                    }
487
488 8
                    $parsed->setOpt($key, $v);
489 22
                } elseif (strlen($str) > 1 && $str[0] == '-') {
490
                    // -abcdef
491 16
                    for ($j = 1; $j < strlen($str); $j++) {
492 16
                        $opt = $str[$j];
493 16
                        $remaining = substr($str, $j + 1);
494 16
                        $type = Cli::val($opt, $types, 'boolean');
495
496
                        // Check for an explicit equals sign.
497 16
                        if (substr($remaining, 0, 1) === '=') {
498 2
                            $remaining = substr($remaining, 1);
499 2
                            if ($type === 'boolean') {
500
                                // Bypass the boolean flag checking below.
501
                                $parsed->setOpt($opt, $remaining);
502
                                break;
503
                            }
504 2
                        }
505
506 16
                        if ($type === 'boolean') {
507 5
                            if (preg_match('`^([01])`', $remaining, $matches)) {
508
                                // Treat the 0 or 1 as a true or false.
509 3
                                $parsed->setOpt($opt, $matches[1]);
510 3
                                $j += strlen($matches[1]);
511 3
                            } else {
512
                                // Treat the option as a flag.
513 3
                                $parsed->setOpt($opt, true);
514
                            }
515 16
                        } elseif ($type === 'string') {
516
                            // Treat the option as a set with no = sign.
517 13
                            $parsed->setOpt($opt, $remaining);
518 13
                            break;
519 3
                        } elseif ($type === 'integer') {
520 3
                            if (preg_match('`^(\d+)`', $remaining, $matches)) {
521
                                // Treat the option as a set with no = sign.
522 2
                                $parsed->setOpt($opt, $matches[1]);
523 2
                                $j += strlen($matches[1]);
524 2
                            } else {
525
                                // Treat the option as either multiple flags.
526 2
                                $optVal = $parsed->getOpt($opt, 0);
527 2
                                $parsed->setOpt($opt, $optVal + 1);
528
                            }
529 3
                        } else {
530
                            // This should not happen unless we've put a bug in our code.
531
                            throw new \Exception("Invalid type $type for $opt.", 500);
532
                        }
533 6
                    }
534 16
                } else {
535
                    // End of opts
536 1
                    break;
537
                }
538 31
            }
539
540
            // Grab the remaining args.
541 32
            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...
542 1
                $this->addArg($schema, $parsed, $argv[$i]);
543 1
            }
544 32
        }
545
546 32
        return $parsed;
547
    }
548
549
    /**
550
     * Validates arguments against the schema.
551
     *
552
     * @param Args $args The arguments that were returned from {@link Cli::parseRaw()}.
553
     * @return Args|null
554
     */
555 26
    public function validate(Args $args) {
556 26
        $isValid = true;
557 26
        $command = $args->getCommand();
558 26
        $valid = new Args($command);
559 26
        $schema = $this->getSchema($command);
560 26
        ksort($schema);
561
562
//        $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...
563 26
        unset($schema[Cli::META]);
564 26
        $opts = $args->getOpts();
565 26
        $missing = [];
566
567
        // Check to see if the command is correct.
568 26
        if ($command && !$this->hasCommand($command) && $this->hasCommand()) {
569
            echo $this->red("Invalid command: $command.".PHP_EOL);
570
            $isValid = false;
571
        }
572
573
        // Add the args.
574 26
        $valid->setArgs($args->getArgs());
575
576 26
        foreach ($schema as $key => $definition) {
577
            // 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...
578 25
            $type = Cli::val('type', $definition, 'string');
579
580 25
            if (isset($opts[$key])) {
581
                // Check for --key.
582 10
                $value = $opts[$key];
583 10
                if ($this->validateType($value, $type, $key, $definition)) {
584 7
                    $valid->setOpt($key, $value);
585 7
                } else {
586 3
                    $isValid = false;
587
                }
588 10
                unset($opts[$key]);
589 25
            } elseif (isset($definition['short']) && isset($opts[$definition['short']])) {
590
                // Check for -s.
591 20
                $value = $opts[$definition['short']];
592 20
                if ($this->validateType($value, $type, $key, $definition)) {
593 19
                    $valid->setOpt($key, $value);
594 19
                } else {
595 2
                    $isValid = false;
596
                }
597 20
                unset($opts[$definition['short']]);
598 23
            } elseif (isset($opts['no-'.$key])) {
599
                // Check for --no-key.
600 4
                $value = $opts['no-'.$key];
601
602 4
                if ($type !== 'boolean') {
603 1
                    echo $this->red("Cannot apply the --no- prefix on the non boolean --$key.".PHP_EOL);
604 1
                    $isValid = false;
605 4
                } elseif ($this->validateType($value, $type, $key, $definition)) {
606 2
                    $valid->setOpt($key, !$value);
607 2
                } else {
608 1
                    $isValid = false;
609
                }
610 4
                unset($opts['no-'.$key]);
611 18
            } elseif ($definition['required']) {
612
                // The key was not supplied. Is it required?
613 2
                $missing[$key] = true;
614 2
                $valid->setOpt($key, false);
615 2
            }
616 26
        }
617
618 26
        if (count($missing)) {
619 2
            $isValid = false;
620 2
            foreach ($missing as $key => $v) {
621 2
                echo $this->red("Missing required option: $key".PHP_EOL);
622 2
            }
623 2
        }
624
625 26
        if (count($opts)) {
626
            $isValid = false;
627
            foreach ($opts as $key => $v) {
628
                echo $this->red("Invalid option: $key".PHP_EOL);
629
            }
630
        }
631
632 26
        if ($isValid) {
633 18
            return $valid;
634
        } else {
635 8
            echo PHP_EOL;
636 8
            return null;
637
        }
638
    }
639
640
    /**
641
     * Gets the full cli schema.
642
     *
643
     * @param string $command The name of the command. This can be left blank if there is no command.
644
     * @return array Returns the schema that matches the command.
645
     */
646 32
    public function getSchema($command = '') {
647 32
        $result = [];
648 32
        foreach ($this->commandSchemas as $pattern => $opts) {
649 32
            if (fnmatch($pattern, $command)) {
650 32
                $result = array_replace_recursive($result, $opts);
651 32
            }
652 32
        }
653 32
        return $result;
654
    }
655
656
    /**
657
     * Gets/sets the value for a current meta item.
658
     *
659
     * @param string $name The name of the meta key.
660
     * @param mixed $value Set a new value for the meta key.
661
     * @return Cli|mixed Returns the current value of the meta item or `$this` for fluent setting.
662
     */
663 8
    public function meta($name, $value = null) {
664 8
        if ($value !== null) {
665 8
            $this->currentSchema[Cli::META][$name] = $value;
666 8
            return $this;
667
        }
668
        if (!isset($this->currentSchema[Cli::META][$name])) {
669
            return null;
670
        }
671
        return $this->currentSchema[Cli::META][$name];
672
    }
673
674
    /**
675
     * Adds an option (opt) to the current schema.
676
     *
677
     * @param string $name The long name(s) of the parameter.
678
     * You can use either just one name or a string in the form 'long:short' to specify the long and short name.
679
     * @param string $description A human-readable description for the column.
680
     * @param bool $required Whether or not the opt is required.
681
     * @param string $type The type of parameter.
682
     * This must be one of string, bool, integer.
683
     * @return Cli Returns this object for fluent calls.
684
     * @throws \Exception Throws an exception when the type is invalid.
685
     */
686 30
    public function opt($name, $description, $required = false, $type = 'string') {
687
        switch ($type) {
688 30
            case 'str':
689 30
            case 'string':
690 28
                $type = 'string';
691 28
                break;
692 28
            case 'bool':
693 28
            case 'boolean':
694 25
                $type = 'boolean';
695 25
                break;
696 21
            case 'int':
697 21
            case 'integer':
698 21
                $type = 'integer';
699 21
                break;
700
            default:
701
                throw new \Exception("Invalid type: $type. Must be one of string, boolean, or integer.", 422);
702
        }
703
704
        // Break the name up into its long and short form.
705 30
        $parts = explode(':', $name, 2);
706 30
        $long = $parts[0];
707 30
        $short = static::val(1, $parts, '');
708
709 30
        $this->currentSchema[$long] = ['description' => $description, 'required' => $required, 'type' => $type, 'short' => $short];
710 30
        return $this;
711
    }
712
713
    /**
714
     * Define an arg on the current command.
715
     *
716
     * @param string $name The name of the arg.
717
     * @param string $description The arg description.
718
     * @param bool $required Whether or not the arg is required.
719
     * @return Cli Returns $this for fluent calls.
720
     */
721 5
    public function arg($name, $description, $required = false) {
722 5
        $this->currentSchema[Cli::META][Cli::ARGS][$name] =
723 5
            ['description' => $description, 'required' => $required];
724 5
        return $this;
725
    }
726
727
    /**
728
     * Selects the current command schema name.
729
     *
730
     * @param string $pattern The command pattern.
731
     * @return Cli Returns $this for fluent calls.
732
     */
733 4
    public function command($pattern) {
734 4
        if (!isset($this->commandSchemas[$pattern])) {
735 4
            $this->commandSchemas[$pattern] = [Cli::META => []];
736 4
        }
737 4
        $this->currentSchema =& $this->commandSchemas[$pattern];
738
739 4
        return $this;
740
    }
741
742
743
    /**
744
     * Determine weather or not a value can be represented as a boolean.
745
     *
746
     * This method is sort of like {@link Cli::validateType()} but requires a more strict check of a boolean value.
747
     *
748
     * @param mixed $value The value to test.
749
     * @return bool
750
     */
751 2
    protected function isStrictBoolean($value, &$boolValue = null) {
752 2
        if ($value === true || $value === false) {
753
            $boolValue = $value;
754
            return true;
755 2
        } elseif (in_array($value, ['0', 'false', 'off', 'no'])) {
756 1
            $boolValue = false;
757 1
            return true;
758 2
        } elseif (in_array($value, ['1', 'true', 'on', 'yes'])) {
759
            $boolValue = true;
760
            return true;
761
        } else {
762 2
            $boolValue = null;
763 2
            return false;
764
        }
765
    }
766
767
    /**
768
     * Set the schema for a command.
769
     *
770
     * The schema array uses a short syntax so that commands can be specified as quickly as possible.
771
     * This schema is the exact same as those provided to {@link Schema::create()}.
772
     * The basic format of the array is the following:
773
     *
774
     * ```
775
     * [
776
     *     type:name[:shortCode][?],
777
     *     type:name[:shortCode][?],
778
     *     ...
779
     * ]
780
     * ```
781
     *
782
     * @param array $schema The schema array.
783
     */
784 1
    public function schema(array $schema) {
785 1
        $parsed = static::parseSchema($schema);
786
787 1
        $this->currentSchema = array_replace($this->currentSchema, $parsed);
788 1
    }
789
790
    /**
791
     * Bold some text.
792
     *
793
     * @param string $text The text to format.
794
     * @return string Returns the text surrounded by formatting commands.
795
     */
796 6
    public function bold($text) {
797 6
        return $this->formatString($text, ["\033[1m", "\033[0m"]);
798
    }
799
800
    /**
801
     * Bold some text.
802
     *
803
     * @param string $text The text to format.
804
     * @return string Returns the text surrounded by formatting commands.
805
     */
806
    public static function boldText($text) {
807
        return "\033[1m{$text}\033[0m";
808
    }
809
810
    /**
811
     * Make some text red.
812
     *
813
     * @param string $text The text to format.
814
     * @return string Returns  text surrounded by formatting commands.
815
     */
816 8
    public function red($text) {
817 8
        return $this->formatString($text, ["\033[1;31m", "\033[0m"]);
818
    }
819
820
    /**
821
     * Make some text red.
822
     *
823
     * @param string $text The text to format.
824
     * @return string Returns  text surrounded by formatting commands.
825
     */
826
    public static function redText($text) {
827
        return "\033[1;31m{$text}\033[0m";
828
    }
829
830
    /**
831
     * Make some text green.
832
     *
833
     * @param string $text The text to format.
834
     * @return string Returns  text surrounded by formatting commands.
835
     */
836
    public function green($text) {
837
        return $this->formatString($text, ["\033[1;32m", "\033[0m"]);
838
    }
839
840
    /**
841
     * Make some text green.
842
     *
843
     * @param string $text The text to format.
844
     * @return string Returns  text surrounded by formatting commands.
845
     */
846
    public static function greenText($text) {
847
        return "\033[1;32m{$text}\033[0m";
848
    }
849
850
    /**
851
     * Make some text blue.
852
     *
853
     * @param string $text The text to format.
854
     * @return string Returns  text surrounded by formatting commands.
855
     */
856
    public function blue($text) {
857
        return $this->formatString($text, ["\033[1;34m", "\033[0m"]);
858
    }
859
860
    /**
861
     * Make some text blue.
862
     *
863
     * @param string $text The text to format.
864
     * @return string Returns  text surrounded by formatting commands.
865
     */
866
    public static function blueText($text) {
867
        return "\033[1;34m{$text}\033[0m";
868
    }
869
870
    /**
871
     * Make some text purple.
872
     *
873
     * @param string $text The text to format.
874
     * @return string Returns  text surrounded by formatting commands.
875
     */
876
    public function purple($text) {
877
        return $this->formatString($text, ["\033[0;35m", "\033[0m"]);
878
    }
879
880
    /**
881
     * Make some text purple.
882
     *
883
     * @param string $text The text to format.
884
     * @return string Returns  text surrounded by formatting commands.
885
     */
886
    public static function purpleText($text) {
887
        return "\033[0;35m{$text}\033[0m";
888
    }
889
890
    /**
891
     * Format some text for the console.
892
     *
893
     * @param string $text The text to format.
894
     * @param array $wrap The format to wrap in the form ['before', 'after'].
895
     * @return string Returns the string formatted according to {@link Cli::$format}.
896
     */
897 14
    protected function formatString($text, array $wrap) {
898 14
        if ($this->formatOutput) {
899
            return "{$wrap[0]}$text{$wrap[1]}";
900
        } else {
901 14
            return $text;
902
        }
903
    }
904
905
    /**
906
     * Guess whether or not to format the output with colors.
907
     *
908
     * If the current environment is being redirected to a file then output should not be formatted. Also, Windows
909
     * machines do not support terminal colors so formatting should be suppressed on them too.
910
     *
911
     * @return bool Returns **true** if the output can be formatter or **false** otherwise.
912
     */
913 32
    public function guessFormatOutput() {
914 32
        if (defined('PHP_WINDOWS_VERSION_MAJOR')) {
915
            return false;
916 32
        } elseif (function_exists('posix_isatty')) {
917 32
            return posix_isatty(STDOUT);
918
        } else {
919
            return true;
920
        }
921
    }
922
923
    /**
924
     * Sleep for a number of seconds, echoing out a dot on each second.
925
     *
926
     * @param int $seconds The number of seconds to sleep.
927
     */
928
    public static function sleep($seconds) {
929
        for ($i = 0; $i < $seconds; $i++) {
930
            sleep(1);
931
            echo '.';
932
        }
933
    }
934
935
    /**
936
     * Validate the type of a value and coerce it into the proper type.
937
     *
938
     * @param mixed &$value The value to validate.
939
     * @param string $type One of: bool, int, string.
940
     * @param string $name The name of the option if you want to print an error message.
941
     * @param array|null $def The option def if you want to print an error message.
942
     * @return bool Returns `true` if the value is the correct type.
943
     * @throws \Exception Throws an exception when {@see $type} is not a known value.
944
     */
945 25
    protected function validateType(&$value, $type, $name = '', $def = null) {
946
        switch ($type) {
947 25
            case 'boolean':
948 15
                if (is_bool($value)) {
949 10
                    $valid = true;
950 15
                } elseif ($value === 0) {
951
                    // 0 doesn't work well with in_array() so check it separately.
952
                    $value = false;
953
                    $valid = true;
954 8
                } elseif (in_array($value, [null, '', '0', 'false', 'no', 'disabled'])) {
955 5
                    $value = false;
956 5
                    $valid = true;
957 8
                } elseif (in_array($value, [1, '1', 'true', 'yes', 'enabled'])) {
958 2
                    $value = true;
959 2
                    $valid = true;
960 2
                } else {
961 3
                    $valid = false;
962
                }
963 15
                break;
964 22
            case 'integer':
965 9
                if (is_numeric($value)) {
966 6
                    $value = (int)$value;
967 6
                    $valid = true;
968 6
                } else {
969 3
                    $valid = false;
970
                }
971 9
                break;
972 21
            case 'string':
973 21
                $value = (string)$value;
974 21
                $valid = true;
975 21
                break;
976
            default:
977
                throw new \Exception("Unknown type: $type.", 400);
978
        }
979
980 25
        if (!$valid && $name) {
981 6
            $short = static::val('short', (array)$def);
982 6
            $nameStr = "--$name".($short ? " (-$short)" : '');
983 6
            echo $this->red("The value of $nameStr is not a valid $type.".PHP_EOL);
984 6
        }
985
986 25
        return $valid;
987
    }
988
989
    /**
990
     * Writes a lis of all of the commands.
991
     */
992 2
    protected function writeCommands() {
993 2
        echo static::bold("COMMANDS").PHP_EOL;
994
995 2
        $table = new Table();
996 2
        foreach ($this->commandSchemas as $pattern => $schema) {
997 2
            if (static::isCommand($pattern)) {
998
                $table
999 2
                    ->row()
1000 2
                    ->cell($pattern)
1001 2
                    ->cell(Cli::val('description', Cli::val(Cli::META, $schema), ''));
1002 2
            }
1003 2
        }
1004 2
        $table->write();
1005 2
    }
1006
1007
    /**
1008
     * Writes the cli help.
1009
     *
1010
     * @param string $command The name of the command or blank if there is no command.
1011
     */
1012 4
    public function writeHelp($command = '') {
1013 4
        $schema = $this->getSchema($command);
1014 4
        $this->writeSchemaHelp($schema);
1015 4
    }
1016
1017
    /**
1018
     * Writes the help for a given schema.
1019
     *
1020
     * @param array $schema A command line scheme returned from {@see Cli::getSchema()}.
1021
     */
1022 4
    protected function writeSchemaHelp($schema) {
1023
        // Write the command description.
1024 4
        $meta = Cli::val(Cli::META, $schema, []);
1025 4
        $description = Cli::val('description', $meta);
1026
1027 4
        if ($description) {
1028 3
            echo implode("\n", Cli::breakLines($description, 80, false)).PHP_EOL.PHP_EOL;
1029 3
        }
1030
1031 4
        unset($schema[Cli::META]);
1032
1033
        // Add the help.
1034 4
        $schema['help'] = [
1035 4
            'description' => 'Display this help.',
1036 4
            'type' => 'boolean',
1037
            'short' => '?'
1038 4
        ];
1039
1040 4
        echo Cli::bold('OPTIONS').PHP_EOL;
1041
1042 4
        ksort($schema);
1043
1044 4
        $table = new Table();
1045 4
        $table->format = $this->formatOutput;
1046
1047 4
        foreach ($schema as $key => $definition) {
1048 4
            $table->row();
1049
1050
            // Write the keys.
1051 4
            $keys = "--{$key}";
1052 4
            if ($shortKey = Cli::val('short', $definition, false)) {
1053 4
                $keys .= ", -$shortKey";
1054 4
            }
1055 4
            if (Cli::val('required', $definition)) {
1056 2
                $table->bold($keys);
1057 2
            } else {
1058 4
                $table->cell($keys);
1059
            }
1060
1061
            // Write the description.
1062 4
            $table->cell(Cli::val('description', $definition, ''));
1063 4
        }
1064
1065 4
        $table->write();
1066 4
        echo PHP_EOL;
1067
1068 4
        $args = Cli::val(Cli::ARGS, $meta, []);
1069 4
        if (!empty($args)) {
1070 2
            echo Cli::bold('ARGUMENTS').PHP_EOL;
1071
1072 2
            $table = new Table();
1073 2
            $table->format = $this->formatOutput;
1074
1075 2
            foreach ($args as $argName => $arg) {
1076 2
                $table->row();
1077
1078 2
                if (Cli::val('required', $arg)) {
1079 1
                    $table->bold($argName);
1080 1
                } else {
1081 1
                    $table->cell($argName);
1082
                }
1083
1084 2
                $table->cell(Cli::val('description', $arg, ''));
1085 2
            }
1086 2
            $table->write();
1087 2
            echo PHP_EOL;
1088 2
        }
1089 4
    }
1090
1091
    /**
1092
     * Writes the basic usage information of the command.
1093
     *
1094
     * @param Args $args The parsed args returned from {@link Cli::parseRaw()}.
1095
     */
1096 6
    protected function writeUsage(Args $args) {
1097 6
        if ($filename = $args->getMeta('filename')) {
1098 6
            $schema = $this->getSchema($args->getCommand());
1099 6
            unset($schema[Cli::META]);
1100
1101 6
            echo static::bold("usage: ").$filename;
1102
1103 6
            if ($this->hasCommand()) {
1104 4
                if ($args->getCommand() && isset($this->commandSchemas[$args->getCommand()])) {
1105 2
                    echo ' '.$args->getCommand();
1106
1107 2
                } else {
1108 2
                    echo ' <command>';
1109
                }
1110 4
            }
1111
1112 6
            if ($this->hasOptions($args->getCommand())) {
1113 6
                echo " [<options>]";
1114 6
            }
1115
1116 6
            if ($hasArgs = $this->hasArgs($args->getCommand())) {
1117 4
                echo $hasArgs === 2 ? " <args>" : " [<args>]";
1118 4
            }
1119
1120 6
            echo PHP_EOL.PHP_EOL;
1121 6
        }
1122 6
    }
1123
1124
    /**
1125
     * Parse a schema in short form into a full schema array.
1126
     *
1127
     * @param array $arr The array to parse into a schema.
1128
     * @return array The full schema array.
1129
     * @throws \InvalidArgumentException Throws an exception when an item in the schema is invalid.
1130
     */
1131 1
    public static function parseSchema(array $arr) {
1132 1
        $result = [];
1133
1134 1
        foreach ($arr as $key => $value) {
1135 1
            if (is_int($key)) {
1136 1
                if (is_string($value)) {
1137
                    // This is a short param value.
1138 1
                    $param = static::parseShortParam($value);
1139 1
                    $name = $param['name'];
1140 1
                    $result[$name] = $param;
1141 1
                } else {
1142
                    throw new \InvalidArgumentException("Schema at position $key is not a valid param.", 500);
1143
                }
1144 1
            } else {
1145
                // The parameter is defined in the key.
1146 1
                $param = static::parseShortParam($key, $value);
1147 1
                $name = $param['name'];
1148
1149 1
                if (is_array($value)) {
1150
                    // The value describes a bit more about the schema.
1151
                    switch ($param['type']) {
1152
                        case 'array':
1153
                            if (isset($value['items'])) {
1154
                                // The value includes array schema information.
1155
                                $param = array_replace($param, $value);
1156
                            } else {
1157
                                // The value is a schema of items.
1158
                                $param['items'] = $value;
1159
                            }
1160
                            break;
1161
                        case 'object':
1162
                            // The value is a schema of the object.
1163
                            $param['properties'] = static::parseSchema($value);
1164
                            break;
1165
                        default:
1166
                            $param = array_replace($param, $value);
1167
                            break;
1168
                    }
1169 1
                } elseif (is_string($value)) {
1170 1
                    if ($param['type'] === 'array') {
1171
                        // Check to see if the value is the item type in the array.
1172
                        if (isset(self::$types[$value])) {
1173
                            $arrType = self::$types[$value];
1174
                        } elseif (($index = array_search($value, self::$types)) !== false) {
1175
                            $arrType = self::$types[$value];
1176
                        }
1177
1178
                        if (isset($arrType)) {
1179
                            $param['items'] = ['type' => $arrType];
1180
                        } else {
1181
                            $param['description'] = $value;
1182
                        }
1183
                    } else {
1184
                        // The value is the schema description.
1185 1
                        $param['description'] = $value;
1186
                    }
1187 1
                }
1188
1189 1
                $result[$name] = $param;
1190
            }
1191 1
        }
1192
1193 1
        return $result;
1194
    }
1195
1196
    /**
1197
     * Parse a short parameter string into a full array parameter.
1198
     *
1199
     * @param string $str The short parameter string to parse.
1200
     * @param array $other An array of other information that might help resolve ambiguity.
1201
     * @return array Returns an array in the form [name, [param]].
1202
     * @throws \InvalidArgumentException Throws an exception if the short param is not in the correct format.
1203
     */
1204 1
    protected static function parseShortParam($str, $other = []) {
1205
        // Is the parameter optional?
1206 1
        if (substr($str, -1) === '?') {
1207 1
            $required = false;
1208 1
            $str = substr($str, 0, -1);
1209 1
        } else {
1210 1
            $required = true;
1211
        }
1212
1213
        // Check for a type.
1214 1
        $parts = explode(':', $str);
1215
1216 1
        if (count($parts) === 1) {
1217 1
            if (isset($other['type'])) {
1218
                $type = $other['type'];
1219
            } else {
1220 1
                $type = 'string';
1221
            }
1222 1
            $name = $parts[0];
1223 1
        } else {
1224 1
            $name = $parts[1];
1225
1226 1
            if (isset(self::$types[$parts[0]])) {
1227 1
                $type = self::$types[$parts[0]];
1228 1
            } else {
1229
                throw new \InvalidArgumentException("Invalid type {$parts[1]} for field $name.", 500);
1230
            }
1231
1232 1
            if (isset($parts[2])) {
1233 1
                $short = $parts[2];
1234 1
            }
1235
        }
1236
1237 1
        $result = ['name' => $name, 'type' => $type, 'required' => $required];
1238
1239 1
        if (isset($short)) {
1240 1
            $result['short'] = $short;
1241 1
        }
1242
1243 1
        return $result;
1244
    }
1245
1246
    /**
1247
     * Safely get a value out of an array.
1248
     *
1249
     * This function uses optimizations found in the [facebook libphputil library](https://github.com/facebook/libphutil).
1250
     *
1251
     * @param string|int $key The array key.
1252
     * @param array $array The array to get the value from.
1253
     * @param mixed $default The default value to return if the key doesn't exist.
1254
     * @return mixed The item from the array or `$default` if the array key doesn't exist.
1255
     */
1256 33
    public static function val($key, array $array, $default = null) {
1257
        // isset() is a micro-optimization - it is fast but fails for null values.
1258 33
        if (isset($array[$key])) {
1259 32
            return $array[$key];
1260
        }
1261
1262
        // Comparing $default is also a micro-optimization.
1263 33
        if ($default === null || array_key_exists($key, $array)) {
1264 31
            return null;
1265
        }
1266
1267 9
        return $default;
1268
    }
1269
}
1270