Cli.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381
  1. <?php
  2. namespace MrClay;
  3. /**
  4. * Forms a front controller for a console app, handling and validating arguments (options)
  5. *
  6. * Instantiate, add arguments, then call validate(). Afterwards, the user's valid arguments
  7. * and their values will be available in $cli->values.
  8. *
  9. * You may also specify that some arguments be used to provide input/output. By communicating
  10. * solely through the file pointers provided by openInput()/openOutput(), you can make your
  11. * app more flexible to end users.
  12. *
  13. * @author Steve Clay <steve@mrclay.org>
  14. * @license http://www.opensource.org/licenses/mit-license.php MIT License
  15. */
  16. class Cli {
  17. /**
  18. * @var array validation errors
  19. */
  20. public $errors = array();
  21. /**
  22. * @var array option values available after validation.
  23. *
  24. * E.g. array(
  25. * 'a' => false // option was missing
  26. * ,'b' => true // option was present
  27. * ,'c' => "Hello" // option had value
  28. * ,'f' => "/home/user/file" // file path from root
  29. * ,'f.raw' => "~/file" // file path as given to option
  30. * )
  31. */
  32. public $values = array();
  33. /**
  34. * @var array
  35. */
  36. public $moreArgs = array();
  37. /**
  38. * @var array
  39. */
  40. public $debug = array();
  41. /**
  42. * @var bool The user wants help info
  43. */
  44. public $isHelpRequest = false;
  45. /**
  46. * @var array of Cli\Arg
  47. */
  48. protected $_args = array();
  49. /**
  50. * @var resource
  51. */
  52. protected $_stdin = null;
  53. /**
  54. * @var resource
  55. */
  56. protected $_stdout = null;
  57. /**
  58. * @param bool $exitIfNoStdin (default true) Exit() if STDIN is not defined
  59. */
  60. public function __construct($exitIfNoStdin = true)
  61. {
  62. if ($exitIfNoStdin && ! defined('STDIN')) {
  63. exit('This script is for command-line use only.');
  64. }
  65. if (isset($GLOBALS['argv'][1])
  66. && ($GLOBALS['argv'][1] === '-?' || $GLOBALS['argv'][1] === '--help')) {
  67. $this->isHelpRequest = true;
  68. }
  69. }
  70. /**
  71. * @param Cli\Arg|string $letter
  72. * @return Cli\Arg
  73. */
  74. public function addOptionalArg($letter)
  75. {
  76. return $this->addArgument($letter, false);
  77. }
  78. /**
  79. * @param Cli\Arg|string $letter
  80. * @return Cli\Arg
  81. */
  82. public function addRequiredArg($letter)
  83. {
  84. return $this->addArgument($letter, true);
  85. }
  86. /**
  87. * @param string $letter
  88. * @param bool $required
  89. * @param Cli\Arg|null $arg
  90. * @return Cli\Arg
  91. * @throws \InvalidArgumentException
  92. */
  93. public function addArgument($letter, $required, Cli\Arg $arg = null)
  94. {
  95. if (! preg_match('/^[a-zA-Z]$/', $letter)) {
  96. throw new \InvalidArgumentException('$letter must be in [a-zA-z]');
  97. }
  98. if (! $arg) {
  99. $arg = new Cli\Arg($required);
  100. }
  101. $this->_args[$letter] = $arg;
  102. return $arg;
  103. }
  104. /**
  105. * @param string $letter
  106. * @return Cli\Arg|null
  107. */
  108. public function getArgument($letter)
  109. {
  110. return isset($this->_args[$letter]) ? $this->_args[$letter] : null;
  111. }
  112. /*
  113. * Read and validate options
  114. *
  115. * @return bool true if all options are valid
  116. */
  117. public function validate()
  118. {
  119. $options = '';
  120. $this->errors = array();
  121. $this->values = array();
  122. $this->_stdin = null;
  123. if ($this->isHelpRequest) {
  124. return false;
  125. }
  126. $lettersUsed = '';
  127. foreach ($this->_args as $letter => $arg) {
  128. /* @var Cli\Arg $arg */
  129. $options .= $letter;
  130. $lettersUsed .= $letter;
  131. if ($arg->mayHaveValue || $arg->mustHaveValue) {
  132. $options .= ($arg->mustHaveValue ? ':' : '::');
  133. }
  134. }
  135. $this->debug['argv'] = $GLOBALS['argv'];
  136. $argvCopy = array_slice($GLOBALS['argv'], 1);
  137. $o = getopt($options);
  138. $this->debug['getopt_options'] = $options;
  139. $this->debug['getopt_return'] = $o;
  140. foreach ($this->_args as $letter => $arg) {
  141. /* @var Cli\Arg $arg */
  142. $this->values[$letter] = false;
  143. if (isset($o[$letter])) {
  144. if (is_bool($o[$letter])) {
  145. // remove from argv copy
  146. $k = array_search("-$letter", $argvCopy);
  147. if ($k !== false) {
  148. array_splice($argvCopy, $k, 1);
  149. }
  150. if ($arg->mustHaveValue) {
  151. $this->addError($letter, "Missing value");
  152. } else {
  153. $this->values[$letter] = true;
  154. }
  155. } else {
  156. // string
  157. $this->values[$letter] = $o[$letter];
  158. $v =& $this->values[$letter];
  159. // remove from argv copy
  160. // first look for -ovalue or -o=value
  161. $pattern = "/^-{$letter}=?" . preg_quote($v, '/') . "$/";
  162. $foundInArgv = false;
  163. foreach ($argvCopy as $k => $argV) {
  164. if (preg_match($pattern, $argV)) {
  165. array_splice($argvCopy, $k, 1);
  166. $foundInArgv = true;
  167. break;
  168. }
  169. }
  170. if (! $foundInArgv) {
  171. // space separated
  172. $k = array_search("-$letter", $argvCopy);
  173. if ($k !== false) {
  174. array_splice($argvCopy, $k, 2);
  175. }
  176. }
  177. // check that value isn't really another option
  178. if (strlen($lettersUsed) > 1) {
  179. $pattern = "/^-[" . str_replace($letter, '', $lettersUsed) . "]/i";
  180. if (preg_match($pattern, $v)) {
  181. $this->addError($letter, "Value was read as another option: %s", $v);
  182. return false;
  183. }
  184. }
  185. if ($arg->assertFile || $arg->assertDir) {
  186. if ($v[0] !== '/' && $v[0] !== '~') {
  187. $this->values["$letter.raw"] = $v;
  188. $v = getcwd() . "/$v";
  189. }
  190. }
  191. if ($arg->assertFile) {
  192. if ($arg->useAsInfile) {
  193. $this->_stdin = $v;
  194. } elseif ($arg->useAsOutfile) {
  195. $this->_stdout = $v;
  196. }
  197. if ($arg->assertReadable && ! is_readable($v)) {
  198. $this->addError($letter, "File not readable: %s", $v);
  199. continue;
  200. }
  201. if ($arg->assertWritable) {
  202. if (is_file($v)) {
  203. if (! is_writable($v)) {
  204. $this->addError($letter, "File not writable: %s", $v);
  205. }
  206. } else {
  207. if (! is_writable(dirname($v))) {
  208. $this->addError($letter, "Directory not writable: %s", dirname($v));
  209. }
  210. }
  211. }
  212. } elseif ($arg->assertDir && $arg->assertWritable && ! is_writable($v)) {
  213. $this->addError($letter, "Directory not readable: %s", $v);
  214. }
  215. }
  216. } else {
  217. if ($arg->isRequired()) {
  218. $this->addError($letter, "Missing");
  219. }
  220. }
  221. }
  222. $this->moreArgs = $argvCopy;
  223. reset($this->moreArgs);
  224. return empty($this->errors);
  225. }
  226. /**
  227. * Get the full paths of file(s) passed in as unspecified arguments
  228. *
  229. * @return array
  230. */
  231. public function getPathArgs()
  232. {
  233. $r = $this->moreArgs;
  234. foreach ($r as $k => $v) {
  235. if ($v[0] !== '/' && $v[0] !== '~') {
  236. $v = getcwd() . "/$v";
  237. $v = str_replace('/./', '/', $v);
  238. do {
  239. $v = preg_replace('@/[^/]+/\\.\\./@', '/', $v, 1, $changed);
  240. } while ($changed);
  241. $r[$k] = $v;
  242. }
  243. }
  244. return $r;
  245. }
  246. /**
  247. * Get a short list of errors with options
  248. *
  249. * @return string
  250. */
  251. public function getErrorReport()
  252. {
  253. if (empty($this->errors)) {
  254. return '';
  255. }
  256. $r = "Some arguments did not pass validation:\n";
  257. foreach ($this->errors as $letter => $arr) {
  258. $r .= " $letter : " . implode(', ', $arr) . "\n";
  259. }
  260. $r .= "\n";
  261. return $r;
  262. }
  263. /**
  264. * @return string
  265. */
  266. public function getArgumentsListing()
  267. {
  268. $r = "\n";
  269. foreach ($this->_args as $letter => $arg) {
  270. /* @var Cli\Arg $arg */
  271. $desc = $arg->getDescription();
  272. $flag = " -$letter ";
  273. if ($arg->mayHaveValue) {
  274. $flag .= "[VAL]";
  275. } elseif ($arg->mustHaveValue) {
  276. $flag .= "VAL";
  277. }
  278. if ($arg->assertFile) {
  279. $flag = str_replace('VAL', 'FILE', $flag);
  280. } elseif ($arg->assertDir) {
  281. $flag = str_replace('VAL', 'DIR', $flag);
  282. }
  283. if ($arg->isRequired()) {
  284. $desc = "(required) $desc";
  285. }
  286. $flag = str_pad($flag, 12, " ", STR_PAD_RIGHT);
  287. $desc = wordwrap($desc, 70);
  288. $r .= $flag . str_replace("\n", "\n ", $desc) . "\n\n";
  289. }
  290. return $r;
  291. }
  292. /**
  293. * Get resource of open input stream. May be STDIN or a file pointer
  294. * to the file specified by an option with 'STDIN'.
  295. *
  296. * @return resource
  297. */
  298. public function openInput()
  299. {
  300. if (null === $this->_stdin) {
  301. return STDIN;
  302. } else {
  303. $this->_stdin = fopen($this->_stdin, 'rb');
  304. return $this->_stdin;
  305. }
  306. }
  307. public function closeInput()
  308. {
  309. if (null !== $this->_stdin) {
  310. fclose($this->_stdin);
  311. }
  312. }
  313. /**
  314. * Get resource of open output stream. May be STDOUT or a file pointer
  315. * to the file specified by an option with 'STDOUT'. The file will be
  316. * truncated to 0 bytes on opening.
  317. *
  318. * @return resource
  319. */
  320. public function openOutput()
  321. {
  322. if (null === $this->_stdout) {
  323. return STDOUT;
  324. } else {
  325. $this->_stdout = fopen($this->_stdout, 'wb');
  326. return $this->_stdout;
  327. }
  328. }
  329. public function closeOutput()
  330. {
  331. if (null !== $this->_stdout) {
  332. fclose($this->_stdout);
  333. }
  334. }
  335. /**
  336. * @param string $letter
  337. * @param string $msg
  338. * @param string $value
  339. */
  340. protected function addError($letter, $msg, $value = null)
  341. {
  342. if ($value !== null) {
  343. $value = var_export($value, 1);
  344. }
  345. $this->errors[$letter][] = sprintf($msg, $value);
  346. }
  347. }