Completed
Push — sidebaracl ( 7a112d...7c3e4a )
by Andreas
04:38
created

DokuCLI_Options::registerCommand()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 10
Code Lines 6

Duplication

Lines 0
Ratio 0 %
Metric Value
dl 0
loc 10
rs 9.4285
cc 2
eloc 6
nc 2
nop 2
1
<?php
2
3
/**
4
 * Class DokuCLI
5
 *
6
 * All DokuWiki commandline scripts should inherit from this class and implement the abstract methods.
7
 *
8
 * @author Andreas Gohr <[email protected]>
9
 */
10
abstract class DokuCLI {
11
    /** @var string the executed script itself */
12
    protected $bin;
13
    /** @var  DokuCLI_Options the option parser */
14
    protected $options;
15
    /** @var  DokuCLI_Colors */
16
    public $colors;
17
18
    /**
19
     * constructor
20
     *
21
     * Initialize the arguments, set up helper classes and set up the CLI environment
22
     */
23
    public function __construct() {
24
        set_exception_handler(array($this, 'fatal'));
25
26
        $this->options = new DokuCLI_Options();
27
        $this->colors  = new DokuCLI_Colors();
28
    }
29
30
    /**
31
     * Register options and arguments on the given $options object
32
     *
33
     * @param DokuCLI_Options $options
34
     * @return void
35
     */
36
    abstract protected function setup(DokuCLI_Options $options);
37
38
    /**
39
     * Your main program
40
     *
41
     * Arguments and options have been parsed when this is run
42
     *
43
     * @param DokuCLI_Options $options
44
     * @return void
45
     */
46
    abstract protected function main(DokuCLI_Options $options);
47
48
    /**
49
     * Execute the CLI program
50
     *
51
     * Executes the setup() routine, adds default options, initiate the options parsing and argument checking
52
     * and finally executes main()
53
     */
54
    public function run() {
55
        if('cli' != php_sapi_name()) throw new DokuCLI_Exception('This has to be run from the command line');
56
57
        // setup
58
        $this->setup($this->options);
59
        $this->options->registerOption(
60
            'no-colors',
61
            'Do not use any colors in output. Useful when piping output to other tools or files.'
62
        );
63
        $this->options->registerOption(
64
            'help',
65
            'Display this help screen and exit immeadiately.',
66
            'h'
67
        );
68
69
        // parse
70
        $this->options->parseOptions();
71
72
        // handle defaults
73
        if($this->options->getOpt('no-colors')) {
74
            $this->colors->disable();
75
        }
76
        if($this->options->getOpt('help')) {
77
            echo $this->options->help();
78
            exit(0);
79
        }
80
81
        // check arguments
82
        $this->options->checkArguments();
83
84
        // execute
85
        $this->main($this->options);
86
87
        exit(0);
88
    }
89
90
    /**
91
     * Exits the program on a fatal error
92
     *
93
     * @param Exception|string $error either an exception or an error message
94
     */
95
    public function fatal($error) {
96
        $code = 0;
97
        if(is_object($error) && is_a($error, 'Exception')) {
98
            /** @var Exception $error */
99
            $code  = $error->getCode();
100
            $error = $error->getMessage();
101
        }
102
        if(!$code) $code = DokuCLI_Exception::E_ANY;
103
104
        $this->error($error);
0 ignored issues
show
Bug introduced by
It seems like $error defined by parameter $error on line 95 can also be of type object<Exception>; however, DokuCLI::error() does only seem to accept string, maybe add an additional type check?

This check looks at variables that have been passed in as parameters and are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
105
        exit($code);
106
    }
107
108
    /**
109
     * Print an error message
110
     *
111
     * @param string $string
112
     */
113
    public function error($string) {
114
        $this->colors->ptln("E: $string", 'red', STDERR);
115
    }
116
117
    /**
118
     * Print a success message
119
     *
120
     * @param string $string
121
     */
122
    public function success($string) {
123
        $this->colors->ptln("S: $string", 'green', STDERR);
124
    }
125
126
    /**
127
     * Print an info message
128
     *
129
     * @param string $string
130
     */
131
    public function info($string) {
132
        $this->colors->ptln("I: $string", 'cyan', STDERR);
133
    }
134
135
}
136
137
/**
138
 * Class DokuCLI_Colors
139
 *
140
 * Handles color output on (Linux) terminals
141
 *
142
 * @author Andreas Gohr <[email protected]>
143
 */
