Failed Conditions
Push — master ( cbaf27...ca549e )
by Andreas
08:53 queued 04:43
created

DokuCLI_Options::tableFormat()   B

Complexity

Conditions 6
Paths 12

Size

Total Lines 25

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 6
nc 12
nop 2
dl 0
loc 25
rs 8.8977
c 0
b 0
f 0
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
        dbg_deprecated('use \splitbrain\phpcli\CLI instead');
30
        $this->error('DokuCLI is deprecated, use \splitbrain\phpcli\CLI instead.');
31
    }
32
33
    /**
34
     * Register options and arguments on the given $options object
35
     *
36
     * @param DokuCLI_Options $options
37
     * @return void
38
     */
39
    abstract protected function setup(DokuCLI_Options $options);
40
41
    /**
42
     * Your main program
43
     *
44
     * Arguments and options have been parsed when this is run
45
     *
46
     * @param DokuCLI_Options $options
47
     * @return void
48
     */
49
    abstract protected function main(DokuCLI_Options $options);
50
51
    /**
52
     * Execute the CLI program
53
     *
54
     * Executes the setup() routine, adds default options, initiate the options parsing and argument checking
55
     * and finally executes main()
56
     */
57
    public function run() {
58
        if('cli' != php_sapi_name()) throw new DokuCLI_Exception('This has to be run from the command line');
59
60
        // setup
61
        $this->setup($this->options);
62
        $this->options->registerOption(
63
            'no-colors',
64
            'Do not use any colors in output. Useful when piping output to other tools or files.'
65
        );
66
        $this->options->registerOption(
67
            'help',
68
            'Display this help screen and exit immediately.',
69
            'h'
70
        );
71
72
        // parse
73
        $this->options->parseOptions();
74
75
        // handle defaults
76
        if($this->options->getOpt('no-colors')) {
77
            $this->colors->disable();
78
        }
79
        if($this->options->getOpt('help')) {
80
            echo $this->options->help();
81
            exit(0);
82
        }
83
84
        // check arguments
85
        $this->options->checkArguments();
86
87
        // execute
88
        $this->main($this->options);
89
90
        exit(0);
91
    }
92
93
    /**
94
     * Exits the program on a fatal error
95
     *
96
     * @param Exception|string $error either an exception or an error message
97
     */
98
    public function fatal($error) {
99
        $code = 0;
100
        if(is_object($error) && is_a($error, 'Exception')) {
101
            /** @var Exception $error */
102
            $code  = $error->getCode();
103
            $error = $error->getMessage();
104
        }
105
        if(!$code) $code = DokuCLI_Exception::E_ANY;
106
107
        $this->error($error);
0 ignored issues
show
Bug introduced by
It seems like $error defined by parameter $error on line 98 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...
108
        exit($code);
109
    }
110
111
    /**
112
     * Print an error message
113
     *
114
     * @param string $string
115
     */
116
    public function error($string) {
117
        $this->colors->ptln("E: $string", 'red', STDERR);
0 ignored issues
show
Documentation introduced by
STDERR is of type string, but the function expects a resource.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
118
    }
119
120
    /**
121
     * Print a success message
122
     *
123
     * @param string $string
124
     */
125
    public function success($string) {
126
        $this->colors->ptln("S: $string", 'green', STDERR);
0 ignored issues
show
Documentation introduced by
STDERR is of type string, but the function expects a resource.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
127
    }
128
129
    /**
130
     * Print an info message
131
     *
132
     * @param string $string
133
     */
134
    public function info($string) {
135
        $this->colors->ptln("I: $string", 'cyan', STDERR);
0 ignored issues
show
Documentation introduced by
STDERR is of type string, but the function expects a resource.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
136
    }
137
138
}
139
140
/**
141
 * Class DokuCLI_Colors
142
 *
143
 * Handles color output on (Linux) terminals
144
 *
145
 * @author Andreas Gohr <[email protected]>
146
 */
