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