4
0

CliOptionsParser.php 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253
  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 ($this->options[$name]->getValueTaken() === CliOption::VALUE_NONE) {
  78. // @phpstan-ignore property.dynamicName
  79. $this->$name = $values !== null;
  80. } elseif (!empty($values)) {
  81. $validValues = [];
  82. $typedValues = [];
  83. switch ($types['type']) {
  84. case 'string':
  85. $typedValues = $values;
  86. break;
  87. case 'int':
  88. $validValues = array_filter($values, static fn($value) => ctype_digit($value));
  89. $typedValues = array_map(static fn($value) => (int)$value, $validValues);
  90. break;
  91. case 'bool':
  92. $validValues = array_filter($values, static fn($value) => filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== null);
  93. $typedValues = array_map(static fn($value) => (bool)filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE), $validValues);
  94. break;
  95. }
  96. if (!empty($typedValues)) {
  97. // @phpstan-ignore property.dynamicName
  98. $this->$name = $types['isArray'] ? $typedValues : array_pop($typedValues);
  99. }
  100. }
  101. }
  102. }
  103. /** @param array<string,string|false>|false $getoptOutput */
  104. private function getoptOutputTransformer($getoptOutput): void {
  105. $getoptOutput = is_array($getoptOutput) ? $getoptOutput : [];
  106. foreach ($getoptOutput as $alias => $value) {
  107. foreach ($this->options as $name => $data) {
  108. if (in_array($alias, $data->getAliases(), true)) {
  109. $this->inputs[$name]['aliasUsed'] = $alias;
  110. $this->inputs[$name]['values'] = $value === false
  111. ? [$data->getOptionalValueDefault()]
  112. : (is_array($value)
  113. ? $value
  114. : [$value]);
  115. }
  116. }
  117. }
  118. }
  119. /**
  120. * @param array<string> $userInputs
  121. * @return list<string>
  122. */
  123. private function getAliasesUsed(array $userInputs, string $regex): array {
  124. $foundAliases = [];
  125. foreach ($userInputs as $input) {
  126. preg_match($regex, $input, $matches);
  127. if (!empty($matches['short'])) {
  128. $foundAliases = array_merge($foundAliases, str_split($matches['short']));
  129. }
  130. if (!empty($matches['long'])) {
  131. $foundAliases[] = $matches['long'];
  132. }
  133. }
  134. return $foundAliases;
  135. }
  136. /**
  137. * @param array<string> $input List of user command-line inputs.
  138. */
  139. private function appendUnknownAliases(array $input): void {
  140. $valid = [];
  141. foreach ($this->options as $option) {
  142. $valid = array_merge($valid, $option->getAliases());
  143. }
  144. $sanitizeInput = $this->getAliasesUsed($input, $this->makeInputRegex());
  145. $unknownAliases = array_diff($sanitizeInput, $valid);
  146. if (empty($unknownAliases)) {
  147. return;
  148. }
  149. foreach ($unknownAliases as $unknownAlias) {
  150. $this->errors[$unknownAlias] = 'unknown option: ' . $unknownAlias;
  151. }
  152. }
  153. /**
  154. * Checks for presence of deprecated aliases.
  155. * @return bool Returns TRUE and generates a deprecation warning if deprecated aliases are present, FALSE otherwise.
  156. */
  157. private function checkForDeprecatedAliasUse(): bool {
  158. $deprecated = [];
  159. $replacements = [];
  160. foreach ($this->inputs as $name => $data) {
  161. if ($data['aliasUsed'] !== null && $data['aliasUsed'] === $this->options[$name]->getDeprecatedAlias()) {
  162. $deprecated[] = $this->options[$name]->getDeprecatedAlias();
  163. $replacements[] = $this->options[$name]->getLongAlias();
  164. }
  165. }
  166. if (empty($deprecated)) {
  167. return false;
  168. }
  169. fwrite(STDERR, "FreshRSS deprecation warning: the CLI option(s): " . implode(', ', $deprecated) .
  170. " are deprecated and will be removed in a future release. Use: " . implode(', ', $replacements) .
  171. " instead\n");
  172. return true;
  173. }
  174. /** @return array{long:array<string>,short:string}*/
  175. private function getGetoptInputs(): array {
  176. $getoptNotation = [
  177. 'none' => '',
  178. 'required' => ':',
  179. 'optional' => '::',
  180. ];
  181. $long = [];
  182. $short = '';
  183. foreach ($this->options as $option) {
  184. $long[] = $option->getLongAlias() . $getoptNotation[$option->getValueTaken()];
  185. $long[] = $option->getDeprecatedAlias() != null ? $option->getDeprecatedAlias() . $getoptNotation[$option->getValueTaken()] : '';
  186. $short .= $option->getShortAlias() != null ? $option->getShortAlias() . $getoptNotation[$option->getValueTaken()] : '';
  187. }
  188. return [
  189. 'long' => array_filter($long),
  190. 'short' => $short
  191. ];
  192. }
  193. private function getUsageMessage(string $command): string {
  194. $required = ['Usage: ' . basename($command)];
  195. $optional = [];
  196. foreach ($this->options as $name => $option) {
  197. $shortAlias = $option->getShortAlias() != null ? '-' . $option->getShortAlias() . ' ' : '';
  198. $longAlias = '--' . $option->getLongAlias() . ($option->getValueTaken() === 'required' ? '=<' . strtolower($name) . '>' : '');
  199. if ($this->inputs[$name]['required']) {
  200. $required[] = $shortAlias . $longAlias;
  201. } else {
  202. $optional[] = '[' . $shortAlias . $longAlias . ']';
  203. }
  204. }
  205. return implode(' ', $required) . ' ' . implode(' ', $optional);
  206. }
  207. private function makeInputRegex(): string {
  208. $shortWithValues = '';
  209. foreach ($this->options as $option) {
  210. if (($option->getValueTaken() === 'required' || $option->getValueTaken() === 'optional') && $option->getShortAlias() != null) {
  211. $shortWithValues .= $option->getShortAlias();
  212. }
  213. }
  214. return $shortWithValues === ''
  215. ? "/^--(?'long'[^=]+)|^-(?<short>\w+)/"
  216. : "/^--(?'long'[^=]+)|^-(?<short>(?(?=\w*[$shortWithValues])[^$shortWithValues]*[$shortWithValues]|\w+))/";
  217. }
  218. }