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