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