1<?php
2
3namespace MrClay;
4
5use MrClay\Cli\Arg;
6use InvalidArgumentException;
7
8/**
9 * Forms a front controller for a console app, handling and validating arguments (options)
10 *
11 * Instantiate, add arguments, then call validate(). Afterwards, the user's valid arguments
12 * and their values will be available in $cli->values.
13 *
14 * You may also specify that some arguments be used to provide input/output. By communicating
15 * solely through the file pointers provided by openInput()/openOutput(), you can make your
16 * app more flexible to end users.
17 *
18 * @author Steve Clay <steve@mrclay.org>
19 * @license http://www.opensource.org/licenses/mit-license.php  MIT License
20 */
21class Cli {
22
23    /**
24     * @var array validation errors
25     */
26    public $errors = array();
27
28    /**
29     * @var array option values available after validation.
30     *
31     * E.g. array(
32     *      'a' => false              // option was missing
33     *     ,'b' => true               // option was present
34     *     ,'c' => "Hello"            // option had value
35     *     ,'f' => "/home/user/file"  // file path from root
36     *     ,'f.raw' => "~/file"       // file path as given to option
37     * )
38     */
39    public $values = array();
40
41    /**
42     * @var array
43     */
44    public $moreArgs = array();
45
46    /**
47     * @var array
48     */
49    public $debug = array();
50
51    /**
52     * @var bool The user wants help info
53     */
54    public $isHelpRequest = false;
55
56    /**
57     * @var Arg[]
58     */
59    protected $_args = array();
60
61    /**
62     * @var resource
63     */
64    protected $_stdin = null;
65
66    /**
67     * @var resource
68     */
69    protected $_stdout = null;
70
71    /**
72     * @param bool $exitIfNoStdin (default true) Exit() if STDIN is not defined
73     */
74    public function __construct($exitIfNoStdin = true)
75    {
76        if ($exitIfNoStdin && ! defined('STDIN')) {
77            exit('This script is for command-line use only.');
78        }
79        if (isset($GLOBALS['argv'][1])
80             && ($GLOBALS['argv'][1] === '-?' || $GLOBALS['argv'][1] === '--help')) {
81            $this->isHelpRequest = true;
82        }
83    }
84
85    /**
86     * @param Arg|string $letter
87     * @return Arg
88     */
89    public function addOptionalArg($letter)
90    {
91        return $this->addArgument($letter, false);
92    }
93
94    /**
95     * @param Arg|string $letter
96     * @return Arg
97     */
98    public function addRequiredArg($letter)
99    {
100        return $this->addArgument($letter, true);
101    }
102
103    /**
104     * @param string $letter
105     * @param bool $required
106     * @param Arg|null $arg
107     * @return Arg
108     * @throws InvalidArgumentException
109     */
110    public function addArgument($letter, $required, Arg $arg = null)
111    {
112        if (! preg_match('/^[a-zA-Z]$/', $letter)) {
113            throw new InvalidArgumentException('$letter must be in [a-zA-Z]');
114        }
115        if (! $arg) {
116            $arg = new Arg($required);
117        }
118        $this->_args[$letter] = $arg;
119        return $arg;
120    }
121
122    /**
123     * @param string $letter
124     * @return Arg|null
125     */
126    public function getArgument($letter)
127    {
128        return isset($this->_args[$letter]) ? $this->_args[$letter] : null;
129    }
130
131    /*
132     * Read and validate options
133     *
134     * @return bool true if all options are valid
135     */
136    public function validate()
137    {
138        $options = '';
139        $this->errors = array();
140        $this->values = array();
141        $this->_stdin = null;
142
143        if ($this->isHelpRequest) {
144            return false;
145        }
146
147        $lettersUsed = '';
148        foreach ($this->_args as $letter => $arg) {
149            /* @var Arg $arg  */
150            $options .= $letter;
151            $lettersUsed .= $letter;
152
153            if ($arg->mayHaveValue || $arg->mustHaveValue) {
154                $options .= ($arg->mustHaveValue ? ':' : '::');
155            }
156        }
157
158        $this->debug['argv'] = $GLOBALS['argv'];
159        $argvCopy = array_slice($GLOBALS['argv'], 1);
160        $o = getopt($options);
161        $this->debug['getopt_options'] = $options;
162        $this->debug['getopt_return'] = $o;
163
164        foreach ($this->_args as $letter => $arg) {
165            /* @var Arg $arg  */
166            $this->values[$letter] = false;
167            if (isset($o[$letter])) {
168                if (is_bool($o[$letter])) {
169
170                    // remove from argv copy
171                    $k = array_search("-$letter", $argvCopy);
172                    if ($k !== false) {
173                        array_splice($argvCopy, $k, 1);
174                    }
175
176                    if ($arg->mustHaveValue) {
177                        $this->addError($letter, "Missing value");
178                    } else {
179                        $this->values[$letter] = true;
180                    }
181                } else {
182                    // string
183                    $this->values[$letter] = $o[$letter];
184                    $v =& $this->values[$letter];
185
186                    // remove from argv copy
187                    // first look for -ovalue or -o=value
188                    $pattern = "/^-{$letter}=?" . preg_quote($v, '/') . "$/";
189                    $foundInArgv = false;
190                    foreach ($argvCopy as $k => $argV) {
191                        if (preg_match($pattern, $argV)) {
192                            array_splice($argvCopy, $k, 1);
193                            $foundInArgv = true;
194                            break;
195                        }
196                    }
197                    if (! $foundInArgv) {
198                        // space separated
199                        $k = array_search("-$letter", $argvCopy);
200                        if ($k !== false) {
201                            array_splice($argvCopy, $k, 2);
202                        }
203                    }
204
205                    // check that value isn't really another option
206                    if (strlen($lettersUsed) > 1) {
207                        $pattern = "/^-[" . str_replace($letter, '', $lettersUsed) . "]/i";
208                        if (preg_match($pattern, $v)) {
209                            $this->addError($letter, "Value was read as another option: %s", $v);
210                            return false;
211                        }
212                    }
213                    if ($arg->assertFile || $arg->assertDir) {
214                        if ($v[0] !== '/' && $v[0] !== '~') {
215                            $this->values["$letter.raw"] = $v;
216                            $v = getcwd() . "/$v";
217                        }
218                    }
219                    if ($arg->assertFile) {
220                        if ($arg->useAsInfile) {
221                            $this->_stdin = $v;
222                        } elseif ($arg->useAsOutfile) {
223                            $this->_stdout = $v;
224                        }
225                        if ($arg->assertReadable && ! is_readable($v)) {
226                            $this->addError($letter, "File not readable: %s", $v);
227                            continue;
228                        }
229                        if ($arg->assertWritable) {
230                            if (is_file($v)) {
231                                if (! is_writable($v)) {
232                                    $this->addError($letter, "File not writable: %s", $v);
233                                }
234                            } else {
235                                if (! is_writable(dirname($v))) {
236                                    $this->addError($letter, "Directory not writable: %s", dirname($v));
237                                }
238                            }
239                        }
240                    } elseif ($arg->assertDir && $arg->assertWritable && ! is_writable($v)) {
241                        $this->addError($letter, "Directory not readable: %s", $v);
242                    }
243                }
244            } else {
245                if ($arg->isRequired()) {
246                    $this->addError($letter, "Missing");
247                }
248            }
249        }
250        $this->moreArgs = $argvCopy;
251        reset($this->moreArgs);
252        return empty($this->errors);
253    }
254
255    /**
256     * Get the full paths of file(s) passed in as unspecified arguments
257     *
258     * @return array
259     */
260    public function getPathArgs()
261    {
262        $r = $this->moreArgs;
263        foreach ($r as $k => $v) {
264            if ($v[0] !== '/' && $v[0] !== '~') {
265                $v = getcwd() . "/$v";
266                $v = str_replace('/./', '/', $v);
267                do {
268                    $v = preg_replace('@/[^/]+/\\.\\./@', '/', $v, 1, $changed);
269                } while ($changed);
270                $r[$k] = $v;
271            }
272        }
273        return $r;
274    }
275
276    /**
277     * Get a short list of errors with options
278     *
279     * @return string
280     */
281    public function getErrorReport()
282    {
283        if (empty($this->errors)) {
284            return '';
285        }
286        $r = "Some arguments did not pass validation:\n";
287        foreach ($this->errors as $letter => $arr) {
288            $r .= "  $letter : " . implode(', ', $arr) . "\n";
289        }
290        $r .= "\n";
291        return $r;
292    }
293
294    /**
295     * @return string
296     */
297    public function getArgumentsListing()
298    {
299        $r = "\n";
300        foreach ($this->_args as $letter => $arg) {
301            /* @var Arg $arg  */
302            $desc = $arg->getDescription();
303            $flag = " -$letter ";
304            if ($arg->mayHaveValue) {
305                $flag .= "[VAL]";
306            } elseif ($arg->mustHaveValue) {
307                $flag .= "VAL";
308            }
309            if ($arg->assertFile) {
310                $flag = str_replace('VAL', 'FILE', $flag);
311            } elseif ($arg->assertDir) {
312                $flag = str_replace('VAL', 'DIR', $flag);
313            }
314            if ($arg->isRequired()) {
315                $desc = "(required) $desc";
316            }
317            $flag = str_pad($flag, 12, " ", STR_PAD_RIGHT);
318            $desc = wordwrap($desc, 70);
319            $r .= $flag . str_replace("\n", "\n            ", $desc) . "\n\n";
320        }
321        return $r;
322    }
323
324    /**
325     * Get resource of open input stream. May be STDIN or a file pointer
326     * to the file specified by an option with 'STDIN'.
327     *
328     * @return resource
329     */
330    public function openInput()
331    {
332        if (null === $this->_stdin) {
333            return STDIN;
334        } else {
335            $this->_stdin = fopen($this->_stdin, 'rb');
336            return $this->_stdin;
337        }
338    }
339
340    public function closeInput()
341    {
342        if (null !== $this->_stdin) {
343            fclose($this->_stdin);
344        }
345    }
346
347    /**
348     * Get resource of open output stream. May be STDOUT or a file pointer
349     * to the file specified by an option with 'STDOUT'. The file will be
350     * truncated to 0 bytes on opening.
351     *
352     * @return resource
353     */
354    public function openOutput()
355    {
356        if (null === $this->_stdout) {
357            return STDOUT;
358        } else {
359            $this->_stdout = fopen($this->_stdout, 'wb');
360            return $this->_stdout;
361        }
362    }
363
364    public function closeOutput()
365    {
366        if (null !== $this->_stdout) {
367            fclose($this->_stdout);
368        }
369    }
370
371    /**
372     * @param string $letter
373     * @param string $msg
374     * @param string $value
375     */
376    protected function addError($letter, $msg, $value = null)
377    {
378        if ($value !== null) {
379            $value = var_export($value, 1);
380        }
381        $this->errors[$letter][] = sprintf($msg, $value);
382    }
383}
384
385