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