xref: /dokuwiki/vendor/splitbrain/php-cli/src/Options.php (revision 13ce475d48f846c56b4bf52ae5fc6c1216dc8deb)
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
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    public function help()
330    {
331        $tf = new TableFormatter($this->colors);
332        $text = '';
333
334        $hascommands = (count($this->setup) > 1);
335        foreach ($this->setup as $command => $config) {
336            $hasopts = (bool)$this->setup[$command]['opts'];
337            $hasargs = (bool)$this->setup[$command]['args'];
338
339            // usage or command syntax line
340            if (!$command) {
341                $text .= $this->colors->wrap('USAGE:', Colors::C_BROWN);
342                $text .= "\n";
343                $text .= '   ' . $this->bin;
344                $mv = 2;
345            } else {
346                $text .= "\n";
347                $text .= $this->colors->wrap('   ' . $command, Colors::C_PURPLE);
348                $mv = 4;
349            }
350
351            if ($hasopts) {
352                $text .= ' ' . $this->colors->wrap('<OPTIONS>', Colors::C_GREEN);
353            }
354
355            if (!$command && $hascommands) {
356                $text .= ' ' . $this->colors->wrap('<COMMAND> ...', Colors::C_PURPLE);
357            }
358
359            foreach ($this->setup[$command]['args'] as $arg) {
360                $out = $this->colors->wrap('<' . $arg['name'] . '>', Colors::C_CYAN);
361
362                if (!$arg['required']) {
363                    $out = '[' . $out . ']';
364                }
365                $text .= ' ' . $out;
366            }
367            $text .= "\n";
368
369            // usage or command intro
370            if ($this->setup[$command]['help']) {
371                $text .= "\n";
372                $text .= $tf->format(
373                    array($mv, '*'),
374                    array('', $this->setup[$command]['help'] . "\n")
375                );
376            }
377
378            // option description
379            if ($hasopts) {
380                if (!$command) {
381                    $text .= "\n";
382                    $text .= $this->colors->wrap('OPTIONS:', Colors::C_BROWN);
383                }
384                $text .= "\n";
385                foreach ($this->setup[$command]['opts'] as $long => $opt) {
386
387                    $name = '';
388                    if ($opt['short']) {
389                        $name .= '-' . $opt['short'];
390                        if ($opt['needsarg']) {
391                            $name .= ' <' . $opt['needsarg'] . '>';
392                        }
393                        $name .= ', ';
394                    }
395                    $name .= "--$long";
396                    if ($opt['needsarg']) {
397                        $name .= ' <' . $opt['needsarg'] . '>';
398                    }
399
400                    $text .= $tf->format(
401                        array($mv, '30%', '*'),
402                        array('', $name, $opt['help']),
403                        array('', 'green', '')
404                    );
405                    $text .= "\n";
406                }
407            }
408
409            // argument description
410            if ($hasargs) {
411                if (!$command) {
412                    $text .= "\n";
413                    $text .= $this->colors->wrap('ARGUMENTS:', Colors::C_BROWN);
414                }
415                $text .= "\n";
416                foreach ($this->setup[$command]['args'] as $arg) {
417                    $name = '<' . $arg['name'] . '>';
418
419                    $text .= $tf->format(
420                        array($mv, '30%', '*'),
421                        array('', $name, $arg['help']),
422                        array('', 'cyan', '')
423                    );
424                }
425            }
426
427            // head line and intro for following command documentation
428            if (!$command && $hascommands) {
429                $text .= "\n";
430                $text .= $this->colors->wrap('COMMANDS:', Colors::C_BROWN);
431                $text .= "\n";
432                $text .= $tf->format(
433                    array($mv, '*'),
434                    array('', 'This tool accepts a command as first parameter as outlined below:')
435                );
436                $text .= "\n";
437            }
438        }
439
440        return $text;
441    }
442
443    /**
444     * Safely read the $argv PHP array across different PHP configurations.
445     * Will take care on register_globals and register_argc_argv ini directives
446     *
447     * @throws Exception
448     * @return array the $argv PHP array or PEAR error if not registered
449     */
450    private function readPHPArgv()
451    {
452        global $argv;
453        if (!is_array($argv)) {
454            if (!@is_array($_SERVER['argv'])) {
455                if (!@is_array($GLOBALS['HTTP_SERVER_VARS']['argv'])) {
456                    throw new Exception(
457                        "Could not read cmd args (register_argc_argv=Off?)",
458                        Exception::E_ARG_READ
459                    );
460                }
461                return $GLOBALS['HTTP_SERVER_VARS']['argv'];
462            }
463            return $_SERVER['argv'];
464        }
465        return $argv;
466    }
467}
468
469