144
class DokuCLI_Colors {
145
    /** @var array known color names */
146
    protected $colors = array(
147
        'reset'       => "\33[0m",
148
        'black'       => "\33[0;30m",
149
        'darkgray'    => "\33[1;30m",
150
        'blue'        => "\33[0;34m",
151
        'lightblue'   => "\33[1;34m",
152
        'green'       => "\33[0;32m",
153
        'lightgreen'  => "\33[1;32m",
154
        'cyan'        => "\33[0;36m",
155
        'lightcyan'   => "\33[1;36m",
156
        'red'         => "\33[0;31m",
157
        'lightred'    => "\33[1;31m",
158
        'purple'      => "\33[0;35m",
159
        'lightpurple' => "\33[1;35m",
160
        'brown'       => "\33[0;33m",
161
        'yellow'      => "\33[1;33m",
162
        'lightgray'   => "\33[0;37m",
163
        'white'       => "\33[1;37m",
164
    );
165
166
    /** @var bool should colors be used? */
167
    protected $enabled = true;
168
169
    /**
170
     * Constructor
171
     *
172
     * Tries to disable colors for non-terminals
173
     */
174
    public function __construct() {
175
        if(function_exists('posix_isatty') && !posix_isatty(STDOUT)) {
176
            $this->enabled = false;
177
            return;
178
        }
179
        if(!getenv('TERM')) {
180
            $this->enabled = false;
181
            return;
182
        }
183
    }
184
185
    /**
186
     * enable color output
187
     */
188
    public function enable() {
189
        $this->enabled = true;
190
    }
191
192
    /**
193
     * disable color output
194
     */
195
    public function disable() {
196
        $this->enabled = false;
197
    }
198
199
    /**
200
     * Convenience function to print a line in a given color
201
     *
202
     * @param string   $line
203
     * @param string   $color
204
     * @param resource $channel
205
     */
206
    public function ptln($line, $color, $channel = STDOUT) {
207
        $this->set($color);
208
        fwrite($channel, rtrim($line)."\n");
209
        $this->reset();
210
    }
211
212
    /**
213
     * Set the given color for consecutive output
214
     *
215
     * @param string $color one of the supported color names
216
     * @throws DokuCLI_Exception
217
     */
218
    public function set($color) {
219
        if(!$this->enabled) return;
220
        if(!isset($this->colors[$color])) throw new DokuCLI_Exception("No such color $color");
221
        echo $this->colors[$color];
222
    }
223
224
    /**
225
     * reset the terminal color
226
     */
227
    public function reset() {
228
        $this->set('reset');
229
    }
230
}
231
232
/**
233
 * Class DokuCLI_Options
234
 *
235
 * Parses command line options passed to the CLI script. Allows CLI scripts to easily register all accepted options and
236
 * commands and even generates a help text from this setup.
237
 *
238
 * @author Andreas Gohr <[email protected]>
239
 */
