123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384 |
- <?php
- namespace MrClay;
- use MrClay\Cli\Arg;
- use InvalidArgumentException;
- /**
- * Forms a front controller for a console app, handling and validating arguments (options)
- *
- * Instantiate, add arguments, then call validate(). Afterwards, the user's valid arguments
- * and their values will be available in $cli->values.
- *
- * You may also specify that some arguments be used to provide input/output. By communicating
- * solely through the file pointers provided by openInput()/openOutput(), you can make your
- * app more flexible to end users.
- *
- * @author Steve Clay <steve@mrclay.org>
- * @license http://www.opensource.org/licenses/mit-license.php MIT License
- */
- class Cli {
-
- /**
- * @var array validation errors
- */
- public $errors = array();
-
- /**
- * @var array option values available after validation.
- *
- * E.g. array(
- * 'a' => false // option was missing
- * ,'b' => true // option was present
- * ,'c' => "Hello" // option had value
- * ,'f' => "/home/user/file" // file path from root
- * ,'f.raw' => "~/file" // file path as given to option
- * )
- */
- public $values = array();
- /**
- * @var array
- */
- public $moreArgs = array();
- /**
- * @var array
- */
- public $debug = array();
- /**
- * @var bool The user wants help info
- */
- public $isHelpRequest = false;
- /**
- * @var Arg[]
- */
- protected $_args = array();
- /**
- * @var resource
- */
- protected $_stdin = null;
- /**
- * @var resource
- */
- protected $_stdout = null;
-
- /**
- * @param bool $exitIfNoStdin (default true) Exit() if STDIN is not defined
- */
- public function __construct($exitIfNoStdin = true)
- {
- if ($exitIfNoStdin && ! defined('STDIN')) {
- exit('This script is for command-line use only.');
- }
- if (isset($GLOBALS['argv'][1])
- && ($GLOBALS['argv'][1] === '-?' || $GLOBALS['argv'][1] === '--help')) {
- $this->isHelpRequest = true;
- }
- }
- /**
- * @param Arg|string $letter
- * @return Arg
- */
- public function addOptionalArg($letter)
- {
- return $this->addArgument($letter, false);
- }
- /**
- * @param Arg|string $letter
- * @return Arg
- */
- public function addRequiredArg($letter)
- {
- return $this->addArgument($letter, true);
- }
- /**
- * @param string $letter
- * @param bool $required
- * @param Arg|null $arg
- * @return Arg
- * @throws InvalidArgumentException
- */
- public function addArgument($letter, $required, Arg $arg = null)
- {
- if (! preg_match('/^[a-zA-Z]$/', $letter)) {
- throw new InvalidArgumentException('$letter must be in [a-zA-Z]');
- }
- if (! $arg) {
- $arg = new Arg($required);
- }
- $this->_args[$letter] = $arg;
- return $arg;
- }
- /**
- * @param string $letter
- * @return Arg|null
- */
- public function getArgument($letter)
- {
- return isset($this->_args[$letter]) ? $this->_args[$letter] : null;
- }
- /*
- * Read and validate options
- *
- * @return bool true if all options are valid
- */
- public function validate()
- {
- $options = '';
- $this->errors = array();
- $this->values = array();
- $this->_stdin = null;
-
- if ($this->isHelpRequest) {
- return false;
- }
-
- $lettersUsed = '';
- foreach ($this->_args as $letter => $arg) {
- /* @var Arg $arg */
- $options .= $letter;
- $lettersUsed .= $letter;
-
- if ($arg->mayHaveValue || $arg->mustHaveValue) {
- $options .= ($arg->mustHaveValue ? ':' : '::');
- }
- }
- $this->debug['argv'] = $GLOBALS['argv'];
- $argvCopy = array_slice($GLOBALS['argv'], 1);
- $o = getopt($options);
- $this->debug['getopt_options'] = $options;
- $this->debug['getopt_return'] = $o;
- foreach ($this->_args as $letter => $arg) {
- /* @var Arg $arg */
- $this->values[$letter] = false;
- if (isset($o[$letter])) {
- if (is_bool($o[$letter])) {
- // remove from argv copy
- $k = array_search("-$letter", $argvCopy);
- if ($k !== false) {
- array_splice($argvCopy, $k, 1);
- }
- if ($arg->mustHaveValue) {
- $this->addError($letter, "Missing value");
- } else {
- $this->values[$letter] = true;
- }
- } else {
- // string
- $this->values[$letter] = $o[$letter];
- $v =& $this->values[$letter];
- // remove from argv copy
- // first look for -ovalue or -o=value
- $pattern = "/^-{$letter}=?" . preg_quote($v, '/') . "$/";
- $foundInArgv = false;
- foreach ($argvCopy as $k => $argV) {
- if (preg_match($pattern, $argV)) {
- array_splice($argvCopy, $k, 1);
- $foundInArgv = true;
- break;
- }
- }
- if (! $foundInArgv) {
- // space separated
- $k = array_search("-$letter", $argvCopy);
- if ($k !== false) {
- array_splice($argvCopy, $k, 2);
- }
- }
-
- // check that value isn't really another option
- if (strlen($lettersUsed) > 1) {
- $pattern = "/^-[" . str_replace($letter, '', $lettersUsed) . "]/i";
- if (preg_match($pattern, $v)) {
- $this->addError($letter, "Value was read as another option: %s", $v);
- return false;
- }
- }
- if ($arg->assertFile || $arg->assertDir) {
- if ($v[0] !== '/' && $v[0] !== '~') {
- $this->values["$letter.raw"] = $v;
- $v = getcwd() . "/$v";
- }
- }
- if ($arg->assertFile) {
- if ($arg->useAsInfile) {
- $this->_stdin = $v;
- } elseif ($arg->useAsOutfile) {
- $this->_stdout = $v;
- }
- if ($arg->assertReadable && ! is_readable($v)) {
- $this->addError($letter, "File not readable: %s", $v);
- continue;
- }
- if ($arg->assertWritable) {
- if (is_file($v)) {
- if (! is_writable($v)) {
- $this->addError($letter, "File not writable: %s", $v);
- }
- } else {
- if (! is_writable(dirname($v))) {
- $this->addError($letter, "Directory not writable: %s", dirname($v));
- }
- }
- }
- } elseif ($arg->assertDir && $arg->assertWritable && ! is_writable($v)) {
- $this->addError($letter, "Directory not readable: %s", $v);
- }
- }
- } else {
- if ($arg->isRequired()) {
- $this->addError($letter, "Missing");
- }
- }
- }
- $this->moreArgs = $argvCopy;
- reset($this->moreArgs);
- return empty($this->errors);
- }
- /**
- * Get the full paths of file(s) passed in as unspecified arguments
- *
- * @return array
- */
- public function getPathArgs()
- {
- $r = $this->moreArgs;
- foreach ($r as $k => $v) {
- if ($v[0] !== '/' && $v[0] !== '~') {
- $v = getcwd() . "/$v";
- $v = str_replace('/./', '/', $v);
- do {
- $v = preg_replace('@/[^/]+/\\.\\./@', '/', $v, 1, $changed);
- } while ($changed);
- $r[$k] = $v;
- }
- }
- return $r;
- }
-
- /**
- * Get a short list of errors with options
- *
- * @return string
- */
- public function getErrorReport()
- {
- if (empty($this->errors)) {
- return '';
- }
- $r = "Some arguments did not pass validation:\n";
- foreach ($this->errors as $letter => $arr) {
- $r .= " $letter : " . implode(', ', $arr) . "\n";
- }
- $r .= "\n";
- return $r;
- }
- /**
- * @return string
- */
- public function getArgumentsListing()
- {
- $r = "\n";
- foreach ($this->_args as $letter => $arg) {
- /* @var Arg $arg */
- $desc = $arg->getDescription();
- $flag = " -$letter ";
- if ($arg->mayHaveValue) {
- $flag .= "[VAL]";
- } elseif ($arg->mustHaveValue) {
- $flag .= "VAL";
- }
- if ($arg->assertFile) {
- $flag = str_replace('VAL', 'FILE', $flag);
- } elseif ($arg->assertDir) {
- $flag = str_replace('VAL', 'DIR', $flag);
- }
- if ($arg->isRequired()) {
- $desc = "(required) $desc";
- }
- $flag = str_pad($flag, 12, " ", STR_PAD_RIGHT);
- $desc = wordwrap($desc, 70);
- $r .= $flag . str_replace("\n", "\n ", $desc) . "\n\n";
- }
- return $r;
- }
-
- /**
- * Get resource of open input stream. May be STDIN or a file pointer
- * to the file specified by an option with 'STDIN'.
- *
- * @return resource
- */
- public function openInput()
- {
- if (null === $this->_stdin) {
- return STDIN;
- } else {
- $this->_stdin = fopen($this->_stdin, 'rb');
- return $this->_stdin;
- }
- }
-
- public function closeInput()
- {
- if (null !== $this->_stdin) {
- fclose($this->_stdin);
- }
- }
-
- /**
- * Get resource of open output stream. May be STDOUT or a file pointer
- * to the file specified by an option with 'STDOUT'. The file will be
- * truncated to 0 bytes on opening.
- *
- * @return resource
- */
- public function openOutput()
- {
- if (null === $this->_stdout) {
- return STDOUT;
- } else {
- $this->_stdout = fopen($this->_stdout, 'wb');
- return $this->_stdout;
- }
- }
-
- public function closeOutput()
- {
- if (null !== $this->_stdout) {
- fclose($this->_stdout);
- }
- }
- /**
- * @param string $letter
- * @param string $msg
- * @param string $value
- */
- protected function addError($letter, $msg, $value = null)
- {
- if ($value !== null) {
- $value = var_export($value, 1);
- }
- $this->errors[$letter][] = sprintf($msg, $value);
- }
- }
|