1 | <?php |
||
2 | |||
3 | namespace clearice\argparser; |
||
4 | |||
5 | |||
6 | /** |
||
7 | * For parsing arguments in ClearIce |
||
8 | * |
||
9 | * @package clearice\argparser |
||
10 | */ |
||
11 | class ArgumentParser |
||
12 | { |
||
13 | /** |
||
14 | * Description to put on top of the help message. |
||
15 | * @var string |
||
16 | */ |
||
17 | private $description; |
||
18 | |||
19 | /** |
||
20 | * A little message for the foot of the help message. |
||
21 | * @var string |
||
22 | */ |
||
23 | private $footer; |
||
24 | |||
25 | /** |
||
26 | * The name of the application. |
||
27 | * @var string |
||
28 | */ |
||
29 | private $name; |
||
30 | |||
31 | /** |
||
32 | * Commands that the application can execute. |
||
33 | * @var array |
||
34 | */ |
||
35 | private $commands = []; |
||
36 | |||
37 | /** |
||
38 | * A cache of all the options added. |
||
39 | * The array keys represents a concatenation of the command and either the short or long name of the option. Elements |
||
40 | * in this array will be the same as those in the options property. However, options that have both a short and long |
||
41 | * name would appear twice. |
||
42 | * @var array |
||
43 | */ |
||
44 | private $optionsCache = []; |
||
45 | |||
46 | /** |
||
47 | * All the possible options for arguments. |
||
48 | * @var array |
||
49 | */ |
||
50 | private $options = []; |
||
51 | |||
52 | /** |
||
53 | * An instance of the help generator. |
||
54 | * @var HelpMessageGenerator |
||
55 | */ |
||
56 | private $helpGenerator; |
||
57 | |||
58 | /** |
||
59 | * An instance of the validator. |
||
60 | * @var Validator |
||
61 | */ |
||
62 | private $validator; |
||
63 | |||
64 | /** |
||
65 | * A reference to a function to be called for exitting the entire application. |
||
66 | * @var callable |
||
67 | */ |
||
68 | private $exitFunction; |
||
69 | |||
70 | /** |
||
71 | * Flag raised when help has been enabled. |
||
72 | * @var bool |
||
73 | */ |
||
74 | private $helpEnabled = false; |
||
75 | |||
76 | /** |
||
77 | * ArgumentParser constructor. |
||
78 | * |
||
79 | * @param ValidatorInterface $validator |
||
80 | * @param HelpGeneratorInterface $helpWriter |
||
81 | */ |
||
82 | 16 | public function __construct(HelpGeneratorInterface $helpWriter = null, ValidatorInterface $validator = null) |
|
83 | { |
||
84 | 16 | $this->helpGenerator = $helpWriter ?? new HelpMessageGenerator(); |
|
85 | 16 | $this->validator = $validator ?? new Validator(); |
|
86 | 16 | $this->exitFunction = function ($code) { exit($code); }; |
|
0 ignored issues
–
show
|
|||
87 | 16 | } |
|
88 | |||
89 | /** |
||
90 | * Add an option to the option cache for easy access through associative arrays. |
||
91 | * The option cache associates arguments with their options. |
||
92 | * |
||
93 | * @param string $identifier |
||
94 | * @param mixed $option |
||
95 | * @throws OptionExistsException |
||
96 | */ |
||
97 | 15 | private function addToOptionCache(string $identifier, $option) : void |
|
98 | { |
||
99 | 15 | if (!isset($option[$identifier])) { |
|
100 | 2 | return; |
|
101 | } |
||
102 | 15 | $cacheKey = "${option['command']}${option[$identifier]}"; |
|
103 | 15 | if (!isset($this->optionsCache[$cacheKey])) { |
|
104 | 15 | $this->optionsCache[$cacheKey] = $option; |
|
105 | } else { |
||
106 | 2 | throw new OptionExistsException( |
|
107 | 2 | "An argument option with $identifier {$option['command']} {$option[$identifier]} already exists." |
|
108 | ); |
||
109 | } |
||
110 | 15 | } |
|
111 | |||
112 | /** |
||
113 | * @param string $command |
||
114 | * @param string $name |
||
115 | * @return mixed |
||
116 | * @throws InvalidArgumentException |
||
117 | */ |
||
118 | 8 | private function retrieveOptionFromCache(string $command, string $name) |
|
119 | { |
||
120 | 8 | $key = $command . $name; |
|
121 | 8 | if(isset($this->optionsCache[$key])) { |
|
122 | 7 | return $this->optionsCache[$key]; |
|
123 | 1 | } else if(isset($this->optionsCache[$name]) && $this->optionsCache[$name]['command'] == "") { |
|
124 | 1 | return $this->optionsCache[$name]; |
|
125 | } else{ |
||
126 | throw new InvalidArgumentException("Unknown option '$name'. Please run with `--help` for more information on valid options."); |
||
127 | } |
||
128 | } |
||
129 | |||
130 | /** |
||
131 | * Add an option to be parsed. |
||
132 | * Arguments are presented as a structured array with the following possible keys. |
||
133 | * |
||
134 | * name: The name of the option prefixed with a double dash -- |
||
135 | * short_name: A shorter single character option prefixed with a single dash - |
||
136 | * type: Required for all options that take values. An option specified without a type is considered to be a |
||
137 | * boolean flag. |
||
138 | * repeats: A boolean value that states whether the option can be repeated or not. Repeatable options are returned |
||
139 | * as arrays. |
||
140 | * default: A default value for the option. |
||
141 | * help: A help message for the option |
||
142 | * |
||
143 | * @param array $option |
||
144 | * @throws OptionExistsException |
||
145 | * @throws InvalidArgumentDescriptionException |
||
146 | * @throws UnknownCommandException |
||
147 | */ |
||
148 | 16 | public function addOption(array $option): void |
|
149 | { |
||
150 | 16 | $this->validator->validateOption($option, $this->commands); |
|
151 | 15 | $option['repeats'] = $option['repeats'] ?? false; |
|
152 | // Save a copy of the original command definition, so it can be spread out if it's an array. |
||
153 | 15 | $commands = $option['command'] ?? ''; |
|
154 | 15 | foreach(is_array($commands) ? $commands : [$commands] as $command) { |
|
155 | 15 | $option['command'] = $command; |
|
156 | 15 | $this->options[] = $option; |
|
157 | 15 | $this->addToOptionCache('name', $option); |
|
158 | 15 | $this->addToOptionCache('short_name', $option); |
|
159 | } |
||
160 | 15 | } |
|
161 | |||
162 | /** |
||
163 | * @param $arguments |
||
164 | * @param $argPointer |
||
165 | * @return mixed |
||
166 | * @throws InvalidValueException |
||
167 | */ |
||
168 | 5 | private function getNextValueOrFail($arguments, &$argPointer, $name) |
|
169 | { |
||
170 | 5 | if (isset($arguments[$argPointer + 1]) && $arguments[$argPointer + 1][0] != '-') { |
|
171 | 3 | $argPointer++; |
|
172 | 3 | return $arguments[$argPointer]; |
|
173 | } else { |
||
174 | 2 | throw new InvalidValueException("A value must be passed along with argument $name."); |
|
175 | } |
||
176 | } |
||
177 | |||
178 | /** |
||
179 | * Parse a long argument that is prefixed with a double dash "--" |
||
180 | * |
||
181 | * @param $arguments |
||
182 | * @param $argPointer |
||
183 | * @throws InvalidValueException |
||
184 | * @throws InvalidArgumentException |
||
185 | */ |
||
186 | 6 | private function parseLongArgument($command, $arguments, &$argPointer, &$output) |
|
187 | { |
||
188 | 6 | $string = substr($arguments[$argPointer], 2); |
|
189 | 6 | preg_match("/(?<name>[a-zA-Z_0-9-]+)(?<equal>=?)(?<value>.*)/", $string, $matches); |
|
190 | 6 | $option = $this->retrieveOptionFromCache($command, $matches['name']); |
|
191 | 6 | $value = true; |
|
192 | |||
193 | 6 | if (isset($option['type'])) { |
|
194 | 4 | if ($matches['equal'] === '=') { |
|
195 | 1 | $value = $matches['value']; |
|
196 | } else { |
||
197 | 4 | $value = $this->getNextValueOrFail($arguments, $argPointer, $matches['name']); |
|
198 | } |
||
199 | } |
||
200 | |||
201 | 5 | $this->assignValue($option, $output, $option['name'], $value); |
|
202 | 5 | } |
|
203 | |||
204 | /** |
||
205 | * Parse a short argument that is prefixed with a single dash '-' |
||
206 | * |
||
207 | * @param $command |
||
208 | * @param $arguments |
||
209 | * @param $argPointer |
||
210 | * @throws InvalidValueException |
||
211 | * @throws InvalidArgumentException |
||
212 | */ |
||
213 | 3 | private function parseShortArgument($command, $arguments, &$argPointer, &$output) |
|
214 | { |
||
215 | 3 | $argument = $arguments[$argPointer]; |
|
216 | 3 | $option = $this->retrieveOptionFromCache($command, substr($argument, 1, 1)); |
|
217 | 3 | $value = true; |
|
218 | |||
219 | 3 | if (isset($option['type'])) { |
|
220 | 3 | if (substr($argument, 2) != "") { |
|
221 | 2 | $value = substr($argument, 2); |
|
222 | } else { |
||
223 | 2 | $value = $this->getNextValueOrFail($arguments, $argPointer, $option['name']); |
|
224 | } |
||
225 | } |
||
226 | |||
227 | 2 | $this->assignValue($option, $output, $option['name'], $value); |
|
228 | 2 | } |
|
229 | |||
230 | 6 | private function assignValue($option, &$output, $key, $value) |
|
231 | { |
||
232 | 6 | if($option['repeats']) { |
|
233 | 1 | $output[$key] = isset($output[$key]) ? array_merge($output[$key], [$value]) : [$value]; |
|
234 | } else { |
||
235 | 5 | $output[$key] = $value; |
|
236 | } |
||
237 | 6 | } |
|
238 | |||
239 | /** |
||
240 | * @param $arguments |
||
241 | * @param $argPointer |
||
242 | * @param $output |
||
243 | * @throws InvalidValueException |
||
244 | * @throws InvalidArgumentException |
||
245 | */ |
||
246 | 8 | private function parseArgumentArray($arguments, &$argPointer, &$output) |
|
247 | { |
||
248 | 8 | $numArguments = count($arguments); |
|
249 | 8 | $command = $output['__command'] ?? ''; |
|
250 | 8 | for (; $argPointer < $numArguments; $argPointer++) { |
|
251 | 8 | $arg = $arguments[$argPointer]; |
|
252 | 8 | if (substr($arg, 0, 2) == "--") { |
|
253 | 6 | $this->parseLongArgument($command, $arguments, $argPointer, $output); |
|
254 | 3 | } else if ($arg[0] == '-') { |
|
255 | 3 | $this->parseShortArgument($command, $arguments, $argPointer, $output); |
|
256 | } else { |
||
257 | 1 | $output['__args'] = isset($output['__args']) ? array_merge($output['__args'], [$arg]) : [$arg]; |
|
258 | } |
||
259 | } |
||
260 | 6 | } |
|
261 | |||
262 | /** |
||
263 | * @param $output |
||
264 | * @throws HelpMessageRequestedException |
||
265 | */ |
||
266 | 6 | private function maybeShowHelp($output) |
|
267 | { |
||
268 | 6 | if ((isset($output['help']) && $output['help'] && $this->helpEnabled)) { |
|
269 | 1 | print $this->getHelpMessage($output['__command'] ?? null); |
|
270 | 1 | throw new HelpMessageRequestedException(); |
|
271 | } |
||
272 | |||
273 | 5 | if(isset($output['__command']) && $output['__command'] == 'help' && $this->helpEnabled) { |
|
274 | print $this->getHelpMessage($output['__args'][0] ?? null); |
||
275 | } |
||
276 | 5 | } |
|
277 | |||
278 | 8 | private function parseCommand($arguments, &$argPointer, &$output) |
|
279 | { |
||
280 | 8 | if (count($arguments) > 1 && isset($this->commands[$arguments[$argPointer]])) { |
|
281 | 2 | $output["__command"] = $arguments[$argPointer]; |
|
282 | 2 | $argPointer++; |
|
283 | } |
||
284 | 8 | } |
|
285 | |||
286 | /** |
||
287 | * @param $parsed |
||
288 | */ |
||
289 | 5 | private function fillInDefaults(&$parsed) |
|
290 | { |
||
291 | 5 | foreach($this->options as $option) { |
|
292 | 5 | if(!isset($parsed[$option['name']]) && isset($option['default']) && ($option['command'] == ($parsed['__command'] ?? "") || $option['command'] == '')) { |
|
293 | 1 | $parsed[$option['name']] = $option['default']; |
|
294 | } |
||
295 | } |
||
296 | 5 | } |
|
297 | |||
298 | /** |
||
299 | * A function called to exit the application whenever there's a parsing error or after requested help has been |
||
300 | * displayed/ |
||
301 | * |
||
302 | * @param callable $exit |
||
303 | */ |
||
304 | 1 | public function setExitCallback(callable $exit) |
|
305 | { |
||
306 | 1 | $this->exitFunction = $exit; |
|
307 | 1 | } |
|
308 | |||
309 | /** |
||
310 | * Parses command line arguments and return a structured array of options and their associated values. |
||
311 | * |
||
312 | * @param array $arguments An optional array of arguments that would be parsed instead of those passed to the CLI. |
||
313 | * @return array |
||
314 | * @throws InvalidValueException |
||
315 | */ |
||
316 | 8 | public function parse($arguments = null) |
|
317 | { |
||
318 | try{ |
||
319 | 8 | global $argv; |
|
320 | 8 | $arguments = $arguments ?? $argv; |
|
321 | 8 | $argPointer = 1; |
|
322 | 8 | $parsed = []; |
|
323 | 8 | $this->name = $this->name ?? $arguments[0]; |
|
324 | 8 | $this->parseCommand($arguments, $argPointer, $parsed); |
|
325 | 8 | $this->parseArgumentArray($arguments, $argPointer, $parsed); |
|
326 | 6 | $this->maybeShowHelp($parsed); |
|
327 | 5 | $this->validator->validateArguments($this->options, $parsed); |
|
328 | 5 | $this->fillInDefaults($parsed); |
|
329 | 5 | $parsed['__executed'] = $this->name; |
|
330 | 5 | return $parsed; |
|
331 | 3 | } catch (HelpMessageRequestedException $exception) { |
|
332 | 1 | ($this->exitFunction)(0); |
|
333 | 2 | } catch (InvalidArgumentException $exception) { |
|
334 | print $exception->getMessage() . PHP_EOL; |
||
335 | ($this->exitFunction)(1024); |
||
336 | } |
||
337 | 1 | } |
|
338 | |||
339 | /** |
||
340 | * Enables help messages so they are shown automatically when the appropriate argument (`--help` or `help`) is passed. |
||
341 | * This method also allows you to optionally pass the name of the application, a description header for the help |
||
342 | * message and a footer. |
||
343 | * |
||
344 | * @param string $name The name of the application binary |
||
345 | * @param string $description A description to be displayed on top of the help message |
||
346 | * @param string $footer A footer message to be displayed after the help message |
||
347 | * |
||
348 | * @throws InvalidArgumentDescriptionException |
||
349 | * @throws OptionExistsException |
||
350 | * @throws UnknownCommandException |
||
351 | */ |
||
352 | 2 | public function enableHelp(string $description = null, string $footer = null, string $name = null) : void |
|
353 | { |
||
354 | 2 | global $argv; |
|
355 | 2 | $this->name = $name ?? $argv[0]; |
|
356 | 2 | $this->description = $description; |
|
357 | 2 | $this->footer = $footer; |
|
358 | 2 | $this->helpEnabled = true; |
|
359 | 2 | $this->addOption([ |
|
360 | 2 | 'name' => 'help', |
|
361 | 'short_name' => 'h', 'help' => "display this help message" |
||
362 | ]); |
||
363 | 2 | if($this->commands) { |
|
0 ignored issues
–
show
The expression
$this->commands of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.
This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent. Consider making the comparison explicit by using ![]() |
|||
364 | 1 | $this->addCommand(['name' => 'help', 'help' => "display help for any command. Usage: {$this->name} help [command]"]); |
|
365 | 1 | foreach($this->commands as $command) { |
|
366 | 1 | $this->addOption([ |
|
367 | 1 | 'name' => 'help', |
|
368 | 1 | 'help' => 'display this help message', |
|
369 | 1 | 'command' => $command['name'] |
|
370 | ]); |
||
371 | } |
||
372 | } |
||
373 | 2 | } |
|
374 | |||
375 | 2 | public function getHelpMessage($command = '') |
|
376 | { |
||
377 | 2 | return $this->helpGenerator->generate( |
|
378 | 2 | $this->name, $command ?? null, |
|
379 | 2 | ['options' => $this->options, 'commands' => $this->commands], |
|
380 | 2 | $this->description, $this->footer |
|
381 | ); |
||
382 | } |
||
383 | |||
384 | /** |
||
385 | * @param $command |
||
386 | * @throws CommandExistsException |
||
387 | * @throws InvalidArgumentDescriptionException |
||
388 | */ |
||
389 | 7 | public function addCommand($command) |
|
390 | { |
||
391 | 7 | $this->validator->validateCommand($command, $this->commands); |
|
392 | 7 | $this->commands[$command['name']] = $command; |
|
393 | 7 | } |
|
394 | } |
||
395 |
In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.