147
class DokuCLI_Colors {
148
    /** @var array known color names */
149
    protected $colors = array(
150
        'reset'       => "\33[0m",
151
        'black'       => "\33[0;30m",
152
        'darkgray'    => "\33[1;30m",
153
        'blue'        => "\33[0;34m",
154
        'lightblue'   => "\33[1;34m",
155
        'green'       => "\33[0;32m",
156
        'lightgreen'  => "\33[1;32m",
157
        'cyan'        => "\33[0;36m",
158
        'lightcyan'   => "\33[1;36m",
159
        'red'         => "\33[0;31m",
160
        'lightred'    => "\33[1;31m",
161
        'purple'      => "\33[0;35m",
162
        'lightpurple' => "\33[1;35m",
163
        'brown'       => "\33[0;33m",
164
        'yellow'      => "\33[1;33m",
165
        'lightgray'   => "\33[0;37m",
166
        'white'       => "\33[1;37m",
167
    );
168
169
    /** @var bool should colors be used? */
170
    protected $enabled = true;
171
172
    /**
173
     * Constructor
174
     *
175
     * Tries to disable colors for non-terminals
176
     */
177
    public function __construct() {
178
        if(function_exists('posix_isatty') && !posix_isatty(STDOUT)) {
179
            $this->enabled = false;
180
            return;
181
        }
182
        if(!getenv('TERM')) {
183
            $this->enabled = false;
184
            return;
185
        }
186
    }
187
188
    /**
189
     * enable color output
190
     */
191
    public function enable() {
192
        $this->enabled = true;
193
    }
194
195
    /**
196
     * disable color output
197
     */
198
    public function disable() {
199
        $this->enabled = false;
200
    }
201
202
    /**
203
     * Convenience function to print a line in a given color
204
     *
205
     * @param string   $line
206
     * @param string   $color
207
     * @param resource $channel
208
     */
209
    public function ptln($line, $color, $channel = STDOUT) {
210
        $this->set($color);
211
        fwrite($channel, rtrim($line)."\n");
212
        $this->reset();
213
    }
214
215
    /**
216
     * Set the given color for consecutive output
217
     *
218
     * @param string $color one of the supported color names
219
     * @throws DokuCLI_Exception
220
     */
221
    public function set($color) {
222
        if(!$this->enabled) return;
223
        if(!isset($this->colors[$color])) throw new DokuCLI_Exception("No such color $color");
224
        echo $this->colors[$color];
225
    }
226
227
    /**
228
     * reset the terminal color
229
     */
230
    public function reset() {
231
        $this->set('reset');
232
    }
233
}
234
235
/**
236
 * Class DokuCLI_Options
237
 *
238
 * Parses command line options passed to the CLI script. Allows CLI scripts to easily register all accepted options and
239
 * commands and even generates a help text from this setup.
240
 *
241
 * @author Andreas Gohr <[email protected]>
242
 */
