initial commit
[namibia] / public / min / lib / MrClay / Cli.php
1 <?php 
2
3 namespace MrClay;
4
5 use MrClay\Cli\Arg;
6 use 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  */
21 class 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