240
class DokuCLI_Options {
241
    /** @var  array keeps the list of options to parse */
242
    protected $setup;
243
244
    /** @var  array store parsed options */
245
    protected $options = array();
246
247
    /** @var string current parsed command if any */
248
    protected $command = '';
249
250
    /** @var  array passed non-option arguments */
251
    public $args = array();
252
253
    /** @var  string the executed script */
254
    protected $bin;
255
256
    /**
257
     * Constructor
258
     */
259
    public function __construct() {
260
        $this->setup = array(
261
            '' => array(
262
                'opts' => array(),
263
                'args' => array(),
264
                'help' => ''
265
            )
266
        ); // default command
267
268
        $this->args = $this->readPHPArgv();
269
        $this->bin  = basename(array_shift($this->args));
270
271
        $this->options = array();
272
    }
273
274
    /**
275
     * Sets the help text for the tool itself
276
     *
277
     * @param string $help
278
     */
279
    public function setHelp($help) {
280
        $this->setup['']['help'] = $help;
281
    }
282
283
    /**
284
     * Register the names of arguments for help generation and number checking
285
     *
286
     * This has to be called in the order arguments are expected
287
     *
288
     * @param string $arg      argument name (just for help)
289
     * @param string $help     help text
290
     * @param bool   $required is this a required argument
291
     * @param string $command  if theses apply to a sub command only
292
     * @throws DokuCLI_Exception
293
     */
294
    public function registerArgument($arg, $help, $required = true, $command = '') {
295
        if(!isset($this->setup[$command])) throw new DokuCLI_Exception("Command $command not registered");
296
297
        $this->setup[$command]['args'][] = array(
298
            'name'     => $arg,
299
            'help'     => $help,
300
            'required' => $required
301
        );
302
    }
303
304
    /**
305
     * This registers a sub command
306
     *
307
     * Sub commands have their own options and use their own function (not main()).
308
     *
309
     * @param string $command
310
     * @param string $help
311
     * @throws DokuCLI_Exception
312
     */
313
    public function registerCommand($command, $help) {
314
        if(isset($this->setup[$command])) throw new DokuCLI_Exception("Command $command already registered");
315
316
        $this->setup[$command] = array(
317
            'opts' => array(),
318
            'args' => array(),
319
            'help' => $help
320
        );
321
322
    }
323
324
    /**
325
     * Register an option for option parsing and help generation
326
     *
327
     * @param string      $long     multi character option (specified with --)
328
     * @param string      $help     help text for this option
329
     * @param string|null $short    one character option (specified with -)
330
     * @param bool|string $needsarg does this option require an argument? give it a name here
331
     * @param string      $command  what command does this option apply to
332
     * @throws DokuCLI_Exception
333
     */
334
    public function registerOption($long, $help, $short = null, $needsarg = false, $command = '') {
335
        if(!isset($this->setup[$command])) throw new DokuCLI_Exception("Command $command not registered");
336
337
        $this->setup[$command]['opts'][$long] = array(
338
            'needsarg' => $needsarg,
339
            'help'     => $help,
340
            'short'    => $short
341
        );
342
343
        if($short) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $short of type string|null is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
344
            if(strlen($short) > 1) throw new DokuCLI_Exception("Short options should be exactly one ASCII character");
345
346
            $this->setup[$command]['short'][$short] = $long;
347
        }
348
    }
349
350
    /**
351
     * Checks the actual number of arguments against the required number
352
     *
353
     * Throws an exception if arguments are missing. Called from parseOptions()
354
     *
355
     * @throws DokuCLI_Exception
356
     */
357
    public function checkArguments() {
358
        $argc = count($this->args);
359
360
        $req = 0;
361
        foreach($this->setup[$this->command]['args'] as $arg) {
362
            if(!$arg['required']) break; // last required arguments seen
363
            $req++;
364
        }
365
366
        if($req > $argc) throw new DokuCLI_Exception("Not enough arguments", DokuCLI_Exception::E_OPT_ARG_REQUIRED);
367
    }
368
369
    /**
370
     * Parses the given arguments for known options and command
371
     *
372
     * The given $args array should NOT contain the executed file as first item anymore! The $args
373
     * array is stripped from any options and possible command. All found otions can be accessed via the
374
     * getOpt() function
375
     *
376
     * Note that command options will overwrite any global options with the same name
377
     *
378
     * @throws DokuCLI_Exception
379
     */
