Completed
Pull Request — master (#24)
by
unknown
06:24 queued 03:07
created

Cli   F

Complexity

Total Complexity 209

Size/Duplication

Total Lines 1279
Duplicated Lines 0 %

Test Coverage

Coverage 98.45%

Importance

Changes 0
Metric Value
dl 0
loc 1279
ccs 254
cts 258
cp 0.9845
rs 0.6314
c 0
b 0
f 0
wmc 209

46 Methods

Rating   Name   Duplication   Size   Complexity  
A setFormatOutput() 0 3 1
A writeCommands() 0 13 3
C opt() 0 25 7
A formatString() 0 5 2
A sleep() 0 4 2
A greenText() 0 2 1
A hasOptions() 0 12 4
A val() 0 12 4
A addArg() 0 6 3
A hasCommand() 0 10 4
C parseShortParam() 0 40 7
A __construct() 0 7 1
D hasArgs() 0 30 10
A bold() 0 2 1
A create() 0 2 1
C writeSchemaHelp() 0 66 8
A command() 0 7 2
A getSchema() 0 8 3
A blue() 0 2 1
A boldText() 0 2 1
B isStrictBoolean() 0 13 5
A description() 0 2 1
A redText() 0 2 1
D parseRaw() 0 191 41
D validate() 0 82 19
A __get() 0 6 2
A breakLines() 0 11 2
A meta() 0 9 3
A red() 0 2 1
C validateType() 0 42 12
A __set() 0 4 2
A isCommand() 0 2 1
A schema() 0 4 1
A green() 0 2 1
C breakString() 0 42 8
C parseSchema() 0 63 13
A getFormatOutput() 0 2 1
A arg() 0 4 1
A purpleText() 0 2 1
A writeHelp() 0 3 1
C writeUsage() 0 25 8
A blueText() 0 2 1
A guessFormatOutput() 0 7 3
A hasOptionsDef() 0 2 3
A purple() 0 2 1
D parse() 0 39 10

How to fix   Complexity   

Complex Class

Complex classes like Cli often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Cli, and based on these observations, apply Extract Interface, too.

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',
36
        'i' => 'integer',
37
        's' => 'string',
38
//        'f' => 'float',
39
        'b' => 'boolean',
40
//        'ts' => 'timestamp',
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 41
    public function __construct() {
51 41
        $this->commandSchemas = ['*' => [Cli::META => []]];
52
53
        // Select the current schema.
54 41
        $this->currentSchema =& $this->commandSchemas['*'];
55
56
        $this->formatOutput = static::guessFormatOutput();
57 41
    }
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 1
    public function __get($name) {
66 1
        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 1
    public function __set($name, $value) {
80 1
        if ($name === 'format') {
81
            trigger_error("Cli->format is deprecated. Use Cli->setFormatOutput() instead.", E_USER_DEPRECATED);
82
            $this->setFormatOutput($value);
83
        }
84 1
    }
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 $this
100
     */
101 2
    public function setFormatOutput($formatOutput) {
102 2
        $this->formatOutput = $formatOutput;
103 2
        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 mixed $arg The value of the argument.
112
     */
113 1
    private function addArg(array $schema, Args $args, $arg) {
114
        $argsCount = count($args->getArgs());
115 1
        $schemaArgs = isset($schema[self::META][self::ARGS]) ? array_keys($schema[self::META][self::ARGS]) : [];
116 1
        $name = isset($schemaArgs[$argsCount]) ? $schemaArgs[$argsCount] : $argsCount;
117
118
        $args->addArg($arg, $name);
119
    }
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
     * @param array $args The constructor arguments, if any.
127
     * @return static Returns a new Cli object.
128
     */
129
    public static function create(...$args) {
130
        return new static(...$args);
0 ignored issues
show
Unused Code introduced by
The call to Garden\Cli\Cli::__construct() has too many arguments starting with $args. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

130
        return /** @scrutinizer ignore-call */ new static(...$args);

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
131
    }
132
133
    /**
134
     * Breaks a cell into several lines according to a given width.
135
     *
136
     * @param string $text The text of the cell.
137
     * @param int $width The width of the cell.
138
     * @param bool $addSpaces Whether or not to right-pad the cell with spaces.
139
     * @return array Returns an array of strings representing the lines in the cell.
140
     */
141
    public static function breakLines($text, $width, $addSpaces = true) {
142
        $rawLines = explode("\n", $text);
143
        $lines = [];
144
145
        foreach ($rawLines as $line) {
146
            // Check to see if the line needs to be broken.
147
            $sublines = static::breakString($line, $width, $addSpaces);
148
            $lines = array_merge($lines, $sublines);
149
        }
150
151
        return $lines;
152
    }
153
154
    /**
155
     * Breaks a line of text according to a given width.
156
     *
157
     * @param string $line The text of the line.
158
     * @param int $width The width of the cell.
159
     * @param bool $addSpaces Whether or not to right pad the lines with spaces.
160
     * @return array Returns an array of lines, broken on word boundaries.
161
     */
162
    protected static function breakString($line, $width, $addSpaces = true) {
163
        $words = explode(' ', $line);
164
        $result = [];
165
166
        $line = '';
167
        foreach ($words as $word) {
168
            $candidate = trim($line.' '.$word);
169
170
            // Check for a new line.
171
            if (strlen($candidate) > $width) {
172
                if ($line === '') {
173
                    // The word is longer than a line.
174
                    if ($addSpaces) {
175
                        $result[] = substr($candidate, 0, $width);
176
                    } else {
177
                        $result[] = $candidate;
178
                    }
179
                } else {
180
                    if ($addSpaces) {
181
                        $line .= str_repeat(' ', $width - strlen($line));
182
                    }
183
184
                    // Start a new line.
185
                    $result[] = $line;
186
                    $line = $word;
187
                }
188
            } else {
189
                $line = $candidate;
190
            }
191
        }
192
193
        // Add the remaining line.
194
        if ($line) {
195
            if ($addSpaces) {
196
                $line .= str_repeat(' ', $width - strlen($line));
197
            }
198
199
            // Start a new line.
200
            $result[] = $line;
201
        }
202
203
        return $result;
204
    }
205
206
    /**
207
     * Sets the description for the current command.
208
     *
209
     * @param string $str The description for the current schema or null to get the current description.
210
     * @return $this
211
     */
212
    public function description($str = null) {
213
        return $this->meta('description', $str);
214
    }
215
216
    /**
217
     * Determines whether or not the schema has a command.
218
     *
219
     * @param string $name Check for the specific command name.
220
     * @return bool Returns true if the schema has a command.
221
     */
222
    public function hasCommand($name = '') {
223
        if ($name) {
224
            return array_key_exists($name, $this->commandSchemas);
225
        } else {
226
            foreach ($this->commandSchemas as $pattern => $opts) {
227
                if (strpos($pattern, '*') === false) {
228
                    return true;
229
                }
230
            }
231
            return false;
232
        }
233
    }
234
235
    /**
236
     * Determines whether a command has options.
237
     *
238
     * @param string $command The name of the command or an empty string for any command.
239
     * @return bool Returns true if the command has options. False otherwise.
240 6
     */
241 6
    public function hasOptions($command = '') {
242
        if ($command) {
243
            $def = $this->getSchema($command);
244
            return $this->hasOptionsDef($def);
245 4
        } else {
246
            foreach ($this->commandSchemas as $pattern => $def) {
247 4
                if ($this->hasOptionsDef($def)) {
248
                    return true;
249 3
                }
250
            }
251
        }
252
        return false;
253
    }
254
255
    /**
256
     * Determines whether or not a command definition has options.
257
     *
258
     * @param array $commandDef The command definition as returned from {@link Cli::getSchema()}.
259
     * @return bool Returns true if the command def has options or false otherwise.
260
     */
261
    protected function hasOptionsDef($commandDef) {
262
        return count($commandDef) > 1 || (count($commandDef) > 0 && !isset($commandDef[Cli::META]));
263
    }
264
265
    /**
266
     * Determines whether or not a command has args.
267
     *
268
     * @param string $command The command name to check.
269
     * @return int Returns one of the following.
270
     * - 0: The command has no args.
271
     * - 1: The command has optional args.
272
     * - 2: The command has required args.
273 6
     */
274 6
    public function hasArgs($command = '') {
275
        $args = null;
276 6
277
        if ($command) {
278
            // Check to see if the specific command has args.
279 2
            $def = $this->getSchema($command);
280 2
            if (isset($def[Cli::META][Cli::ARGS])) {
281
                $args = $def[Cli::META][Cli::ARGS];
282
            }
283 4
        } else {
284 2
            foreach ($this->commandSchemas as $pattern => $def) {
285 2
                if (isset($def[Cli::META][Cli::ARGS])) {
286
                    $args = $def[Cli::META][Cli::ARGS];
287
                }
288 4
            }
289 2
            if (!empty($args)) {
290
                return 1;
291 2
            }
292
        }
293 4
294 2
        if (!$args || empty($args)) {
295
            return 0;
296
        }
297
298
        foreach ($args as $arg) {
299 1
            if (!Cli::val('required', $arg)) {
300
                return 1;
301 1
            }
302 1
        }
303
        return 2;
304
    }
305
306
    /**
307
     * Finds our whether a pattern is a command.
308
     *
309
     * @param string $pattern The pattern being evaluated.
310
     * @return bool Returns `true` if `$pattern` is a command, `false` otherwise.
311
     */
312
    public static function isCommand($pattern) {
313
        return strpos($pattern, '*') === false;
314
    }
315
316
    /**
317
     * Parses and validates a set of command line arguments the schema.
318
     *
319
     * @param array $argv The command line arguments a form compatible with the global `$argv` variable.
320
     *
321
     * Note that the `$argv` array must have at least one element and it must represent the path to the command that
322
     * invoked the command. This is used to write usage information.
323
     * @param bool $exit Whether to exit the application when there is an error or when writing help.
324
     * @return Args|null Returns an {@see Args} instance when a command should be executed
325
     * or `null` when one should not be executed.
326
     * @throws \Exception Throws an exception when {@link $exit} is false and the help or errors need to be displayed.
327 39
     */
328 39
    public function parse($argv = null, $exit = true) {
329
        $formatOutputBak = $this->formatOutput;
330 39
        // Only format commands if we are exiting.
331 38
        if (!$exit) {
332
            $this->formatOutput = false;
333 39
        }
334
        if (!$exit) {
335
            ob_start();
336
        }
337
338
        $args = $this->parseRaw($argv);
339
340
        $hasCommand = $this->hasCommand();
341 35
342
        if ($hasCommand && !$args->getCommand()) {
343
            // If no command is given then write a list of commands.
344
            $this->writeUsage($args);
345 2
            $this->writeCommands();
346
            $result = null;
347
        } elseif ($args->getOpt('help') || $args->getOpt('?')) {
348
            // Write the help.
349
            $this->writeUsage($args);
350 4
            $this->writeHelp($args->getCommand());
351
            $result = null;
352
        } else {
353
            // Validate the arguments against the schema.
354 33
            $validArgs = $this->validate($args);
355 6
            $result = $validArgs;
356 39
        }
357 38
        if (!$exit) {
358
            $this->formatOutput = $formatOutputBak;
359 38
            $output = ob_get_clean();
360
            if ($result === null) {
361
                throw new \Exception(trim($output));
362 1
            }
363
        } elseif ($result === null) {
364 24
            exit();
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

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

Loading history...
365 25
        }
366 1
        return $result;
367
    }
368
369
    /**
370
     * Parse an array of arguments.
371
     *
372
     * If the first item in the array is in the form of a command (no preceding - or --),
373
     * 'command' is filled with its value.
374
     *
375
     * @param array $argv An array of arguments passed in a form compatible with the global `$argv` variable.
376
     * @return Args Returns the raw parsed arguments.
377
     * @throws \Exception Throws an exception when {@see $argv} isn't an array.
378 39
     */
379 39
    protected function parseRaw($argv = null) {
380
        if ($argv === null) {
381
            $argv = $GLOBALS['argv'];
382
        }
383
384
        if (!is_array($argv)) {
385
            throw new \Exception(__METHOD__ . " expects an array", 400);
386
        }
387
388
        $path = array_shift($argv);
389
        $hasCommand = $this->hasCommand();
390
391
        $parsed = new Args();
392
        $parsed->setMeta('path', $path);
393
        $parsed->setMeta('filename', basename($path));
394
395
        if (count($argv)) {
396
            // Get possible command.
397
            if (substr($argv[0], 0, 1) != '-') {
398 4
                $arg0 = array_shift($argv);
399
                if ($hasCommand) {
400
                    $parsed->setCommand($arg0);
401
                } else {
402
                    $schema = $this->getSchema($parsed->getCommand());
403 2
                    $this->addArg($schema, $parsed, $arg0);
404
                }
405
            }
406 38
            // Get the data types for all of the commands.
407
            if (!isset($schema)) {
408
                $schema = $this->getSchema($parsed->getCommand());
409
            }
410 38
411
            $types = [];
412 2
            foreach ($schema as $sName => $sRow) {
413 38
                if ($sName === Cli::META) {
414
                    continue;
415
                }
416
417 8
                $type = Cli::val('type', $sRow, 'string');
418 8
                $types[$sName] = $type;
419 9
                if (isset($sRow['short'])) {
420
                    $types[$sRow['short']] = $type;
421
                }
422
            }
423
424
            // Parse opts.
425 23
            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...
426
                $str = $argv[$i];
427
428
                // Parse the help command as a boolean since it is not part of the schema!
429
                if (in_array($str, ['-?', '--help'])) {
430 6
                    $parsed->setOpt('help', true);
431
                    continue;
432
                }
433 17
434
                if ($str === '--') {
435
                    // --
436
                    $i++;
437
                    break;
438
                } elseif (strlen($str) > 2 && substr($str, 0, 2) == '--') {
439
                    // --foo
440
                    $str = substr($str, 2);
441 12
                    $parts = explode('=', $str);
442 12
                    $key = $parts[0];
443
                    $v = null;
444
445
                    // Has a =, so pick the second piece
446 7
                    if (count($parts) == 2) {
447
                        $v = $parts[1];
448
                    // Does not have an =
449
                    } else {
450 1
                        // If there is a value (even if there are no equals)
451
                        if (isset($argv[$i + 1]) && preg_match('/^--?.+/', $argv[$i + 1]) == 0) {
452 6
                            // so choose the next arg as its value if any,
453
                            $v = $argv[$i + 1];
454
                            // If this is a boolean we need to coerce the value
455
                            if (Cli::val($key, $types) === 'boolean') {
456
                                if (in_array($v, ['0', '1', 'true', 'false', 'on', 'off', 'yes', 'no'])) {
457 1
                                    // The next arg looks like a boolean to me.
458
                                    $i++;
459
                                } else {
460
                                    // Next arg is not a boolean: set the flag on, and use next arg in its own iteration
461 1
                                    $v = true;
462
                                }
463 5
                            } else {
464 1
                                $i++;
465
                            }
466
                        // If there is no value but we have a no- before the command
467
                        } elseif (strpos($key, 'no-') === 0) {
468
                            $tmpKey = str_replace('no-', null, $key);
469 1
                            if (Cli::val($tmpKey, $types) === 'boolean') {
470 1
                                $key = $tmpKey;
471
                                $v = false;
472
                            }
473 2
                        } elseif (Cli::val($key, $types) === 'boolean') {
474 5
                            $v = true;
475 7
                        }
476
                    }
477
                    $parsed->setOpt($key, $v);
478
                } elseif (strlen($str) == 2 && $str[0] == '-') {
479
                    // -a
480 8
481
                    $key = $str[1];
482 8
                    $type = Cli::val($key, $types, 'boolean');
483
                    $v = null;
484 8
485
                    if (isset($argv[$i + 1])) {
486 5
                        // Try and be smart about the next arg.
487
                        $nextArg = $argv[$i + 1];
488 5
489
                        if ($type === 'boolean') {
490
                            if ($this->isStrictBoolean($nextArg)) {
491 1
                                // The next arg looks like a boolean to me.
492 1
                                $v = $nextArg;
493
                                $i++;
494 2
                            } else {
495 1
                                $v = true;
496
                            }
497
                        } elseif (!preg_match('/^--?.+/', $argv[$i + 1])) {
498 4
                            // The next arg is not an opt.
499 4
                            $v = $nextArg;
500
                            $i++;
501
                        } else {
502
                            // The next arg is another opt.
503 5
                            $v = null;
504
                        }
505
                    }
506 8
507
                    if ($v === null) {
508
                        $v = Cli::val($type, ['boolean' => true, 'integer' => 1, 'string' => '']);
509
                    }
510
511
                    $parsed->setOpt($key, $v);
512
                } elseif (strlen($str) > 1 && $str[0] == '-') {
513
                    // -abcdef
514 11
                    for ($j = 1; $j < strlen($str); $j++) {
515
                        $opt = $str[$j];
516
                        $remaining = substr($str, $j + 1);
517
                        $type = Cli::val($opt, $types, 'boolean');
518
519
                        // Check for an explicit equals sign.
520
                        if (substr($remaining, 0, 1) === '=') {
521 3
                            $remaining = substr($remaining, 1);
522
                            if ($type === 'boolean') {
523
                                // Bypass the boolean flag checking below.
524
                                $parsed->setOpt($opt, $remaining);
525
                                break;
526
                            }
527
                        }
528 11
529
                        if ($type === 'boolean') {
530
                            if (preg_match('`^([01])`', $remaining, $matches)) {
531
                                // Treat the 0 or 1 as a true or false.
532
                                $parsed->setOpt($opt, $matches[1]);
533
                                $j += strlen($matches[1]);
534
                            } else {
535
                                // Treat the option as a flag.
536 1
                                $parsed->setOpt($opt, true);
537 13
                            }
538
                        } elseif ($type === 'string') {
539
                            // Treat the option as a set with no = sign.
540 13
                            $parsed->setOpt($opt, $remaining);
541 1
                            break;
542
                        } elseif ($type === 'integer') {
543
                            if (preg_match('`^(\d+)`', $remaining, $matches)) {
544
                                // Treat the option as a set with no = sign.
545
                                $parsed->setOpt($opt, $matches[1]);
546
                                $j += strlen($matches[1]);
547
                            } else {
548
                                // Treat the option as either multiple flags.
549
                                $optVal = $parsed->getOpt($opt, 0);
550 2
                                $parsed->setOpt($opt, $optVal + 1);
551
                            }
552
                        } else {
553
                            // This should not happen unless we've put a bug in our code.
554 1
                            throw new \Exception("Invalid type $type for $opt.", 500);
555 10
                        }
556
                    }
557
                } else {
558 1
                    // End of opts
559 16
                    break;
560 1
                }
561
            }
562
563
            // Grab the remaining args.
564
            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...
565 1
                $this->addArg($schema, $parsed, $argv[$i]);
566
            }
567
        }
568 39
569
        return $parsed;
570
    }
571
572
    /**
573
     * Validates arguments against the schema.
574
     *
575
     * @param Args $args The arguments that were returned from {@link Cli::parseRaw()}.
576
     * @return Args|null
577 33
     */
578 33
    public function validate(Args $args) {
579
        $isValid = true;
580
        $command = $args->getCommand();
581
        $valid = new Args($command);
582
        $schema = $this->getSchema($command);
583
        ksort($schema);
584
585 33
//        $meta = $schema[Cli::META];
586
        unset($schema[Cli::META]);
587 33
        $opts = $args->getOpts();
588
        $missing = [];
589
590 33
        // Check to see if the command is correct.
591
        if ($command && !$this->hasCommand($command) && $this->hasCommand()) {
592
            echo $this->red("Invalid command: $command.".PHP_EOL);
593
            $isValid = false;
594
        }
595
596
        // Add the args.
597
        $valid->setArgs($args->getArgs());
598
599
        foreach ($schema as $key => $definition) {
600
            // No Parameter (default)
601
            $type = Cli::val('type', $definition, 'string');
602
603
            if (array_key_exists($key, $opts)) {
604 10
                // Check for --key.
605
                $value = $opts[$key];
606
                if ($this->validateType($value, $type, $key, $definition)) {
607
                    $valid->setOpt($key, $value);
608 3
                } else {
609 7
                    $isValid = false;
610 10
                }
611
                unset($opts[$key]);
612
            } elseif (isset($definition['short']) && array_key_exists($definition['short'], $opts)) {
613 13
                // Check for -s.
614
                $value = $opts[$definition['short']];
615
                if ($this->validateType($value, $type, $key, $definition)) {
616
                    $valid->setOpt($key, $value);
617 2
                } else {
618 13
                    $isValid = false;
619 13
                }
620
                unset($opts[$definition['short']]);
621
            } elseif (array_key_exists('no-'.$key, $opts)) {
622 2
                // Check for --no-key.
623
                $value = $opts['no-'.$key];
624 2
625
                if ($type !== 'boolean') {
626 1
                    echo $this->red("Cannot apply the --no- prefix on the non boolean --$key.".PHP_EOL);
627
                    $isValid = false;
628
                } elseif ($this->validateType($value, $type, $key, $definition)) {
629
                    $valid->setOpt($key, !$value);
630 1
                } else {
631 1
                    $isValid = false;
632 2
                }
633 4
                unset($opts['no-'.$key]);
634
            } elseif ($definition['required']) {
635 1
                // The key was not supplied. Is it required?
636
                $missing[$key] = true;
637 13
                $valid->setOpt($key, false);
638 1
            }
639
        }
640
641 2
        if (count($missing)) {
642
            $isValid = false;
643
            foreach ($missing as $key => $v) {
644
                echo $this->red("Missing required option: $key".PHP_EOL);
645
            }
646
        }
647
648
        if (count($opts)) {
649
            $isValid = false;
650
            foreach ($opts as $key => $v) {
651
                echo $this->red("Invalid option: $key".PHP_EOL);
652
            }
653
        }
654 33
655 25
        if ($isValid) {
656
            return $valid;
657 8
        } else {
658 8
            echo PHP_EOL;
659
            return null;
660 33
        }
661
    }
662
663
    /**
664
     * Gets the full cli schema.
665
     *
666
     * @param string $command The name of the command. This can be left blank if there is no command.
667
     * @return array Returns the schema that matches the command.
668 2
     */
669 2
    public function getSchema($command = '') {
670 2
        $result = [];
671
        foreach ($this->commandSchemas as $pattern => $opts) {
672
            if (fnmatch($pattern, $command)) {
673
                $result = array_replace_recursive($result, $opts);
674
            }
675 2
        }
676
        return $result;
677
    }
678
679
    /**
680
     * Gets/sets the value for a current meta item.
681
     *
682
     * @param string $name The name of the meta key.
683
     * @param mixed $value Set a new value for the meta key.
684
     * @return $this|mixed Returns the current value of the meta item or `$this` for fluent setting.
685 6
     */
686 6
    public function meta($name, $value = null) {
687 6
        if ($value !== null) {
688 6
            $this->currentSchema[Cli::META][$name] = $value;
689
            return $this;
690
        }
691
        if (!isset($this->currentSchema[Cli::META][$name])) {
692
            return null;
693
        }
694
        return $this->currentSchema[Cli::META][$name];
695
    }
696
697
    /**
698
     * Adds an option (opt) to the current schema.
699
     *
700
     * @param string $name The long name(s) of the parameter.
701
     * You can use either just one name or a string in the form 'long:short' to specify the long and short name.
702
     * @param string $description A human-readable description for the column.
703
     * @param bool $required Whether or not the opt is required.
704
     * @param string $type The type of parameter.
705
     * This must be one of string, bool, integer.
706
     * @return $this
707
     * @throws \Exception Throws an exception when the type is invalid.
708 33
     */
709
    public function opt($name, $description, $required = false, $type = 'string') {
710 9
        switch ($type) {
711 9
            case 'str':
712 33
            case 'string':
713 33
                $type = 'string';
714 3
                break;
715 3
            case 'bool':
716
            case 'boolean':
717
                $type = 'boolean';
718 22
                break;
719 22
            case 'int':
720 22
            case 'integer':
721 22
                $type = 'integer';
722
                break;
723
            default:
724
                throw new \Exception("Invalid type: $type. Must be one of string, boolean, or integer.", 422);
725
        }
726
727
        // Break the name up into its long and short form.
728 9
        $parts = explode(':', $name, 2);
729
        $long = $parts[0];
730
        $short = static::val(1, $parts, '');
731 9
732 9
        $this->currentSchema[$long] = ['description' => $description, 'required' => $required, 'type' => $type, 'short' => $short];
733 5
        return $this;
734
    }
735
736
    /**
737
     * Define an arg on the current command.
738
     *
739
     * @param string $name The name of the arg.
740
     * @param string $description The arg description.
741
     * @param bool $required Whether or not the arg is required.
742
     * @return $this
743 4
     */
744 4
    public function arg($name, $description, $required = false) {
745 4
        $this->currentSchema[Cli::META][Cli::ARGS][$name] =
746 4
            ['description' => $description, 'required' => $required];
747
        return $this;
748
    }
749
750
    /**
751
     * Selects the current command schema name.
752
     *
753
     * @param string $pattern The command pattern.
754
     * @return $this
755 2
     */
756 2
    public function command($pattern) {
757 2
        if (!isset($this->commandSchemas[$pattern])) {
758
            $this->commandSchemas[$pattern] = [Cli::META => []];
759 2
        }
760
        $this->currentSchema =& $this->commandSchemas[$pattern];
761 2
762
        return $this;
763
    }
764
765
766
    /**
767
     * Determine weather or not a value can be represented as a boolean.
768
     *
769
     * This method is sort of like {@link Cli::validateType()} but requires a more strict check of a boolean value.
770
     *
771
     * @param mixed $value The value to test.
772
     * @return bool
773 2
     */
774 1
    protected function isStrictBoolean($value, &$boolValue = null) {
775
        if ($value === true || $value === false) {
776
            $boolValue = $value;
777
            return true;
778 1
        } elseif (in_array($value, ['0', 'false', 'off', 'no'])) {
779 1
            $boolValue = false;
780
            return true;
781
        } elseif (in_array($value, ['1', 'true', 'on', 'yes'])) {
782
            $boolValue = true;
783
            return true;
784 2
        } else {
785 2
            $boolValue = null;
786
            return false;
787 1
        }
788
    }
789
790
    /**
791
     * Set the schema for a command.
792
     *
793
     * The schema array uses a short syntax so that commands can be specified as quickly as possible.
794
     * This schema is the exact same as those provided to {@link Schema::create()}.
795
     * The basic format of the array is the following:
796
     *
797
     * ```
798
     * [
799
     *     type:name[:shortCode][?],
800
     *     type:name[:shortCode][?],
801
     *     ...
802
     * ]
803
     * ```
804
     *
805
     * @param array $schema The schema array.
806
     */
807
    public function schema(array $schema) {
808
        $parsed = static::parseSchema($schema);
809
810
        $this->currentSchema = array_replace($this->currentSchema, $parsed);
811
    }
812
813
    /**
814
     * Bold some text.
815
     *
816
     * @param string $text The text to format.
817
     * @return string Returns the text surrounded by formatting commands.
818
     */
819
    public function bold($text) {
820
        return $this->formatString($text, ["\033[1m", "\033[0m"]);
821
    }
822
823
    /**
824
     * Bold some text.
825
     *
826
     * @param string $text The text to format.
827
     * @return string Returns the text surrounded by formatting commands.
828
     */
829
    public static function boldText($text) {
830
        return "\033[1m{$text}\033[0m";
831
    }
832
833
    /**
834
     * Make some text red.
835
     *
836
     * @param string $text The text to format.
837
     * @return string Returns  text surrounded by formatting commands.
838
     */
839
    public function red($text) {
840
        return $this->formatString($text, ["\033[1;31m", "\033[0m"]);
841
    }
842
843
    /**
844
     * Make some text red.
845
     *
846
     * @param string $text The text to format.
847
     * @return string Returns  text surrounded by formatting commands.
848 1
     */
849 1
    public static function redText($text) {
850
        return "\033[1;31m{$text}\033[0m";
851
    }
852
853
    /**
854
     * Make some text green.
855
     *
856
     * @param string $text The text to format.
857
     * @return string Returns  text surrounded by formatting commands.
858
     */
859
    public function green($text) {
860
        return $this->formatString($text, ["\033[1;32m", "\033[0m"]);
861
    }
862
863
    /**
864
     * Make some text green.
865
     *
866
     * @param string $text The text to format.
867
     * @return string Returns  text surrounded by formatting commands.
868 1
     */
869 1
    public static function greenText($text) {
870
        return "\033[1;32m{$text}\033[0m";
871
    }
872
873
    /**
874
     * Make some text blue.
875
     *
876
     * @param string $text The text to format.
877
     * @return string Returns  text surrounded by formatting commands.
878
     */
879
    public function blue($text) {
880
        return $this->formatString($text, ["\033[1;34m", "\033[0m"]);
881
    }
882
883
    /**
884
     * Make some text blue.
885
     *
886
     * @param string $text The text to format.
887
     * @return string Returns  text surrounded by formatting commands.
888
     */
889
    public static function blueText($text) {
890
        return "\033[1;34m{$text}\033[0m";
891
    }
892
893
    /**
894
     * Make some text purple.
895
     *
896
     * @param string $text The text to format.
897
     * @return string Returns  text surrounded by formatting commands.
898
     */
899
    public function purple($text) {
900
        return $this->formatString($text, ["\033[0;35m", "\033[0m"]);
901
    }
902
903
    /**
904
     * Make some text purple.
905
     *
906
     * @param string $text The text to format.
907
     * @return string Returns  text surrounded by formatting commands.
908
     */
909
    public static function purpleText($text) {
910
        return "\033[0;35m{$text}\033[0m";
911
    }
912
913
    /**
914
     * Format some text for the console.
915
     *
916
     * @param string $text The text to format.
917
     * @param string[] $wrap The format to wrap in the form ['before', 'after'].
918
     * @return string Returns the string formatted according to {@link Cli::$format}.
919 8
     */
920 8
    protected function formatString($text, array $wrap) {
921 1
        if ($this->formatOutput) {
922
            return "{$wrap[0]}$text{$wrap[1]}";
923 7
        } else {
924
            return $text;
925 8
        }
926
    }
927
928
    /**
929
     * Guess whether or not to format the output with colors.
930
     *
931
     * If the current environment is being redirected to a file then output should not be formatted. Also, Windows
932
     * machines do not support terminal colors so formatting should be suppressed on them too.
933
     *
934
     * @return bool Returns **true** if the output can be formatter or **false** otherwise.
935
     */
936
    public static function guessFormatOutput() {
937
        if (defined('PHP_WINDOWS_VERSION_MAJOR')) {
938
            return false;
939
        } elseif (function_exists('posix_isatty')) {
940
            return posix_isatty(STDOUT);
941
        } else {
942
            return true;
943
        }
944
    }
945
946
    /**
947
     * Sleep for a number of seconds, echoing out a dot on each second.
948
     *
949
     * @param int $seconds The number of seconds to sleep.
950
     */
951
    public static function sleep($seconds) {
952
        for ($i = 0; $i < $seconds; $i++) {
953
            sleep(1);
954
            echo '.';
955
        }
956
    }
957
958
    /**
959
     * Validate the type of a value and coerce it into the proper type.
960
     *
961
     * @param mixed &$value The value to validate.
962
     * @param string $type One of: bool, int, string.
963
     * @param string $name The name of the option if you want to print an error message.
964
     * @param array|null $def The option def if you want to print an error message.
965
     * @return bool Returns `true` if the value is the correct type.
966
     * @throws \Exception Throws an exception when {@see $type} is not a known value.
967 26
     */
968
    protected function validateType(&$value, $type, $name = '', $def = null) {
969 14
        switch ($type) {
970
            case 'boolean':
971 6
                if (is_bool($value)) {
972 6
                    $valid = true;
973
                } elseif ($value === 0) {
974
                    // 0 doesn't work well with in_array() so check it separately.
975
                    $value = false;
976
                    $valid = true;
977 5
                } elseif (in_array($value, [null, '', '0', 'false', 'no', 'disabled'])) {
978 5
                    $value = false;
979
                    $valid = true;
980 2
                } elseif (in_array($value, [1, '1', 'true', 'yes', 'enabled'])) {
981 2
                    $value = true;
982
                    $valid = true;
983 3
                } else {
984 3
                    $valid = false;
985 6
                }
986 19
                break;
987
            case 'integer':
988 6
                if (is_numeric($value)) {
989 6
                    $value = (int)$value;
990
                    $valid = true;
991 3
                } else {
992 6
                    $valid = false;
993 9
                }
994 26
                break;
995 26
            case 'string':
996 26
                $value = (string)$value;
997 26
                $valid = true;
998
                break;
999
            default:
1000
                throw new \Exception("Unknown type: $type.", 400);
1001
        }
1002 14
1003
        if (!$valid && $name) {
1004 6
            $short = static::val('short', (array)$def);
1005
            $nameStr = "--$name".($short ? " (-$short)" : '');
1006
            echo $this->red("The value of $nameStr is not a valid $type.".PHP_EOL);
1007
        }
1008 14
1009
        return $valid;
1010
    }
1011
1012
    /**
1013
     * Writes a lis of all of the commands.
1014 2
     */
1015
    protected function writeCommands() {
1016
        echo static::bold("COMMANDS").PHP_EOL;
0 ignored issues
show
Bug Best Practice introduced by
The method Garden\Cli\Cli::bold() is not static, but was called statically. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

1016
        echo static::/** @scrutinizer ignore-call */ bold("COMMANDS").PHP_EOL;
Loading history...
1017
1018 2
        $table = new Table();
1019
        foreach ($this->commandSchemas as $pattern => $schema) {
1020
            if (static::isCommand($pattern)) {
1021 1
                $table
1022 1
                    ->row()
1023
                    ->cell($pattern)
1024
                    ->cell(Cli::val('description', Cli::val(Cli::META, $schema), ''));
1025
            }
1026
        }
1027 2
        $table->write();
1028
    }
1029
1030
    /**
1031
     * Writes the cli help.
1032
     *
1033
     * @param string $command The name of the command or blank if there is no command.
1034 4
     */
1035
    public function writeHelp($command = '') {
1036
        $schema = $this->getSchema($command);
1037 4
        $this->writeSchemaHelp($schema);
1038
    }
1039
1040
    /**
1041
     * Writes the help for a given schema.
1042
     *
1043
     * @param array $schema A command line scheme returned from {@see Cli::getSchema()}.
1044 5
     */
1045
    protected function writeSchemaHelp($schema) {
1046
        // Write the command description.
1047
        $meta = Cli::val(Cli::META, $schema, []);
1048
        $description = Cli::val('description', $meta);
1049 5
1050
        if ($description) {
1051
            echo implode("\n", Cli::breakLines($description, 80, false)).PHP_EOL.PHP_EOL;
1052
        }
1053 5
1054
        unset($schema[Cli::META]);
1055
1056
        // Add the help.
1057
        $schema['help'] = [
1058
            'description' => 'Display this help.',
1059
            'type' => 'boolean',
1060 5
            'short' => '?'
1061
        ];
1062
1063
        echo Cli::bold('OPTIONS').PHP_EOL;
0 ignored issues
show
Bug Best Practice introduced by
The method Garden\Cli\Cli::bold() is not static, but was called statically. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

1063
        echo Cli::/** @scrutinizer ignore-call */ bold('OPTIONS').PHP_EOL;
Loading history...
1064
1065
        ksort($schema);
1066
1067
        $table = new Table();
1068
        $table->setFormatOutput($this->formatOutput);
1069
1070
        foreach ($schema as $key => $definition) {
1071
            $table->row();
1072
1073
            // Write the keys.
1074
            $keys = "--{$key}";
1075 1
            if ($shortKey = Cli::val('short', $definition, false)) {
1076
                $keys .= ", -$shortKey";
1077
            }
1078
            if (Cli::val('required', $definition)) {
1079
                $table->bold($keys);
1080
            } else {
1081 2
                $table->cell($keys);
1082
            }
1083
1084
            // Write the description.
1085
            $table->cell(Cli::val('description', $definition, ''));
1086
        }
1087
1088 5
        $table->write();
1089
        echo PHP_EOL;
1090
1091 5
        $args = Cli::val(Cli::ARGS, $meta, []);
1092
        if (!empty($args)) {
1093
            echo Cli::bold('ARGUMENTS').PHP_EOL;
1094
1095
            $table = new Table();
1096
            $table->setFormatOutput($this->formatOutput);
1097
1098
            foreach ($args as $argName => $arg) {
1099
                $table->row();
1100
1101
                if (Cli::val('required', $arg)) {
1102
                    $table->bold($argName);
1103
                } else {
1104 1
                    $table->cell($argName);
1105
                }
1106
1107
                $table->cell(Cli::val('description', $arg, ''));
1108
            }
1109 2
            $table->write();
1110
            echo PHP_EOL;
1111 5
        }
1112
    }
1113
1114
    /**
1115
     * Writes the basic usage information of the command.
1116
     *
1117
     * @param Args $args The parsed args returned from {@link Cli::parseRaw()}.
1118 6
     */
1119
    protected function writeUsage(Args $args) {
1120
        if ($filename = $args->getMeta('filename')) {
1121
            $schema = $this->getSchema($args->getCommand());
1122
            unset($schema[Cli::META]);
1123
1124
            echo static::bold("usage: ").$filename;
0 ignored issues
show
Bug Best Practice introduced by
The method Garden\Cli\Cli::bold() is not static, but was called statically. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

1124
            echo static::/** @scrutinizer ignore-call */ bold("usage: ").$filename;
Loading history...
1125
1126
            if ($this->hasCommand()) {
1127
                if ($args->getCommand() && isset($this->commandSchemas[$args->getCommand()])) {
1128
                    echo ' '.$args->getCommand();
1129
1130 2
                } else {
1131 2
                    echo ' <command>';
1132
                }
1133
            }
1134
1135 6
            if ($this->hasOptions($args->getCommand())) {
1136
                echo " [<options>]";
1137
            }
1138
1139 4
            if ($hasArgs = $this->hasArgs($args->getCommand())) {
1140
                echo $hasArgs === 2 ? " <args>" : " [<args>]";
1141
            }
1142 6
1143
            echo PHP_EOL.PHP_EOL;
1144
        }
1145
    }
1146
1147
    /**
1148
     * Parse a schema in short form into a full schema array.
1149
     *
1150
     * @param array $arr The array to parse into a schema.
1151
     * @return array The full schema array.
1152
     * @throws \InvalidArgumentException Throws an exception when an item in the schema is invalid.
1153 1
     */
1154 1
    public static function parseSchema(array $arr) {
1155
        $result = [];
1156
1157
        foreach ($arr as $key => $value) {
1158
            if (is_int($key)) {
1159
                if (is_string($value)) {
1160
                    // This is a short param value.
1161 1
                    $param = static::parseShortParam($value);
1162 1
                    $name = $param['name'];
1163
                    $result[$name] = $param;
1164
                } else {
1165 1
                    throw new \InvalidArgumentException("Schema at position $key is not a valid param.", 500);
1166
                }
1167
            } else {
1168
                // The parameter is defined in the key.
1169
                $param = static::parseShortParam($key, $value);
1170
                $name = $param['name'];
1171
1172
                if (is_array($value)) {
1173
                    // The value describes a bit more about the schema.
1174
                    switch ($param['type']) {
1175
                        case 'array':
1176
                            if (isset($value['items'])) {
1177
                                // The value includes array schema information.
1178
                                $param = array_replace($param, $value);
1179
                            } else {
1180
                                // The value is a schema of items.
1181
                                $param['items'] = $value;
1182
                            }
1183
                            break;
1184
                        case 'object':
1185
                            // The value is a schema of the object.
1186
                            $param['properties'] = static::parseSchema($value);
1187
                            break;
1188
                        default:
1189
                            $param = array_replace($param, $value);
1190
                            break;
1191
                    }
1192
                } elseif (is_string($value)) {
1193
                    if ($param['type'] === 'array') {
1194
                        // Check to see if the value is the item type in the array.
1195
                        if (isset(self::$types[$value])) {
1196
                            $arrType = self::$types[$value];
1197
                        } elseif (($index = array_search($value, self::$types)) !== false) {
0 ignored issues
show
Unused Code introduced by
The assignment to $index is dead and can be removed.
Loading history...
1198
                            $arrType = self::$types[$value];
1199
                        }
1200
1201
                        if (isset($arrType)) {
1202
                            $param['items'] = ['type' => $arrType];
1203
                        } else {
1204
                            $param['description'] = $value;
1205
                        }
1206
                    } else {
1207
                        // The value is the schema description.
1208
                        $param['description'] = $value;
1209
                    }
1210
                }
1211
1212 1
                $result[$name] = $param;
1213
            }
1214
        }
1215 1
1216 1
        return $result;
1217
    }
1218
1219
    /**
1220
     * Parse a short parameter string into a full array parameter.
1221
     *
1222
     * @param string $str The short parameter string to parse.
1223
     * @param array $other An array of other information that might help resolve ambiguity.
1224
     * @return array Returns an array in the form [name, [param]].
1225
     * @throws \InvalidArgumentException Throws an exception if the short param is not in the correct format.
1226 1
     */
1227
    protected static function parseShortParam($str, $other = []) {
1228
        // Is the parameter optional?
1229
        if (substr($str, -1) === '?') {
1230
            $required = false;
1231
            $str = substr($str, 0, -1);
1232 1
        } else {
1233
            $required = true;
1234
        }
1235
1236
        // Check for a type.
1237
        $parts = explode(':', $str);
1238
1239 1
        if (count($parts) === 1) {
1240
            if (isset($other['type'])) {
1241
                $type = $other['type'];
1242 1
            } else {
1243
                $type = 'string';
1244 1
            }
1245
            $name = $parts[0];
1246
        } else {
1247
            $name = $parts[1];
1248
1249
            if (isset(self::$types[$parts[0]])) {
1250
                $type = self::$types[$parts[0]];
1251
            } else {
1252
                throw new \InvalidArgumentException("Invalid type {$parts[1]} for field $name.", 500);
1253
            }
1254
1255 1
            if (isset($parts[2])) {
1256
                $short = $parts[2];
1257 1
            }
1258
        }
1259
1260
        $result = ['name' => $name, 'type' => $type, 'required' => $required];
1261
1262 1
        if (isset($short)) {
1263
            $result['short'] = $short;
1264
        }
1265
1266 1
        return $result;
1267
    }
1268
1269
    /**
1270
     * Safely get a value out of an array.
1271
     *
1272
     * This function uses optimizations found in the [facebook libphputil library](https://github.com/facebook/libphutil).
1273
     *
1274
     * @param string|int $key The array key.
1275
     * @param array $array The array to get the value from.
1276
     * @param mixed $default The default value to return if the key doesn't exist.
1277
     * @return mixed The item from the array or `$default` if the array key doesn't exist.
1278 4
     */
1279
    public static function val($key, array $array, $default = null) {
1280
        // isset() is a micro-optimization - it is fast but fails for null values.
1281
        if (isset($array[$key])) {
1282
            return $array[$key];
1283
        }
1284
1285
        // Comparing $default is also a micro-optimization.
1286 4
        if ($default === null || array_key_exists($key, $array)) {
1287
            return null;
1288
        }
1289 3
1290
        return $default;
1291
    }
1292
}
1293