243
class DokuCLI_Options {
244
    /** @var  array keeps the list of options to parse */
245
    protected $setup;
246
247
    /** @var  array store parsed options */
248
    protected $options = array();
249
250
    /** @var string current parsed command if any */
251
    protected $command = '';
252
253
    /** @var  array passed non-option arguments */
254
    public $args = array();
255
256
    /** @var  string the executed script */
257
    protected $bin;
258
259
    /**
260
     * Constructor
261
     */
262
    public function __construct() {
263
        $this->setup = array(
264
            '' => array(
265
                'opts' => array(),
266
                'args' => array(),
267
                'help' => ''
268
            )
269
        ); // default command
270
271
        $this->args = $this->readPHPArgv();
272
        $this->bin  = basename(array_shift($this->args));
273
274
        $this->options = array();
275
    }
276
277
    /**
278
     * Sets the help text for the tool itself
279
     *
280
     * @param string $help
281
     */
282
    public function setHelp($help) {
283
        $this->setup['']['help'] = $help;
284
    }
285
286
    /**
287
     * Register the names of arguments for help generation and number checking
288
     *
289
     * This has to be called in the order arguments are expected
290
     *
291
     * @param string $arg      argument name (just for help)
292
     * @param string $help     help text
293
     * @param bool   $required is this a required argument
294
     * @param string $command  if theses apply to a sub command only
295
     * @throws DokuCLI_Exception
296
     */
297
    public function registerArgument($arg, $help, $required = true, $command = '') {
298
        if(!isset($this->setup[$command])) throw new DokuCLI_Exception("Command $command not registered");
299
300
        $this->setup[$command]['args'][] = array(
301
            'name'     => $arg,
302
            'help'     => $help,
303
            'required' => $required
304
        );
305
    }
306
307
    /**
308
     * This registers a sub command
309
     *
310
     * Sub commands have their own options and use their own function (not main()).
311
     *
312
     * @param string $command
313
     * @param string $help
314
     * @throws DokuCLI_Exception
315
     */
316
    public function registerCommand($command, $help) {
317
        if(isset($this->setup[$command])) throw new DokuCLI_Exception("Command $command already registered");
318
319
        $this->setup[$command] = array(
320
            'opts' => array(),
321
            'args' => array(),
322
            'help' => $help
323
        );
324
325
    }
326
327
    /**
328
     * Register an option for option parsing and help generation
329
     *
330
     * @param string      $long     multi character option (specified with --)
331
     * @param string      $help     help text for this option
332
     * @param string|null $short    one character option (specified with -)
333
     * @param bool|string $needsarg does this option require an argument? give it a name here
334
     * @param string      $command  what command does this option apply to
335
     * @throws DokuCLI_Exception
336
     */
337
    public function registerOption($long, $help, $short = null, $needsarg = false, $command = '') {
338
        if(!isset($this->setup[$command])) throw new DokuCLI_Exception("Command $command not registered");
339
340
        $this->setup[$command]['opts'][$long] = array(
341
            'needsarg' => $needsarg,
342
            'help'     => $help,
343
            'short'    => $short
344
        );
345
346
        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...
347
            if(strlen($short) > 1) throw new DokuCLI_Exception("Short options should be exactly one ASCII character");
348
349
            $this->setup[$command]['short'][$short] = $long;
350
        }
351
    }
352
353
    /**
354
     * Checks the actual number of arguments against the required number
355
     *
356
     * Throws an exception if arguments are missing. Called from parseOptions()
357
     *
358
     * @throws DokuCLI_Exception
359
     */
360
    public function checkArguments() {
361
        $argc = count($this->args);
362
363
        $req = 0;
364
        foreach($this->setup[$this->command]['args'] as $arg) {
365
            if(!$arg['required']) break; // last required arguments seen
366
            $req++;
367
        }
368
369
        if($req > $argc) throw new DokuCLI_Exception("Not enough arguments", DokuCLI_Exception::E_OPT_ARG_REQUIRED);
370
    }
371
372
    /**
373
     * Parses the given arguments for known options and command
374
     *
375
     * The given $args array should NOT contain the executed file as first item anymore! The $args
376
     * array is stripped from any options and possible command. All found otions can be accessed via the
377
     * getOpt() function
378
     *
379
     * Note that command options will overwrite any global options with the same name
380
     *
381
     * @throws DokuCLI_Exception
382
     */
