Cli.php 11 KB

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