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