383
    public function parseOptions() {
384
        $non_opts = array();
385
386
        $argc = count($this->args);
387
        for($i = 0; $i < $argc; $i++) {
388
            $arg = $this->args[$i];
389
390
            // The special element '--' means explicit end of options. Treat the rest of the arguments as non-options
391
            // and end the loop.
392
            if($arg == '--') {
393
                $non_opts = array_merge($non_opts, array_slice($this->args, $i + 1));
394
                break;
395
            }
396
397
            // '-' is stdin - a normal argument
398
            if($arg == '-') {
399
                $non_opts = array_merge($non_opts, array_slice($this->args, $i));
400
                break;
401
            }
402
403
            // first non-option
404
            if($arg{0} != '-') {
405
                $non_opts = array_merge($non_opts, array_slice($this->args, $i));
406
                break;
407
            }
408
409
            // long option
410
            if(strlen($arg) > 1 && $arg{1} == '-') {
411
                list($opt, $val) = explode('=', substr($arg, 2), 2);
412
413
                if(!isset($this->setup[$this->command]['opts'][$opt])) {
414
                    throw new DokuCLI_Exception("No such option $arg", DokuCLI_Exception::E_UNKNOWN_OPT);
415
                }
416
417
                // argument required?
418
                if($this->setup[$this->command]['opts'][$opt]['needsarg']) {
419
                    if(is_null($val) && $i + 1 < $argc && !preg_match('/^--?[\w]/', $this->args[$i + 1])) {
420
                        $val = $this->args[++$i];
421
                    }
422
                    if(is_null($val)) {
423
                        throw new DokuCLI_Exception("Option $arg requires an argument", DokuCLI_Exception::E_OPT_ARG_REQUIRED);
424
                    }
425
                    $this->options[$opt] = $val;
426
                } else {
427
                    $this->options[$opt] = true;
428
                }
429
430
                continue;
431
            }
432
433
            // short option
434
            $opt = substr($arg, 1);
435
            if(!isset($this->setup[$this->command]['short'][$opt])) {
436
                throw new DokuCLI_Exception("No such option $arg", DokuCLI_Exception::E_UNKNOWN_OPT);
437
            } else {
438
                $opt = $this->setup[$this->command]['short'][$opt]; // store it under long name
439
            }
440
441
            // argument required?
442
            if($this->setup[$this->command]['opts'][$opt]['needsarg']) {
443
                $val = null;
444
                if($i + 1 < $argc && !preg_match('/^--?[\w]/', $this->args[$i + 1])) {
445
                    $val = $this->args[++$i];
446
                }
447
                if(is_null($val)) {
448
                    throw new DokuCLI_Exception("Option $arg requires an argument", DokuCLI_Exception::E_OPT_ARG_REQUIRED);
449
                }
450
                $this->options[$opt] = $val;
451
            } else {
452
                $this->options[$opt] = true;
453
            }
454
        }
455
456
        // parsing is now done, update args array
457
        $this->args = $non_opts;
458
459
        // if not done yet, check if first argument is a command and reexecute argument parsing if it is
460
        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...
461
            // it is a command!
462
            $this->command = array_shift($this->args);
463
            $this->parseOptions(); // second pass
464
        }
465
    }
466
467
    /**
468
     * Get the value of the given option
469
     *
470
     * Please note that all options are accessed by their long option names regardless of how they were
471
     * specified on commandline.
472
     *
473
     * Can only be used after parseOptions() has been run
474
     *
475
     * @param string $option
476
     * @param bool|string $default what to return if the option was not set
477
     * @return bool|string
478
     */
479
    public function getOpt($option, $default = false) {
480
        if(isset($this->options[$option])) return $this->options[$option];
481
        return $default;
482
    }
483
484
    /**
485
     * Return the found command if any
486
     *
487
     * @return string
488
     */
489
    public function getCmd() {
490
        return $this->command;
491
    }
492
493
    /**
494
     * Builds a help screen from the available options. You may want to call it from -h or on error
495
     *
496
     * @return string
497
     */
498
    public function help() {
499
        $text = '';
500
501
        $hascommands = (count($this->setup) > 1);
502
        foreach($this->setup as $command => $config) {
503
            $hasopts = (bool) $this->setup[$command]['opts'];
504
            $hasargs = (bool) $this->setup[$command]['args'];
505
506
            if(!$command) {
507
                $text .= 'USAGE: '.$this->bin;
508
            } else {
509
                $text .= "\n$command";
510
            }
511
512
            if($hasopts) $text .= ' <OPTIONS>';
513
514
            foreach($this->setup[$command]['args'] as $arg) {
515
                if($arg['required']) {
516
                    $text .= ' <'.$arg['name'].'>';
517
                } else {
518
                    $text .= ' [<'.$arg['name'].'>]';
519
                }
520
            }
521
            $text .= "\n";
522
523
            if($this->setup[$command]['help']) {
524
                $text .= "\n";
525
                $text .= $this->tableFormat(
526
                    array(2, 72),
527
                    array('', $this->setup[$command]['help']."\n")
528
                );
529
            }
530
531
            if($hasopts) {
532
                $text .= "\n  OPTIONS\n\n";
533
                foreach($this->setup[$command]['opts'] as $long => $opt) {
534
535
                    $name = '';
536
                    if($opt['short']) {
537
                        $name .= '-'.$opt['short'];
538
                        if($opt['needsarg']) $name .= ' <'.$opt['needsarg'].'>';
539
                        $name .= ', ';
540
                    }
541
                    $name .= "--$long";
542
                    if($opt['needsarg']) $name .= ' <'.$opt['needsarg'].'>';
543
544
                    $text .= $this->tableFormat(
545
                        array(2, 20, 52),
546
                        array('', $name, $opt['help'])
547
                    );
548
                    $text .= "\n";
549
                }
550
            }
551
552
            if($hasargs) {
553
                $text .= "\n";
554
                foreach($this->setup[$command]['args'] as $arg) {
555
                    $name = '<'.$arg['name'].'>';
556
557
                    $text .= $this->tableFormat(
558
                        array(2, 20, 52),
559
                        array('', $name, $arg['help'])
560
                    );
561
                }
562
            }
563
564
            if($command == '' && $hascommands) {
565
                $text .= "\nThis tool accepts a command as first parameter as outlined below:\n";
566
            }
567
        }
568
569
        return $text;
570
    }