380
    public function parseOptions() {
381
        $non_opts = array();
382
383
        $argc = count($this->args);
384
        for($i = 0; $i < $argc; $i++) {
385
            $arg = $this->args[$i];
386
387
            // The special element '--' means explicit end of options. Treat the rest of the arguments as non-options
388
            // and end the loop.
389
            if($arg == '--') {
390
                $non_opts = array_merge($non_opts, array_slice($this->args, $i + 1));
391
                break;
392
            }
393
394
            // '-' is stdin - a normal argument
395
            if($arg == '-') {
396
                $non_opts = array_merge($non_opts, array_slice($this->args, $i));
397
                break;
398
            }
399
400
            // first non-option
401
            if($arg{0} != '-') {
402
                $non_opts = array_merge($non_opts, array_slice($this->args, $i));
403
                break;
404
            }
405
406
            // long option
407
            if(strlen($arg) > 1 && $arg{1} == '-') {
408
                list($opt, $val) = explode('=', substr($arg, 2), 2);
409
410
                if(!isset($this->setup[$this->command]['opts'][$opt])) {
411
                    throw new DokuCLI_Exception("No such option $arg", DokuCLI_Exception::E_UNKNOWN_OPT);
412
                }
413
414
                // argument required?
415
                if($this->setup[$this->command]['opts'][$opt]['needsarg']) {
416
                    if(is_null($val) && $i + 1 < $argc && !preg_match('/^--?[\w]/', $this->args[$i + 1])) {
417
                        $val = $this->args[++$i];
418
                    }
419
                    if(is_null($val)) {
420
                        throw new DokuCLI_Exception("Option $arg requires an argument", DokuCLI_Exception::E_OPT_ARG_REQUIRED);
421
                    }
422
                    $this->options[$opt] = $val;
423
                } else {
424
                    $this->options[$opt] = true;
425
                }
426
427
                continue;
428
            }
429
430
            // short option
431
            $opt = substr($arg, 1);
432
            if(!isset($this->setup[$this->command]['short'][$opt])) {
433
                throw new DokuCLI_Exception("No such option $arg", DokuCLI_Exception::E_UNKNOWN_OPT);
434
            } else {
435
                $opt = $this->setup[$this->command]['short'][$opt]; // store it under long name
436
            }
437
438
            // argument required?
439
            if($this->setup[$this->command]['opts'][$opt]['needsarg']) {
440
                $val = null;
441
                if($i + 1 < $argc && !preg_match('/^--?[\w]/', $this->args[$i + 1])) {
442
                    $val = $this->args[++$i];
443
                }
444
                if(is_null($val)) {
445
                    throw new DokuCLI_Exception("Option $arg requires an argument", DokuCLI_Exception::E_OPT_ARG_REQUIRED);
446
                }
447
                $this->options[$opt] = $val;
448
            } else {
449
                $this->options[$opt] = true;
450
            }
451
        }
452
453
        // parsing is now done, update args array
454
        $this->args = $non_opts;
455
456
        // if not done yet, check if first argument is a command and reexecute argument parsing if it is
457
        if(!$this->command && $this->args && isset($this->setup[$this->args[0]])) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->args of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
458
            // it is a command!
459
            $this->command = array_shift($this->args);
460
            $this->parseOptions(); // second pass
461
        }
462
    }
463
464
    /**
465
     * Get the value of the given option
466
     *
467
     * Please note that all options are accessed by their long option names regardless of how they were
468
     * specified on commandline.
469
     *
470
     * Can only be used after parseOptions() has been run
471
     *
472
     * @param string $option
473
     * @param bool|string $default what to return if the option was not set
474
     * @return bool|string
475
     */
476
    public function getOpt($option, $default = false) {
477
        if(isset($this->options[$option])) return $this->options[$option];
478
        return $default;
479
    }
480
481
    /**
482
     * Return the found command if any
483
     *
484
     * @return string
485
     */
486
    public function getCmd() {
487
        return $this->command;
488
    }
489
490
    /**
491
     * Builds a help screen from the available options. You may want to call it from -h or on error
492
     *
493
     * @return string
494
     */
495
    public function help() {
496
        $text = '';
497
498
        $hascommands = (count($this->setup) > 1);
499
        foreach($this->setup as $command => $config) {
500
            $hasopts = (bool) $this->setup[$command]['opts'];
501
            $hasargs = (bool) $this->setup[$command]['args'];
502
503
            if(!$command) {
504
                $text .= 'USAGE: '.$this->bin;
505
            } else {
506
                $text .= "\n$command";
507
            }
508
509
            if($hasopts) $text .= ' <OPTIONS>';
510
511
            foreach($this->setup[$command]['args'] as $arg) {
512
                if($arg['required']) {
513
                    $text .= ' <'.$arg['name'].'>';
514
                } else {
515
                    $text .= ' [<'.$arg['name'].'>]';
516
                }
517
            }
518
            $text .= "\n";
519
520
            if($this->setup[$command]['help']) {
521
                $text .= "\n";
522
                $text .= $this->tableFormat(
523
                    array(2, 72),
524
                    array('', $this->setup[$command]['help']."\n")
525
                );
526
            }
527
528
            if($hasopts) {
529
                $text .= "\n  OPTIONS\n\n";
530
                foreach($this->setup[$command]['opts'] as $long => $opt) {
531
532
                    $name = '';
533
                    if($opt['short']) {
534
                        $name .= '-'.$opt['short'];
535
                        if($opt['needsarg']) $name .= ' <'.$opt['needsarg'].'>';
536
                        $name .= ', ';
537
                    }
538
                    $name .= "--$long";
539
                    if($opt['needsarg']) $name .= ' <'.$opt['needsarg'].'>';
540
541
                    $text .= $this->tableFormat(
542
                        array(2, 20, 52),
543
                        array('', $name, $opt['help'])
544
                    );
545
                    $text .= "\n";
546
                }
547
            }
548
549
            if($hasargs) {
550
                $text .= "\n";
551
                foreach($this->setup[$command]['args'] as $arg) {
552
                    $name = '<'.$arg['name'].'>';
553
554
                    $text .= $this->tableFormat(
555
                        array(2, 20, 52),
556
                        array('', $name, $arg['help'])
557
                    );
558
                }
559
            }
560
561
            if($command == '' && $hascommands) {
562
                $text .= "\nThis tool accepts a command as first parameter as outlined below:\n";
563
            }
564
        }
565
566
        return $text;
567
    }
