CliOptionsParser.php 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249
  1. <?php
  2. declare(strict_types=1);
  3. abstract class CliOptionsParser {
  4. /** @var array<string,CliOption> */
  5. private array $options = [];
  6. /** @var array<string,array{defaultInput:?string[],required:?bool,aliasUsed:?string,values:?string[]}> */
  7. private array $inputs = [];
  8. /** @var array<string,string> $errors */
  9. public array $errors = [];
  10. public string $usage = '';
  11. public function __construct() {
  12. /** @var array<string> $argv */
  13. global $argv;
  14. $this->usage = $this->getUsageMessage($argv[0]);
  15. $this->parseInput();
  16. $this->appendUnknownAliases($argv);
  17. $this->appendInvalidValues();
  18. $this->appendTypedValidValues();
  19. }
  20. private function parseInput(): void {
  21. $getoptInputs = $this->getGetoptInputs();
  22. // @phpstan-ignore argument.type
  23. $this->getoptOutputTransformer(getopt($getoptInputs['short'], $getoptInputs['long']));
  24. $this->checkForDeprecatedAliasUse();
  25. }
  26. /** Adds an option that produces an error message if not set. */
  27. protected function addRequiredOption(string $name, CliOption $option): void {
  28. $this->inputs[$name] = [
  29. 'defaultInput' => null,
  30. 'required' => true,
  31. 'aliasUsed' => null,
  32. 'values' => null,
  33. ];
  34. $this->options[$name] = $option;
  35. }
  36. /**
  37. * Adds an optional option.
  38. * @param string $defaultInput If not null this value is received as input in all cases where no
  39. * user input is present. e.g. set this if you want an option to always return a value.
  40. */
  41. protected function addOption(string $name, CliOption $option, ?string $defaultInput = null): void {
  42. $this->inputs[$name] = [
  43. 'defaultInput' => is_string($defaultInput) ? [$defaultInput] : $defaultInput,
  44. 'required' => null,
  45. 'aliasUsed' => null,
  46. 'values' => null,
  47. ];
  48. $this->options[$name] = $option;
  49. }
  50. private function appendInvalidValues(): void {
  51. foreach ($this->options as $name => $option) {
  52. if ($this->inputs[$name]['required'] && $this->inputs[$name]['values'] === null) {
  53. $this->errors[$name] = 'invalid input: ' . $option->getLongAlias() . ' cannot be empty';
  54. }
  55. }
  56. foreach ($this->inputs as $name => $input) {
  57. foreach ($input['values'] ?? $input['defaultInput'] ?? [] as $value) {
  58. switch ($this->options[$name]->getTypes()['type']) {
  59. case 'int':
  60. if (!ctype_digit($value)) {
  61. $this->errors[$name] = 'invalid input: ' . $input['aliasUsed'] . ' must be an integer';
  62. }
  63. break;
  64. case 'bool':
  65. if (filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) === null) {
  66. $this->errors[$name] = 'invalid input: ' . $input['aliasUsed'] . ' must be a boolean';
  67. }
  68. break;
  69. }
  70. }
  71. }
  72. }
  73. private function appendTypedValidValues(): void {
  74. foreach ($this->inputs as $name => $input) {
  75. $values = $input['values'] ?? $input['defaultInput'] ?? null;
  76. $types = $this->options[$name]->getTypes();
  77. if (!empty($values)) {
  78. $validValues = [];
  79. $typedValues = [];
  80. switch ($types['type']) {
  81. case 'string':
  82. $typedValues = $values;
  83. break;
  84. case 'int':
  85. $validValues = array_filter($values, static fn($value) => ctype_digit($value));
  86. $typedValues = array_map(static fn($value) => (int)$value, $validValues);
  87. break;
  88. case 'bool':
  89. $validValues = array_filter($values, static fn($value) => filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== null);
  90. $typedValues = array_map(static fn($value) => (bool)filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE), $validValues);
  91. break;
  92. }
  93. if (!empty($typedValues)) {
  94. // @phpstan-ignore property.dynamicName
  95. $this->$name = $types['isArray'] ? $typedValues : array_pop($typedValues);
  96. }
  97. }
  98. }
  99. }
  100. /** @param array<string,string|false>|false $getoptOutput */
  101. private function getoptOutputTransformer($getoptOutput): void {
  102. $getoptOutput = is_array($getoptOutput) ? $getoptOutput : [];
  103. foreach ($getoptOutput as $alias => $value) {
  104. foreach ($this->options as $name => $data) {
  105. if (in_array($alias, $data->getAliases(), true)) {
  106. $this->inputs[$name]['aliasUsed'] = $alias;
  107. $this->inputs[$name]['values'] = $value === false
  108. ? [$data->getOptionalValueDefault()]
  109. : (is_array($value)
  110. ? $value
  111. : [$value]);
  112. }
  113. }
  114. }
  115. }
  116. /**
  117. * @param array<string> $userInputs
  118. * @return list<string>
  119. */
  120. private function getAliasesUsed(array $userInputs, string $regex): array {
  121. $foundAliases = [];
  122. foreach ($userInputs as $input) {
  123. preg_match($regex, $input, $matches);
  124. if (!empty($matches['short'])) {
  125. $foundAliases = array_merge($foundAliases, str_split($matches['short']));
  126. }
  127. if (!empty($matches['long'])) {
  128. $foundAliases[] = $matches['long'];
  129. }
  130. }
  131. return $foundAliases;
  132. }
  133. /**
  134. * @param array<string> $input List of user command-line inputs.
  135. */
  136. private function appendUnknownAliases(array $input): void {
  137. $valid = [];
  138. foreach ($this->options as $option) {
  139. $valid = array_merge($valid, $option->getAliases());
  140. }
  141. $sanitizeInput = $this->getAliasesUsed($input, $this->makeInputRegex());
  142. $unknownAliases = array_diff($sanitizeInput, $valid);
  143. if (empty($unknownAliases)) {
  144. return;
  145. }
  146. foreach ($unknownAliases as $unknownAlias) {
  147. $this->errors[$unknownAlias] = 'unknown option: ' . $unknownAlias;
  148. }
  149. }
  150. /**
  151. * Checks for presence of deprecated aliases.
  152. * @return bool Returns TRUE and generates a deprecation warning if deprecated aliases are present, FALSE otherwise.
  153. */
  154. private function checkForDeprecatedAliasUse(): bool {
  155. $deprecated = [];
  156. $replacements = [];
  157. foreach ($this->inputs as $name => $data) {
  158. if ($data['aliasUsed'] !== null && $data['aliasUsed'] === $this->options[$name]->getDeprecatedAlias()) {
  159. $deprecated[] = $this->options[$name]->getDeprecatedAlias();
  160. $replacements[] = $this->options[$name]->getLongAlias();
  161. }
  162. }
  163. if (empty($deprecated)) {
  164. return false;
  165. }
  166. fwrite(STDERR, "FreshRSS deprecation warning: the CLI option(s): " . implode(', ', $deprecated) .
  167. " are deprecated and will be removed in a future release. Use: " . implode(', ', $replacements) .
  168. " instead\n");
  169. return true;
  170. }
  171. /** @return array{long:array<string>,short:string}*/
  172. private function getGetoptInputs(): array {
  173. $getoptNotation = [
  174. 'none' => '',
  175. 'required' => ':',
  176. 'optional' => '::',
  177. ];
  178. $long = [];
  179. $short = '';
  180. foreach ($this->options as $option) {
  181. $long[] = $option->getLongAlias() . $getoptNotation[$option->getValueTaken()];
  182. $long[] = $option->getDeprecatedAlias() != null ? $option->getDeprecatedAlias() . $getoptNotation[$option->getValueTaken()] : '';
  183. $short .= $option->getShortAlias() != null ? $option->getShortAlias() . $getoptNotation[$option->getValueTaken()] : '';
  184. }
  185. return [
  186. 'long' => array_filter($long),
  187. 'short' => $short
  188. ];
  189. }
  190. private function getUsageMessage(string $command): string {
  191. $required = ['Usage: ' . basename($command)];
  192. $optional = [];
  193. foreach ($this->options as $name => $option) {
  194. $shortAlias = $option->getShortAlias() != null ? '-' . $option->getShortAlias() . ' ' : '';
  195. $longAlias = '--' . $option->getLongAlias() . ($option->getValueTaken() === 'required' ? '=<' . strtolower($name) . '>' : '');
  196. if ($this->inputs[$name]['required']) {
  197. $required[] = $shortAlias . $longAlias;
  198. } else {
  199. $optional[] = '[' . $shortAlias . $longAlias . ']';
  200. }
  201. }
  202. return implode(' ', $required) . ' ' . implode(' ', $optional);
  203. }
  204. private function makeInputRegex(): string {
  205. $shortWithValues = '';
  206. foreach ($this->options as $option) {
  207. if (($option->getValueTaken() === 'required' || $option->getValueTaken() === 'optional') && $option->getShortAlias() != null) {
  208. $shortWithValues .= $option->getShortAlias();
  209. }
  210. }
  211. return $shortWithValues === ''
  212. ? "/^--(?'long'[^=]+)|^-(?<short>\w+)/"
  213. : "/^--(?'long'[^=]+)|^-(?<short>(?(?=\w*[$shortWithValues])[^$shortWithValues]*[$shortWithValues]|\w+))/";
  214. }
  215. }