571
572
    /**
573
     * Safely read the $argv PHP array across different PHP configurations.
574
     * Will take care on register_globals and register_argc_argv ini directives
575
     *
576
     * @throws DokuCLI_Exception
577
     * @return array the $argv PHP array or PEAR error if not registered
578
     */
579
    private function readPHPArgv() {
580
        global $argv;
581
        if(!is_array($argv)) {
582
            if(!@is_array($_SERVER['argv'])) {
583
                if(!@is_array($GLOBALS['HTTP_SERVER_VARS']['argv'])) {
584
                    throw new DokuCLI_Exception(
585
                        "Could not read cmd args (register_argc_argv=Off?)",
586
                        DOKU_CLI_OPTS_ARG_READ
587
                    );
588
                }
589
                return $GLOBALS['HTTP_SERVER_VARS']['argv'];
590
            }
591
            return $_SERVER['argv'];
592
        }
593
        return $argv;
594
    }
595
596
    /**
597
     * Displays text in multiple word wrapped columns
598
     *
599
     * @param int[]    $widths list of column widths (in characters)
600
     * @param string[] $texts  list of texts for each column
601
     * @return string
602
     */
603
    private function tableFormat($widths, $texts) {
604
        $wrapped = array();
605
        $maxlen  = 0;
606
607
        foreach($widths as $col => $width) {
608
            $wrapped[$col] = explode("\n", wordwrap($texts[$col], $width - 1, "\n", true)); // -1 char border
609
            $len           = count($wrapped[$col]);
610
            if($len > $maxlen) $maxlen = $len;
611
612
        }
613
614
        $out = '';
615
        for($i = 0; $i < $maxlen; $i++) {
616
            foreach($widths as $col => $width) {
617
                if(isset($wrapped[$col][$i])) {
618
                    $val = $wrapped[$col][$i];
619
                } else {
620
                    $val = '';
621
                }
622
                $out .= sprintf('%-'.$width.'s', $val);
623
            }
624
            $out .= "\n";
625
        }
626
        return $out;
627
    }
628
}
629
630
/**
631
 * Class DokuCLI_Exception
632
 *
633
 * The code is used as exit code for the CLI tool. This should probably be extended. Many cases just fall back to the
634
 * E_ANY code.
635
 *
636
 * @author Andreas Gohr <[email protected]>
637
 */
638
class DokuCLI_Exception extends Exception {
639
    const E_ANY = -1; // no error code specified
640
    const E_UNKNOWN_OPT = 1; //Unrecognized option
641
    const E_OPT_ARG_REQUIRED = 2; //Option requires argument
642
    const E_OPT_ARG_DENIED = 3; //Option not allowed argument
643
    const E_OPT_ABIGUOUS = 4; //Option abiguous
644
    const E_ARG_READ = 5; //Could not read argv
645
646
    /**
647
     * @param string    $message     The Exception message to throw.
648
     * @param int       $code        The Exception code
649
     * @param Exception $previous    The previous exception used for the exception chaining.
650
     */
651
    public function __construct($message = "", $code = 0, Exception $previous = null) {
652
        if(!$code) $code = DokuCLI_Exception::E_ANY;
653
        parent::__construct($message, $code, $previous);
654
    }
655
}
656