568
569
    /**
570
     * Safely read the $argv PHP array across different PHP configurations.
571
     * Will take care on register_globals and register_argc_argv ini directives
572
     *
573
     * @throws DokuCLI_Exception
574
     * @return array the $argv PHP array or PEAR error if not registered
575
     */
576
    private function readPHPArgv() {
577
        global $argv;
578
        if(!is_array($argv)) {
579
            if(!@is_array($_SERVER['argv'])) {
580
                if(!@is_array($GLOBALS['HTTP_SERVER_VARS']['argv'])) {
581
                    throw new DokuCLI_Exception(
582
                        "Could not read cmd args (register_argc_argv=Off?)",
583
                        DOKU_CLI_OPTS_ARG_READ
584
                    );
585
                }
586
                return $GLOBALS['HTTP_SERVER_VARS']['argv'];
587
            }
588
            return $_SERVER['argv'];
589
        }
590
        return $argv;
591
    }
592
593
    /**
594
     * Displays text in multiple word wrapped columns
595
     *
596
     * @param int[]    $widths list of column widths (in characters)
597
     * @param string[] $texts  list of texts for each column
598
     * @return string
599
     */
600
    private function tableFormat($widths, $texts) {
601
        $wrapped = array();
602
        $maxlen  = 0;
603
604
        foreach($widths as $col => $width) {
605
            $wrapped[$col] = explode("\n", wordwrap($texts[$col], $width - 1, "\n", true)); // -1 char border
606
            $len           = count($wrapped[$col]);
607
            if($len > $maxlen) $maxlen = $len;
608
609
        }
610
611
        $out = '';
612
        for($i = 0; $i < $maxlen; $i++) {
613
            foreach($widths as $col => $width) {
614
                if(isset($wrapped[$col][$i])) {
615
                    $val = $wrapped[$col][$i];
616
                } else {
617
                    $val = '';
618
                }
619
                $out .= sprintf('%-'.$width.'s', $val);
620
            }
621
            $out .= "\n";
622
        }
623
        return $out;
624
    }
625
}
626
627
/**
628
 * Class DokuCLI_Exception
629
 *
630
 * The code is used as exit code for the CLI tool. This should probably be extended. Many cases just fall back to the
631
 * E_ANY code.
632
 *
633
 * @author Andreas Gohr <[email protected]>
634
 */
635
class DokuCLI_Exception extends Exception {
636
    const E_ANY = -1; // no error code specified
637
    const E_UNKNOWN_OPT = 1; //Unrecognized option
638
    const E_OPT_ARG_REQUIRED = 2; //Option requires argument
639
    const E_OPT_ARG_DENIED = 3; //Option not allowed argument
640
    const E_OPT_ABIGUOUS = 4; //Option abiguous
641
    const E_ARG_READ = 5; //Could not read argv
642
643
    /**
644
     * @param string    $message     The Exception message to throw.
645
     * @param int       $code        The Exception code
646
     * @param Exception $previous    The previous exception used for the exception chaining.
647
     */
648
    public function __construct($message = "", $code = 0, Exception $previous = null) {
649
        if(!$code) $code = DokuCLI_Exception::E_ANY;
650
        parent::__construct($message, $code, $previous);
651
    }
652
}
653