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