1<?php
2
3namespace splitbrain\phpcli;
4
5/**
6 * Class Options
7 *
8 * Parses command line options passed to the CLI script. Allows CLI scripts to easily register all accepted options and
9 * commands and even generates a help text from this setup.
10 *
11 * @author Andreas Gohr <andi@splitbrain.org>
12 * @license MIT
13 */
14class Options
15{
16    /** @var  array keeps the list of options to parse */
17    protected $setup;
18
19    /** @var  array store parsed options */
20    protected $options = array();
21
22    /** @var string current parsed command if any */
23    protected $command = '';
24
25    /** @var  array passed non-option arguments */
26    protected $args = array();
27
28    /** @var  string the executed script */
29    protected $bin;
30
31    /** @var  Colors for colored help output */
32    protected $colors;
33
34    /** @var string newline used for spacing help texts */
35    protected $newline = "\n";
36
37    /**
38     * Constructor
39     *
40     * @param Colors $colors optional configured color object
41     * @throws Exception when arguments can't be read
42     */
43    public function __construct(Colors $colors = null)
44    {
45        if (!is_null($colors)) {
46            $this->colors = $colors;
47        } else {
48            $this->colors = new Colors();
49        }
50
51        $this->setup = array(
52            '' => array(
53                'opts' => array(),
54                'args' => array(),
55                'help' => '',
56                'commandhelp' => 'This tool accepts a command as first parameter as outlined below:'
57            )
58        ); // default command
59
60        $this->args = $this->readPHPArgv();
61        $this->bin = basename(array_shift($this->args));
62
63        $this->options = array();
64    }
65
66    /**
67     * Gets the bin value
68     */
69    public function getBin()
70    {
71        return $this->bin;
72    }
73
74    /**
75     * Sets the help text for the tool itself
76     *
77     * @param string $help
78     */
79    public function setHelp($help)
80    {
81        $this->setup['']['help'] = $help;
82    }
83
84    /**
85     * Sets the help text for the tools commands itself
86     *
87     * @param string $help
88     */
89    public function setCommandHelp($help)
90    {
91        $this->setup['']['commandhelp'] = $help;
92    }
93
94    /**
95     * Use a more compact help screen with less new lines
96     *
97     * @param bool $set
98     */
99    public function useCompactHelp($set = true)
100    {
101        $this->newline = $set ? '' : "\n";
102    }
103
104    /**
105     * Register the names of arguments for help generation and number checking
106     *
107     * This has to be called in the order arguments are expected
108     *
109     * @param string $arg argument name (just for help)
110     * @param string $help help text
111     * @param bool $required is this a required argument
112     * @param string $command if theses apply to a sub command only
113     * @throws Exception
114     */
115    public function registerArgument($arg, $help, $required = true, $command = '')
116    {
117        if (!isset($this->setup[$command])) {
118            throw new Exception("Command $command not registered");
119        }
120
121        $this->setup[$command]['args'][] = array(
122            'name' => $arg,
123            'help' => $help,
124            'required' => $required
125        );
126    }
127
128    /**
129     * This registers a sub command
130     *
131     * Sub commands have their own options and use their own function (not main()).
132     *
133     * @param string $command
134     * @param string $help
135     * @throws Exception
136     */
137    public function registerCommand($command, $help)
138    {
139        if (isset($this->setup[$command])) {
140            throw new Exception("Command $command already registered");
141        }
142
143        $this->setup[$command] = array(
144            'opts' => array(),
145            'args' => array(),
146            'help' => $help
147        );
148
149    }
150
151    /**
152     * Register an option for option parsing and help generation
153     *
154     * @param string $long multi character option (specified with --)
155     * @param string $help help text for this option
156     * @param string|null $short one character option (specified with -)
157     * @param bool|string $needsarg does this option require an argument? give it a name here
158     * @param string $command what command does this option apply to
159     * @throws Exception
160     */
161    public function registerOption($long, $help, $short = null, $needsarg = false, $command = '')
162    {
163        if (!isset($this->setup[$command])) {
164            throw new Exception("Command $command not registered");
165        }
166
167        $this->setup[$command]['opts'][$long] = array(
168            'needsarg' => $needsarg,
169            'help' => $help,
170            'short' => $short
171        );
172
173        if ($short) {
174            if (strlen($short) > 1) {
175                throw new Exception("Short options should be exactly one ASCII character");
176            }
177
178            $this->setup[$command]['short'][$short] = $long;
179        }
180    }
181
182    /**
183     * Checks the actual number of arguments against the required number
184     *
185     * Throws an exception if arguments are missing.
186     *
187     * This is run from CLI automatically and usually does not need to be called directly
188     *
189     * @throws Exception
190     */
191    public function checkArguments()
192    {
193        $argc = count($this->args);
194
195        $req = 0;
196        foreach ($this->setup[$this->command]['args'] as $arg) {
197            if (!$arg['required']) {
198                break;
199            } // last required arguments seen
200            $req++;
201        }
202
203        if ($req > $argc) {
204            throw new Exception("Not enough arguments", Exception::E_OPT_ARG_REQUIRED);
205        }
206    }
207
208    /**
209     * Parses the given arguments for known options and command
210     *
211     * The given $args array should NOT contain the executed file as first item anymore! The $args
212     * array is stripped from any options and possible command. All found otions can be accessed via the
213     * getOpt() function
214     *
215     * Note that command options will overwrite any global options with the same name
216     *
217     * This is run from CLI automatically and usually does not need to be called directly
218     *
219     * @throws Exception
220     */
221    public function parseOptions()
222    {
223        $non_opts = array();
224
225        $argc = count($this->args);
226        for ($i = 0; $i < $argc; $i++) {
227            $arg = $this->args[$i];
228
229            // The special element '--' means explicit end of options. Treat the rest of the arguments as non-options
230            // and end the loop.
231            if ($arg == '--') {
232                $non_opts = array_merge($non_opts, array_slice($this->args, $i + 1));
233                break;
234            }
235
236            // '-' is stdin - a normal argument
237            if ($arg == '-') {
238                $non_opts = array_merge($non_opts, array_slice($this->args, $i));
239                break;
240            }
241
242            // first non-option
243            if ($arg[0] != '-') {
244                $non_opts = array_merge($non_opts, array_slice($this->args, $i));
245                break;
246            }
247
248            // long option
249            if (strlen($arg) > 1 && $arg[1] === '-') {
250                $arg = explode('=', substr($arg, 2), 2);
251                $opt = array_shift($arg);
252                $val = array_shift($arg);
253
254                if (!isset($this->setup[$this->command]['opts'][$opt])) {
255                    throw new Exception("No such option '$opt'", Exception::E_UNKNOWN_OPT);
256                }
257
258                // argument required?
259                if ($this->setup[$this->command]['opts'][$opt]['needsarg']) {
260                    if (is_null($val) && $i + 1 < $argc && !preg_match('/^--?[\w]/', $this->args[$i + 1])) {
261                        $val = $this->args[++$i];
262                    }
263                    if (is_null($val)) {
264                        throw new Exception("Option $opt requires an argument",
265                            Exception::E_OPT_ARG_REQUIRED);
266                    }
267                    $this->options[$opt] = $val;
268                } else {
269                    $this->options[$opt] = true;
270                }
271
272                continue;
273            }
274
275            // short option
276            $opt = substr($arg, 1);
277            if (!isset($this->setup[$this->command]['short'][$opt])) {
278                throw new Exception("No such option $arg", Exception::E_UNKNOWN_OPT);
279            } else {
280                $opt = $this->setup[$this->command]['short'][$opt]; // store it under long name
281            }
282
283            // argument required?
284            if ($this->setup[$this->command]['opts'][$opt]['needsarg']) {
285                $val = null;
286                if ($i + 1 < $argc && !preg_match('/^--?[\w]/', $this->args[$i + 1])) {
287                    $val = $this->args[++$i];
288                }
289                if (is_null($val)) {
290                    throw new Exception("Option $arg requires an argument",
291                        Exception::E_OPT_ARG_REQUIRED);
292                }
293                $this->options[$opt] = $val;
294            } else {
295                $this->options[$opt] = true;
296            }
297        }
298
299        // parsing is now done, update args array
300        $this->args = $non_opts;
301
302        // if not done yet, check if first argument is a command and reexecute argument parsing if it is
303        if (!$this->command && $this->args && isset($this->setup[$this->args[0]])) {
304            // it is a command!
305            $this->command = array_shift($this->args);
306            $this->parseOptions(); // second pass
307        }
308    }
309
310    /**
311     * Get the value of the given option
312     *
313     * Please note that all options are accessed by their long option names regardless of how they were
314     * specified on commandline.
315     *
316     * Can only be used after parseOptions() has been run
317     *
318     * @param mixed $option
319     * @param bool|string $default what to return if the option was not set
320     * @return bool|string|string[]
321     */
322    public function getOpt($option = null, $default = false)
323    {
324        if ($option === null) {
325            return $this->options;
326        }
327
328        if (isset($this->options[$option])) {
329            return $this->options[$option];
330        }
331        return $default;
332    }
333
334    /**
335     * Return the found command if any
336     *
337     * @return string
338     */
339    public function getCmd()
340    {
341        return $this->command;
342    }
343
344    /**
345     * Get all the arguments passed to the script
346     *
347     * This will not contain any recognized options or the script name itself
348     *
349     * @return array
350     */
351    public function getArgs()
352    {
353        return $this->args;
354    }
355
356    /**
357     * Builds a help screen from the available options. You may want to call it from -h or on error
358     *
359     * @return string
360     *
361     * @throws Exception
362     */
363    public function help()
364    {
365        $tf = new TableFormatter($this->colors);
366        $text = '';
367
368        $hascommands = (count($this->setup) > 1);
369        $commandhelp = $this->setup['']["commandhelp"];
370
371        foreach ($this->setup as $command => $config) {
372            $hasopts = (bool)$this->setup[$command]['opts'];
373            $hasargs = (bool)$this->setup[$command]['args'];
374
375            // usage or command syntax line
376            if (!$command) {
377                $text .= $this->colors->wrap('USAGE:', Colors::C_BROWN);
378                $text .= "\n";
379                $text .= '   ' . $this->bin;
380                $mv = 2;
381            } else {
382                $text .= $this->newline;
383                $text .= $this->colors->wrap('   ' . $command, Colors::C_PURPLE);
384                $mv = 4;
385            }
386
387            if ($hasopts) {
388                $text .= ' ' . $this->colors->wrap('<OPTIONS>', Colors::C_GREEN);
389            }
390
391            if (!$command && $hascommands) {
392                $text .= ' ' . $this->colors->wrap('<COMMAND> ...', Colors::C_PURPLE);
393            }
394
395            foreach ($this->setup[$command]['args'] as $arg) {
396                $out = $this->colors->wrap('<' . $arg['name'] . '>', Colors::C_CYAN);
397
398                if (!$arg['required']) {
399                    $out = '[' . $out . ']';
400                }
401                $text .= ' ' . $out;
402            }
403            $text .= $this->newline;
404
405            // usage or command intro
406            if ($this->setup[$command]['help']) {
407                $text .= "\n";
408                $text .= $tf->format(
409                    array($mv, '*'),
410                    array('', $this->setup[$command]['help'] . $this->newline)
411                );
412            }
413
414            // option description
415            if ($hasopts) {
416                if (!$command) {
417                    $text .= "\n";
418                    $text .= $this->colors->wrap('OPTIONS:', Colors::C_BROWN);
419                }
420                $text .= "\n";
421                foreach ($this->setup[$command]['opts'] as $long => $opt) {
422
423                    $name = '';
424                    if ($opt['short']) {
425                        $name .= '-' . $opt['short'];
426                        if ($opt['needsarg']) {
427                            $name .= ' <' . $opt['needsarg'] . '>';
428                        }
429                        $name .= ', ';
430                    }
431                    $name .= "--$long";
432                    if ($opt['needsarg']) {
433                        $name .= ' <' . $opt['needsarg'] . '>';
434                    }
435
436                    $text .= $tf->format(
437                        array($mv, '30%', '*'),
438                        array('', $name, $opt['help']),
439                        array('', 'green', '')
440                    );
441                    $text .= $this->newline;
442                }
443            }
444
445            // argument description
446            if ($hasargs) {
447                if (!$command) {
448                    $text .= "\n";
449                    $text .= $this->colors->wrap('ARGUMENTS:', Colors::C_BROWN);
450                }
451                $text .= $this->newline;
452                foreach ($this->setup[$command]['args'] as $arg) {
453                    $name = '<' . $arg['name'] . '>';
454
455                    $text .= $tf->format(
456                        array($mv, '30%', '*'),
457                        array('', $name, $arg['help']),
458                        array('', 'cyan', '')
459                    );
460                }
461            }
462
463            // head line and intro for following command documentation
464            if (!$command && $hascommands) {
465                $text .= "\n";
466                $text .= $this->colors->wrap('COMMANDS:', Colors::C_BROWN);
467                $text .= "\n";
468                $text .= $tf->format(
469                    array($mv, '*'),
470                    array('', $commandhelp)
471                );
472                $text .= $this->newline;
473            }
474        }
475
476        return $text;
477    }
478
479    /**
480     * Safely read the $argv PHP array across different PHP configurations.
481     * Will take care on register_globals and register_argc_argv ini directives
482     *
483     * @throws Exception
484     * @return array the $argv PHP array or PEAR error if not registered
485     */
486    private function readPHPArgv()
487    {
488        global $argv;
489        if (!is_array($argv)) {
490            if (!@is_array($_SERVER['argv'])) {
491                if (!@is_array($GLOBALS['HTTP_SERVER_VARS']['argv'])) {
492                    throw new Exception(
493                        "Could not read cmd args (register_argc_argv=Off?)",
494                        Exception::E_ARG_READ
495                    );
496                }
497                return $GLOBALS['HTTP_SERVER_VARS']['argv'];
498            }
499            return $_SERVER['argv'];
500        }
501        return $argv;
502    }
503}
504
505