1: <?php declare(strict_types=1);
2:
3: namespace Salient\Cli;
4:
5: use Salient\Cli\Exception\CliInvalidArgumentsException;
6: use Salient\Cli\Exception\CliUnknownValueException;
7: use Salient\Console\ConsoleFormatter as Formatter;
8: use Salient\Contract\Cli\CliApplicationInterface;
9: use Salient\Contract\Cli\CliCommandInterface;
10: use Salient\Contract\Cli\CliHelpSectionName;
11: use Salient\Contract\Cli\CliHelpStyleInterface;
12: use Salient\Contract\Cli\CliHelpTarget;
13: use Salient\Contract\Cli\CliOptionValueType;
14: use Salient\Contract\Cli\CliOptionVisibility;
15: use Salient\Contract\Core\JsonSchemaInterface;
16: use Salient\Contract\Core\MessageLevel as Level;
17: use Salient\Core\Facade\Console;
18: use Salient\Utility\Arr;
19: use Salient\Utility\Regex;
20: use Salient\Utility\Str;
21: use InvalidArgumentException;
22: use LogicException;
23: use Throwable;
24:
25: /**
26: * Base class for runnable CLI commands
27: *
28: * @api
29: */
30: abstract class CliCommand implements CliCommandInterface
31: {
32: protected CliApplicationInterface $App;
33: /** @var string[] */
34: private array $Name = [];
35: /** @var array<string,CliOption>|null */
36: private ?array $Options = null;
37: /** @var array<string,CliOption> */
38: private array $OptionsByName = [];
39: /** @var array<string,CliOption> */
40: private array $PositionalOptions = [];
41: /** @var array<string,CliOption> */
42: private array $SchemaOptions = [];
43: /** @var string[] */
44: private array $DeferredOptionErrors = [];
45: /** @var string[] */
46: private array $Arguments = [];
47: /** @var array<string,array<string|int|bool|float>|string|int|bool|float|null> */
48: private array $ArgumentValues = [];
49: /** @var array<string,mixed>|null */
50: private ?array $OptionValues = null;
51: /** @var string[] */
52: private array $OptionErrors = [];
53: private ?int $NextArgumentIndex = null;
54: private bool $HasHelpArgument = false;
55: private bool $HasVersionArgument = false;
56: private bool $IsRunning = false;
57: private int $ExitStatus = 0;
58: private int $Runs = 0;
59:
60: /**
61: * Run the command
62: *
63: * The command's return value will be:
64: *
65: * 1. the return value of this method (if an `int` is returned)
66: * 2. the last value passed to {@see CliCommand::setExitStatus()}, or
67: * 3. `0`, indicating success
68: *
69: * @param string ...$args Non-option arguments passed to the command.
70: * @return int|void
71: */
72: abstract protected function run(string ...$args);
73:
74: /**
75: * Override to return a list of options for the command
76: *
77: * @return iterable<CliOption|CliOptionBuilder>
78: */
79: protected function getOptionList(): iterable
80: {
81: return [];
82: }
83:
84: /**
85: * Override to return a detailed description of the command
86: */
87: protected function getLongDescription(): ?string
88: {
89: return null;
90: }
91:
92: /**
93: * Override to return content for the command's help message
94: *
95: * `"NAME"`, `"SYNOPSIS"`, `"OPTIONS"` and `"DESCRIPTION"` sections are
96: * generated automatically and must not be returned by this method.
97: *
98: * @return array<CliHelpSectionName::*|string,string> An array that maps
99: * section names to content.
100: */
101: protected function getHelpSections(): array
102: {
103: return [];
104: }
105:
106: /**
107: * Override to modify the command's JSON Schema before it is returned
108: *
109: * @param array{'$schema':string,type:string,required:string[],properties:array<string,mixed>} $schema
110: * @return array{'$schema':string,type:string,required:string[],properties:array<string,mixed>,...}
111: */
112: protected function filterJsonSchema(array $schema): array
113: {
114: return $schema;
115: }
116:
117: /**
118: * Override to modify schema option values before they are returned
119: *
120: * @param mixed[] $values
121: * @return mixed[]
122: */
123: protected function filterGetSchemaValues(array $values): array
124: {
125: return $values;
126: }
127:
128: /**
129: * Override to modify schema option values before they are normalised
130: *
131: * @param array<array<string|int|bool|float>|string|int|bool|float|null> $values
132: * @return array<array<string|int|bool|float>|string|int|bool|float|null>
133: */
134: protected function filterNormaliseSchemaValues(array $values): array
135: {
136: return $values;
137: }
138:
139: /**
140: * Override to modify schema option values before they are applied to the
141: * command
142: *
143: * {@see filterNormaliseSchemaValues()} is always applied to `$values`
144: * before {@see filterApplySchemaValues()}.
145: *
146: * @param array<array<string|int|bool|float>|string|int|bool|float|null> $values
147: * @param bool $normalised `true` if `$values` have been normalised,
148: * otherwise `false`.
149: * @return array<array<string|int|bool|float>|string|int|bool|float|null>
150: */
151: protected function filterApplySchemaValues(array $values, bool $normalised): array
152: {
153: return $values;
154: }
155:
156: public function __construct(CliApplicationInterface $app)
157: {
158: $this->App = $app;
159: }
160:
161: /**
162: * @inheritDoc
163: */
164: final public function __invoke(string ...$args): int
165: {
166: $this->reset();
167:
168: $this->Arguments = $args;
169: $this->Runs++;
170:
171: $this->loadOptionValues();
172:
173: if ($this->HasHelpArgument) {
174: $style = new CliHelpStyle(CliHelpTarget::NORMAL);
175: Console::printStdout($style->buildHelp($this->getHelp($style)));
176: return 0;
177: }
178:
179: if ($this->HasVersionArgument) {
180: $this->App->reportVersion(Level::INFO, true);
181: return 0;
182: }
183:
184: if ($this->DeferredOptionErrors) {
185: throw new CliInvalidArgumentsException(
186: ...$this->DeferredOptionErrors,
187: );
188: }
189:
190: $this->IsRunning = true;
191: try {
192: $return = $this->run(...array_slice($this->Arguments, $this->NextArgumentIndex));
193: } finally {
194: $this->IsRunning = false;
195: }
196:
197: if (is_int($return)) {
198: return $return;
199: }
200:
201: return $this->ExitStatus;
202: }
203:
204: /**
205: * @internal
206: */
207: final public function __clone()
208: {
209: $this->Options = null;
210: $this->OptionsByName = [];
211: $this->PositionalOptions = [];
212: $this->SchemaOptions = [];
213: $this->DeferredOptionErrors = [];
214: }
215:
216: /**
217: * @inheritDoc
218: */
219: final public function getContainer(): CliApplicationInterface
220: {
221: return $this->App;
222: }
223:
224: /**
225: * @inheritDoc
226: */
227: final public function getName(): string
228: {
229: return implode(' ', $this->Name);
230: }
231:
232: /**
233: * @inheritDoc
234: */
235: final public function getNameParts(): array
236: {
237: return $this->Name;
238: }
239:
240: /**
241: * @inheritDoc
242: */
243: final public function setName(array $name): void
244: {
245: $this->Name = $name;
246: }
247:
248: /**
249: * @inheritDoc
250: */
251: final public function getHelp(?CliHelpStyleInterface $style = null): array
252: {
253: $style ??= new CliHelpStyle();
254:
255: $b = $style->getBold();
256: $em = $style->getItalic();
257: $esc = $style->getEscape();
258: $indent = $style->getOptionIndent();
259: $optionPrefix = $style->getOptionPrefix();
260: $descriptionPrefix = $style->getOptionDescriptionPrefix();
261: /** @var int&CliOptionVisibility::* */
262: $visibility = $style->getVisibility();
263: $width = $style->getWidth();
264:
265: $formatter = $style->getFormatter();
266:
267: $options = [];
268: foreach ($this->_getOptions() as $option) {
269: if (!($option->Visibility & $visibility)) {
270: continue;
271: }
272:
273: $short = $option->Short;
274: $long = $option->Long;
275: $line = [];
276: $value = [];
277: $allowed = null;
278: $booleanValue = false;
279: $valueName = null;
280: $default = null;
281: $prefix = '';
282: $suffix = '';
283:
284: if ($option->IsFlag) {
285: if ($short !== null) {
286: $line[] = $b . '-' . $short . $b;
287: }
288: if ($long !== null) {
289: $line[] = $b . '--' . $long . $b;
290: }
291: } else {
292: if (
293: $option->AllowedValues
294: || $option->ValueType === CliOptionValueType::BOOLEAN
295: ) {
296: foreach ($option->AllowedValues ?: [true, false] as $optionValue) {
297: $optionValue = $option->normaliseValueForHelp($optionValue);
298: $allowed[] = $em . Formatter::escapeTags($optionValue) . $em;
299: }
300: if (!$option->AllowedValues) {
301: $booleanValue = true;
302: }
303: }
304:
305: // `{}` is a placeholder for value name / allowed value list
306: if ($option->IsPositional) {
307: if ($option->MultipleAllowed) {
308: $line[] = '{}...';
309: } else {
310: $line[] = '{}';
311: }
312: } else {
313: $ellipsis = '';
314: if ($option->MultipleAllowed) {
315: if ($option->Delimiter) {
316: $ellipsis = $option->Delimiter . '...';
317: } elseif ($option->ValueRequired) {
318: $prefix = $esc . '(';
319: $suffix = ')...';
320: } else {
321: $suffix = '...';
322: }
323: }
324:
325: if ($short !== null) {
326: $line[] = $b . '-' . $short . $b;
327: $value[] = $option->ValueRequired
328: ? ' {}' . $ellipsis
329: : $esc . '[{}' . $ellipsis . ']';
330: }
331:
332: if ($long !== null) {
333: $line[] = $b . '--' . $long . $b;
334: $value[] = $option->ValueRequired
335: ? ' {}' . $ellipsis
336: : $esc . '[={}' . $ellipsis . ']';
337: }
338: }
339: }
340:
341: $line = $prefix . implode(', ', $line) . array_pop($value) . $suffix;
342:
343: // Replace value name with allowed values if $synopsis won't break
344: // over multiple lines, otherwise add them after the description
345: $pos = strrpos($line, '{}');
346: if ($pos !== false) {
347: $_line = null;
348: if (!$option->IsPositional && $allowed) {
349: if ($option->ValueRequired) {
350: $prefix = '(';
351: $suffix = ')';
352: } else {
353: $prefix = '';
354: $suffix = '';
355: }
356: $_line = substr_replace(
357: $line,
358: $prefix . implode('|', $allowed) . $suffix,
359: $pos,
360: 2
361: );
362: if (
363: $booleanValue
364: || mb_strlen($formatter->getWrapAfterApply()
365: ? $formatter->format($indent . $_line)
366: : Formatter::removeTags($indent . $_line)) <= ($width ?: 76)
367: ) {
368: $allowed = null;
369: } else {
370: $_line = null;
371: }
372: }
373: if ($_line === null) {
374: $_line = substr_replace($line, $option->getValueName(true), $pos, 2);
375: }
376: $line = $_line;
377: }
378:
379: $lines = [];
380: $description = trim((string) $option->Description);
381: if ($description !== '') {
382: $lines[] = $this->prepareHelp($style, $description, $indent);
383: }
384:
385: if ($allowed) {
386: foreach ($allowed as &$value) {
387: $value = sprintf('%s- %s', $indent, $value);
388: }
389: $valueName = $option->getValueNameWords();
390: $lines[] = sprintf(
391: "%sThe %s can be:\n\n%s",
392: $indent,
393: $valueName,
394: implode("\n", $allowed),
395: );
396: }
397:
398: if (
399: !$option->IsFlag
400: && $option->OriginalDefaultValue !== null
401: && $option->OriginalDefaultValue !== []
402: && (
403: !($option->Visibility & CliOptionVisibility::HIDE_DEFAULT)
404: || $visibility === CliOptionVisibility::HELP
405: )
406: ) {
407: foreach ((array) $option->OriginalDefaultValue as $value) {
408: if ((string) $value === '') {
409: continue;
410: }
411: $value = $option->normaliseValueForHelp($value);
412: $default[] = $em . Formatter::escapeTags($value) . $em;
413: }
414: $default = implode(Str::coalesce($option->Delimiter, ' '), $default);
415: if ($default !== '') {
416: $lines[] = sprintf(
417: '%sThe default %s is: %s',
418: $indent,
419: $valueName ?? $option->getValueNameWords(),
420: $default,
421: );
422: }
423: }
424:
425: $options[] = $optionPrefix . $line
426: . ($lines ? $descriptionPrefix . ltrim(implode("\n\n", $lines)) : '');
427: }
428:
429: $name = Formatter::escapeTags($this->getNameWithProgram());
430: $summary = Formatter::escapeTags($this->getDescription());
431: $synopsis = $this->getSynopsis($style);
432:
433: $description = $this->getLongDescription() ?? '';
434: if ($description !== '') {
435: $description = $this->prepareHelp($style, $description);
436: }
437:
438: $help = [
439: CliHelpSectionName::NAME => $name . ' - ' . $summary,
440: CliHelpSectionName::SYNOPSIS => $synopsis,
441: CliHelpSectionName::DESCRIPTION => $description,
442: CliHelpSectionName::OPTIONS => implode("\n\n", $options),
443: ];
444:
445: $sections = $this->getHelpSections();
446: $invalid = array_intersect_key($sections, $help);
447: if ($invalid) {
448: throw new LogicException(sprintf(
449: '%s must not be returned by %s::getHelpSections()',
450: implode(', ', array_keys($invalid)),
451: static::class,
452: ));
453: }
454: foreach ($sections as $name => $section) {
455: if ($section !== '') {
456: $help[$name] = $this->prepareHelp($style, $section);
457: }
458: }
459:
460: return $help;
461: }
462:
463: private function prepareHelp(CliHelpStyleInterface $style, string $text, string $indent = ''): string
464: {
465: $text = str_replace(
466: ['{{app}}', '{{program}}', '{{command}}', '{{subcommand}}'],
467: [
468: $this->App->getAppName(),
469: $this->App->getProgramName(),
470: $this->getNameWithProgram(),
471: Str::coalesce(
472: Arr::last($this->getNameParts()),
473: $this->App->getProgramName(),
474: ),
475: ],
476: $text,
477: );
478:
479: return $style->prepareHelp($text, $indent);
480: }
481:
482: /**
483: * @inheritDoc
484: */
485: final public function getSynopsis(?CliHelpStyleInterface $style = null): string
486: {
487: $style ??= new CliHelpStyle();
488:
489: $b = $style->getBold();
490: $prefix = $style->getSynopsisPrefix();
491: $newline = $style->getSynopsisNewline();
492: $softNewline = $style->getSynopsisSoftNewline();
493: $width = $style->getWidth();
494:
495: // Synopsis newlines are hard line breaks, so wrap without markup
496: $formatter = $style->getFormatter()->withWrapAfterApply(false);
497:
498: $name = $b . $this->getNameWithProgram() . $b;
499: $full = $this->getOptionsSynopsis($style, $collapsed);
500: $synopsis = Arr::implode(' ', [$name, $full], '');
501:
502: if ($width !== null) {
503: $wrapped = $formatter->format(
504: $synopsis,
505: false,
506: [$width, $width - 4],
507: true,
508: );
509: $wrapped = $prefix . str_replace("\n", $newline, $wrapped, $count);
510:
511: if (!$style->getCollapseSynopsis() || !$count) {
512: if ($softNewline !== '') {
513: $wrapped = $style->getFormatter()->format(
514: $wrapped,
515: false,
516: $width,
517: true,
518: $softNewline,
519: );
520: }
521: return $wrapped;
522: }
523:
524: $synopsis = Arr::implode(' ', [$name, $collapsed], '');
525: }
526:
527: $synopsis = $formatter->format(
528: $synopsis,
529: false,
530: $width !== null ? [$width, $width - 4] : null,
531: true,
532: );
533: $synopsis = $prefix . str_replace("\n", $newline, $synopsis);
534:
535: if ($width !== null && $softNewline !== '') {
536: $synopsis = $style->getFormatter()->format(
537: $synopsis,
538: false,
539: $width,
540: true,
541: $softNewline,
542: );
543: }
544:
545: return $synopsis;
546: }
547:
548: private function getOptionsSynopsis(CliHelpStyleInterface $style, ?string &$collapsed = null): string
549: {
550: $b = $style->getBold();
551: $esc = $style->getEscape();
552: /** @var int&CliOptionVisibility::* */
553: $visibility = $style->getVisibility();
554:
555: // Produce this:
556: //
557: // [-ny] [--exclude PATTERN] [--verbose] --from SOURCE DEST
558: //
559: // By generating this:
560: //
561: // $shortFlag = ['n', 'y'];
562: // $optional = ['[--exclude PATTERN]', '[--verbose]'];
563: // $required = ['--from SOURCE'];
564: // $positional = ['DEST'];
565: //
566: $shortFlag = [];
567: $optional = [];
568: $required = [];
569: $positional = [];
570:
571: $optionalCount = 0;
572: foreach ($this->_getOptions() as $option) {
573: if (!($option->Visibility & $visibility)) {
574: continue;
575: }
576:
577: if ($option->IsFlag || !($option->IsPositional || $option->WasRequired)) {
578: $optionalCount++;
579: if ($option->MultipleAllowed) {
580: $optionalCount++;
581: }
582: }
583:
584: if (!($option->Visibility & CliOptionVisibility::SYNOPSIS)) {
585: continue;
586: }
587:
588: if ($option->IsFlag) {
589: if ($option->Short !== null) {
590: $shortFlag[] = $option->Short;
591: continue;
592: }
593: $optional[] = $esc . '[' . $b . '--' . $option->Long . $b . ']';
594: continue;
595: }
596:
597: $valueName = $option->getValueName(true);
598: $valueName = $style->maybeEscapeTags($valueName);
599:
600: if ($option->IsPositional) {
601: if ($option->MultipleAllowed) {
602: $valueName .= '...';
603: }
604: $positional[] = $option->WasRequired
605: ? $valueName
606: : $esc . '[' . $valueName . ']';
607: continue;
608: }
609:
610: $prefix = '';
611: $suffix = '';
612: $ellipsis = '';
613: if ($option->MultipleAllowed) {
614: if ($option->Delimiter) {
615: $valueName .= $option->Delimiter . '...';
616: } elseif ($option->ValueRequired) {
617: if ($option->WasRequired) {
618: $prefix = $esc . '(';
619: $suffix = ')...';
620: } else {
621: $ellipsis = '...';
622: }
623: } else {
624: $ellipsis = '...';
625: }
626: }
627:
628: $valueName = $option->Short !== null
629: ? $prefix . $b . '-' . $option->Short . $b
630: . ($option->ValueRequired
631: ? $esc . ' ' . $valueName . $suffix
632: : $esc . '[' . $valueName . ']' . $suffix)
633: : $prefix . $b . '--' . $option->Long . $b
634: . ($option->ValueRequired
635: ? $esc . ' ' . $valueName . $suffix
636: : $esc . '[=' . $valueName . ']' . $suffix);
637:
638: if ($option->WasRequired) {
639: $required[] = $valueName . $ellipsis;
640: } else {
641: $optional[] = $esc . '[' . $valueName . ']' . $ellipsis;
642: }
643: }
644:
645: $collapsed = Arr::implode(' ', [
646: $optionalCount > 1 ? $esc . '[' . $style->maybeEscapeTags('<options>') . ']' : '',
647: $optionalCount === 1 ? $esc . '[' . $style->maybeEscapeTags('<option>') . ']' : '',
648: $required ? implode(' ', $required) : '',
649: $positional ? $esc . '[' . $b . '--' . $b . '] ' . implode(' ', $positional) : '',
650: ], '');
651:
652: return Arr::implode(' ', [
653: $shortFlag ? $esc . '[' . $b . '-' . implode('', $shortFlag) . $b . ']' : '',
654: $optional ? implode(' ', $optional) : '',
655: $required ? implode(' ', $required) : '',
656: $positional ? $esc . '[' . $b . '--' . $b . '] ' . implode(' ', $positional) : '',
657: ], '');
658: }
659:
660: /**
661: * Get the command name, including the name used to run the script, as a
662: * string of space-delimited subcommands
663: */
664: final protected function getNameWithProgram(): string
665: {
666: return implode(' ', Arr::unshift(
667: $this->Name,
668: $this->App->getProgramName(),
669: ));
670: }
671:
672: /**
673: * @return array{'$schema':string,type:string,required?:string[],properties?:array<string,mixed>,...}
674: */
675: final public function getJsonSchema(): array
676: {
677: $schema = [
678: '$schema' => JsonSchemaInterface::DRAFT_04_SCHEMA_ID,
679: ];
680: $schema['type'] = 'object';
681: $schema['required'] = [];
682: $schema['properties'] = [];
683:
684: foreach ($this->_getOptions() as $option) {
685: if (!($option->Visibility & CliOptionVisibility::SCHEMA)) {
686: continue;
687: }
688:
689: $name = Str::camel((string) $option->Name);
690: if ($name === '' || isset($schema['properties'][$name])) {
691: throw new LogicException(sprintf('Schema option names must be unique and non-empty after camelCase conversion: %s', $option->Name));
692: }
693:
694: $schema['properties'][$name] = $option->getJsonSchema();
695:
696: if ($option->Required) {
697: $schema['required'][] = $name;
698: }
699: }
700:
701: // Preserve essential properties in their original order
702: $schema = array_merge($schema, $this->filterJsonSchema($schema));
703:
704: if ($schema['required'] === []) {
705: unset($schema['required']);
706: }
707:
708: if ($schema['properties'] === []) {
709: unset($schema['properties']);
710: }
711:
712: return $schema;
713: }
714:
715: /**
716: * Normalise an array of option values, apply them to the command, and
717: * return them to the caller
718: *
719: * @param array<array<string|int|bool|float>|string|int|bool|float|null> $values
720: * @param bool $normalise `false` if `$values` have already been normalised.
721: * @param bool $expand If `true` and an option has an optional value, expand
722: * `null` or `true` to the default value of the option. Ignored if
723: * `$normalise` is `false`.
724: * @param bool $schema If `true`, only apply `$values` to schema options.
725: * @param bool $asArguments If `true`, apply `$values` as if they had been
726: * given on the command line.
727: * @param bool $forgetArguments If `true` and `$asArguments` is also `true`,
728: * apply `$values` as if any options previously given on the command line
729: * had not been given.
730: * @return mixed[]
731: */
732: final protected function applyOptionValues(
733: array $values,
734: bool $normalise = true,
735: bool $expand = false,
736: bool $schema = false,
737: bool $asArguments = false,
738: bool $forgetArguments = false
739: ): array {
740: $this->assertHasRun();
741: $this->loadOptions();
742: if ($asArguments && $forgetArguments) {
743: if (!$schema) {
744: $this->ArgumentValues = [];
745: } else {
746: foreach ($this->SchemaOptions as $option) {
747: unset($this->ArgumentValues[$option->Key]);
748: }
749: }
750: }
751: if ($schema) {
752: if ($normalise) {
753: $values = $this->filterNormaliseSchemaValues($values);
754: }
755: $values = $this->filterApplySchemaValues($values, !$normalise);
756: }
757: foreach ($values as $name => $value) {
758: $option = $this->_getOption($name, $schema);
759: if (!$schema) {
760: $name = $option->Name;
761: }
762: $_value = $option->applyValue($value, $normalise, $expand);
763: $_values[$name] = $_value;
764: $this->OptionValues[$option->Key] = $_value;
765: if ($asArguments) {
766: // If the option has an optional value and no value was given,
767: // store null to ensure it's not expanded on export
768: if (
769: $option->ValueOptional
770: && $option->ValueType !== CliOptionValueType::BOOLEAN
771: && $value === true
772: ) {
773: $value = null;
774: }
775: $this->ArgumentValues[$option->Key] = $value;
776: }
777: }
778:
779: return $_values ?? [];
780: }
781:
782: /**
783: * Normalise an array of option values
784: *
785: * @param array<array<string|int|bool|float>|string|int|bool|float|null> $values
786: * @param bool $expand If `true` and an option has an optional value, expand
787: * `null` or `true` to the default value of the option.
788: * @param bool $schema If `true`, only normalise schema options.
789: * @return mixed[]
790: */
791: final protected function normaliseOptionValues(
792: array $values,
793: bool $expand = false,
794: bool $schema = false
795: ): array {
796: $this->loadOptions();
797: if ($schema) {
798: $values = $this->filterNormaliseSchemaValues($values);
799: }
800: foreach ($values as $name => $value) {
801: $option = $this->_getOption($name, $schema);
802: if (!$schema) {
803: $name = $option->Name;
804: }
805: $_values[$name] = $option->normaliseValue($value, $expand);
806: }
807:
808: return $_values ?? [];
809: }
810:
811: /**
812: * Check that an array of option values is valid
813: *
814: * @param mixed[] $values
815: * @phpstan-assert-if-true array<array<string|int|bool|float>|string|int|bool|float|null> $values
816: */
817: final protected function checkOptionValues(array $values): bool
818: {
819: foreach ($values as $value) {
820: if (
821: $value === null
822: || is_string($value)
823: || is_int($value)
824: || is_bool($value)
825: || is_float($value)
826: ) {
827: continue;
828: }
829: if (!is_array($value)) {
830: return false;
831: }
832: foreach ($value as $v) {
833: if (!(is_string($v) || is_int($v) || is_bool($v) || is_float($v))) {
834: return false;
835: }
836: }
837: }
838: return true;
839: }
840:
841: /**
842: * Get an array that maps option names to values
843: *
844: * @param bool $export If `true`, only options given on the command line are
845: * returned.
846: * @param bool $schema If `true`, an array that maps schema option names to
847: * values is returned.
848: * @param bool $unexpand If `true` and an option has an optional value not
849: * given on the command line, replace its value with `null` or `true`.
850: * @return array<array<string|int|bool|float>|string|int|bool|float|null>
851: */
852: final protected function getOptionValues(
853: bool $export = false,
854: bool $schema = false,
855: bool $unexpand = false
856: ): array {
857: $this->assertHasRun();
858: $this->loadOptions();
859: $options = $schema ? $this->SchemaOptions : $this->Options;
860: foreach ($options as $key => $option) {
861: $given = array_key_exists($option->Key, $this->ArgumentValues);
862: if ($export && !$given) {
863: continue;
864: }
865: if ($option->ValueOptional && !$option->Required && !$given) {
866: continue;
867: }
868: $name = $schema ? $key : $option->Name;
869: if (
870: $unexpand
871: && $given
872: && $option->ValueOptional
873: && $this->ArgumentValues[$option->Key] === null
874: ) {
875: $value = $option->ValueType !== CliOptionValueType::BOOLEAN
876: ? true
877: : null;
878: } else {
879: $value = $this->OptionValues[$option->Key] ?? null;
880: }
881: $values[$name] = $value;
882: }
883:
884: /** @var array<array<string|int|bool|float>|string|int|bool|float|null> */
885: return $schema
886: ? $this->filterGetSchemaValues($values ?? [])
887: : $values ?? [];
888: }
889:
890: /**
891: * Get an array that maps option names to default values
892: *
893: * @param bool $schema If `true`, an array that maps schema option names to
894: * default values is returned.
895: * @return array<array<string|int|bool|float>|string|int|bool|float|null>
896: */
897: final protected function getDefaultOptionValues(bool $schema = false): array
898: {
899: $this->loadOptions();
900: $options = $schema ? $this->SchemaOptions : $this->Options;
901: foreach ($options as $key => $option) {
902: if ($option->ValueOptional && !$option->Required) {
903: continue;
904: }
905: $name = $schema ? $key : $option->Name;
906: $values[$name] = $option->OriginalDefaultValue;
907: }
908:
909: /** @var array<array<string|int|bool|float>|string|int|bool|float|null> */
910: return $schema
911: ? $this->filterGetSchemaValues($values ?? [])
912: : $values ?? [];
913: }
914:
915: /**
916: * Get the value of a given option
917: *
918: * @return mixed
919: */
920: final protected function getOptionValue(string $name)
921: {
922: $this->assertHasRun();
923: $this->loadOptions();
924: $option = $this->_getOption($name, false);
925: return $this->OptionValues[$option->Key] ?? null;
926: }
927:
928: /**
929: * True if an option was given on the command line
930: */
931: final protected function optionHasArgument(string $name): bool
932: {
933: $this->assertHasRun();
934: $this->loadOptions();
935: $option = $this->_getOption($name, false);
936: return array_key_exists($option->Key, $this->ArgumentValues);
937: }
938:
939: /**
940: * Get the given option
941: */
942: final protected function getOption(string $name): CliOption
943: {
944: $this->loadOptions();
945: return $this->_getOption($name, false);
946: }
947:
948: /**
949: * True if the command has a given option
950: */
951: final protected function hasOption(string $name): bool
952: {
953: $this->loadOptions();
954: return isset($this->OptionsByName[$name])
955: || isset($this->SchemaOptions[$name]);
956: }
957:
958: private function _getOption(string $name, bool $schema): CliOption
959: {
960: if ($schema) {
961: $option = $this->SchemaOptions[$name] ?? null;
962: } else {
963: $option = $this->OptionsByName[$name]
964: ?? $this->SchemaOptions[$name]
965: ?? null;
966: }
967: if (!$option) {
968: throw new InvalidArgumentException(sprintf(
969: '%s not found: %s',
970: $schema ? 'Schema option' : 'option',
971: $name
972: ));
973: }
974: return $option;
975: }
976:
977: /**
978: * @phpstan-assert !null $this->NextArgumentIndex
979: */
980: private function loadOptionValues(): void
981: {
982: $this->loadOptions();
983:
984: try {
985: $merged = $this->mergeArguments(
986: $this->Arguments,
987: $this->ArgumentValues,
988: $this->NextArgumentIndex,
989: $this->HasHelpArgument,
990: $this->HasVersionArgument
991: );
992:
993: $this->OptionValues = [];
994:
995: foreach ($this->Options as $option) {
996: if ($option->Required
997: && (!array_key_exists($option->Key, $merged) || $merged[$option->Key] === [])) {
998: if (!(count($this->Arguments) === 1 && ($this->HasHelpArgument || $this->HasVersionArgument))) {
999: $this->optionError(sprintf(
1000: '%s required%s',
1001: $option->DisplayName,
1002: $option->formatAllowedValues()
1003: ));
1004: }
1005: continue;
1006: }
1007:
1008: $value = $merged[$option->Key]
1009: ?? ($option->ValueRequired ? $option->DefaultValue : null);
1010: try {
1011: $value = $option->applyValue($value);
1012: if ($value !== null || array_key_exists($option->Key, $merged)) {
1013: $this->OptionValues[$option->Key] = $value;
1014: }
1015: } catch (CliInvalidArgumentsException $ex) {
1016: foreach ($ex->getErrors() as $error) {
1017: $this->optionError($error);
1018: }
1019: } catch (CliUnknownValueException $ex) {
1020: $this->optionError($ex->getMessage());
1021: }
1022: }
1023:
1024: if ($this->OptionErrors) {
1025: throw new CliInvalidArgumentsException(
1026: ...$this->OptionErrors,
1027: ...$this->DeferredOptionErrors
1028: );
1029: }
1030: } catch (Throwable $ex) {
1031: $this->OptionValues = null;
1032:
1033: throw $ex;
1034: }
1035: }
1036:
1037: /**
1038: * @param string[] $args
1039: * @param array<string,array<string|int|bool|float>|string|int|bool|float|null> $argValues
1040: * @return array<string,array<string|int|bool|float>|string|int|bool|float|null>
1041: */
1042: private function mergeArguments(
1043: array $args,
1044: array &$argValues,
1045: ?int &$nextArgumentIndex,
1046: bool &$hasHelpArgument,
1047: bool &$hasVersionArgument
1048: ): array {
1049: $saveArgValue =
1050: function (string $key, $value) use (&$argValues, &$saved, &$option) {
1051: if ($saved) {
1052: return;
1053: }
1054: $saved = true;
1055: /** @var CliOption $option */
1056: if (!array_key_exists($key, $argValues)
1057: || ($option->IsFlag && !$option->MultipleAllowed)) {
1058: $argValues[$key] = $value;
1059: } else {
1060: $argValues[$key] = array_merge((array) $argValues[$key], Arr::wrap($value));
1061: }
1062: };
1063:
1064: $merged = [];
1065: $positional = [];
1066: $totalArgs = count($args);
1067:
1068: for ($i = 0; $i < $totalArgs; $i++) {
1069: $arg = $args[$i];
1070: $short = false;
1071: $saved = false;
1072: if (Regex::match('/^-([a-z0-9_])(.*)/i', $arg, $matches)) {
1073: $name = $matches[1];
1074: $value = Str::coalesce($matches[2], null);
1075: $short = true;
1076: } elseif (Regex::match(
1077: '/^--([a-z0-9_][-a-z0-9_]+)(?:=(.*))?$/i',
1078: $arg,
1079: $matches,
1080: \PREG_UNMATCHED_AS_NULL,
1081: )) {
1082: $name = $matches[1];
1083: $value = $matches[2];
1084: } elseif ($arg === '--') {
1085: $i++;
1086: break;
1087: } elseif ($arg === '-' || ($arg !== '' && $arg[0] !== '-')) {
1088: $positional[] = $arg;
1089: continue;
1090: } else {
1091: $this->optionError(sprintf("invalid argument '%s'", $arg));
1092: continue;
1093: }
1094:
1095: $option = $this->OptionsByName[$name] ?? null;
1096: if (!$option || $option->IsPositional) {
1097: $this->optionError(sprintf("unknown option '%s'", $name));
1098: continue;
1099: }
1100:
1101: if ($option->Long === 'help') {
1102: $hasHelpArgument = true;
1103: } elseif ($option->Long === 'version') {
1104: $hasVersionArgument = true;
1105: }
1106:
1107: $key = $option->Key;
1108: if ($option->IsFlag) {
1109: // Handle multiple short flags per argument, e.g. `cp -rv`
1110: if ($short && $value !== null) {
1111: $args[$i] = "-$value";
1112: $i--;
1113: }
1114: $value = true;
1115: $saveArgValue($key, $value);
1116: } elseif ($option->ValueOptional) {
1117: // Don't use the default value if `--option=` was given
1118: if ($value === null) {
1119: $saveArgValue($key, $value);
1120: $value = $option->DefaultValue;
1121: }
1122: } elseif ($value === null) {
1123: $i++;
1124: $value = $args[$i] ?? null;
1125: if ($value === null) {
1126: // Allow null to be stored to prevent an additional
1127: // "argument required" error
1128: $this->optionError(sprintf(
1129: '%s requires a value%s',
1130: $option->DisplayName,
1131: $option->formatAllowedValues(),
1132: ));
1133: $i--;
1134: }
1135: }
1136:
1137: if ($option->MultipleAllowed && !$option->IsFlag) {
1138: // Interpret "--opt=" as "clear default or previous values" and
1139: // "--opt ''" as "apply empty string"
1140: if ($option->ValueRequired && $value === '') {
1141: if ($args[$i] === '') {
1142: $value = [''];
1143: } else {
1144: $merged[$key] = [];
1145: $argValues[$key] = [];
1146: continue;
1147: }
1148: } else {
1149: $value = $option->maybeSplitValue($value);
1150: }
1151: $saveArgValue($key, $value);
1152: }
1153:
1154: if (array_key_exists($key, $merged)
1155: && !($option->IsFlag && !$option->MultipleAllowed)) {
1156: $merged[$key] = array_merge((array) $merged[$key], (array) $value);
1157: } else {
1158: $merged[$key] = $value;
1159: }
1160: $saveArgValue($key, $value);
1161: }
1162:
1163: // Splice $positional into $args to ensure $nextArgumentIndex is correct
1164: if ($positional) {
1165: $positionalArgs = count($positional);
1166: $i -= $positionalArgs;
1167: array_splice($args, $i, $positionalArgs, $positional);
1168: }
1169: $pending = count($this->PositionalOptions);
1170: foreach ($this->PositionalOptions as $option) {
1171: if ($i >= $totalArgs) {
1172: break;
1173: }
1174: $pending--;
1175: $key = $option->Key;
1176: $saved = false;
1177: if ($option->Required || !$option->MultipleAllowed) {
1178: $arg = $args[$i++];
1179: $merged[$key] = $option->MultipleAllowed ? [$arg] : $arg;
1180: $saveArgValue($key, $arg);
1181: if (!$option->MultipleAllowed) {
1182: continue;
1183: }
1184: }
1185: // Only one positional option can accept multiple values, so collect
1186: // arguments until all that remains is one per pending option
1187: while ($totalArgs - $i - $pending > 0) {
1188: $saved = false;
1189: $arg = $args[$i++];
1190: $merged[$key] = array_merge((array) ($merged[$key] ?? null), [$arg]);
1191: $saveArgValue($key, $arg);
1192: }
1193: }
1194:
1195: $nextArgumentIndex = $i;
1196:
1197: return $merged;
1198: }
1199:
1200: private function optionError(string $message): void
1201: {
1202: $this->OptionErrors[] = $message;
1203: }
1204:
1205: /**
1206: * Get the command's options
1207: *
1208: * @return list<CliOption>
1209: */
1210: final protected function getOptions(): array
1211: {
1212: return array_values($this->_getOptions());
1213: }
1214:
1215: /**
1216: * @return array<string,CliOption>
1217: */
1218: private function _getOptions(): array
1219: {
1220: /** @var array<string,CliOption> */
1221: $options = $this->loadOptions()->Options;
1222:
1223: return $options;
1224: }
1225:
1226: /**
1227: * @return $this
1228: * @phpstan-assert !null $this->Options
1229: */
1230: private function loadOptions()
1231: {
1232: if ($this->Options !== null) {
1233: return $this;
1234: }
1235:
1236: try {
1237: foreach ($this->getOptionList() as $option) {
1238: $this->addOption($option);
1239: }
1240:
1241: return $this->maybeAddOption('help')->maybeAddOption('version');
1242: } catch (Throwable $ex) {
1243: $this->Options = null;
1244: $this->OptionsByName = [];
1245: $this->PositionalOptions = [];
1246: $this->SchemaOptions = [];
1247: $this->DeferredOptionErrors = [];
1248:
1249: throw $ex;
1250: }
1251: }
1252:
1253: /**
1254: * @return $this
1255: */
1256: private function maybeAddOption(string $long)
1257: {
1258: if (!isset($this->OptionsByName[$long])) {
1259: $this->addOption(CliOption::build()->long($long)->hide());
1260: }
1261: return $this;
1262: }
1263:
1264: /**
1265: * @param CliOption|CliOptionBuilder $option
1266: */
1267: private function addOption($option): void
1268: {
1269: $option = CliOption::resolve($option);
1270: try {
1271: $option = $option->load();
1272: } catch (CliUnknownValueException $ex) {
1273: // If an exception is thrown over a value found in the environment,
1274: // defer it in case we're only loading options for a help message
1275: $this->DeferredOptionErrors[] = $ex->getMessage();
1276: }
1277:
1278: $names = $option->getNames();
1279:
1280: if (array_intersect_key(array_flip($names), $this->OptionsByName)) {
1281: throw new LogicException(sprintf('Option names must be unique: %s', implode(', ', $names)));
1282: }
1283:
1284: if ($option->Visibility & CliOptionVisibility::SCHEMA) {
1285: $name = Str::camel((string) $option->Name);
1286: if ($name === '' || isset($this->SchemaOptions[$name])) {
1287: throw new LogicException(sprintf(
1288: 'Schema option names must be unique and non-empty after camelCase conversion: %s',
1289: $option->Name
1290: ));
1291: }
1292: $this->SchemaOptions[$name] = $option;
1293: }
1294:
1295: if ($option->IsPositional) {
1296: if ($option->WasRequired
1297: && array_filter(
1298: $this->PositionalOptions,
1299: fn(CliOption $opt) =>
1300: !$opt->WasRequired && !$opt->MultipleAllowed
1301: )) {
1302: throw new LogicException('Required positional options must be added before optional ones');
1303: }
1304:
1305: if (!$option->WasRequired
1306: && array_filter(
1307: $this->PositionalOptions,
1308: fn(CliOption $opt) =>
1309: $opt->MultipleAllowed
1310: )) {
1311: throw new LogicException("'multipleAllowed' positional options must be added after optional ones");
1312: }
1313:
1314: if ($option->MultipleAllowed
1315: && array_filter(
1316: $this->PositionalOptions,
1317: fn(CliOption $opt) =>
1318: $opt->MultipleAllowed
1319: )) {
1320: throw new LogicException("'multipleAllowed' cannot be set on more than one positional option");
1321: }
1322:
1323: $this->PositionalOptions[$option->Key] = $option;
1324: }
1325:
1326: $this->Options[$option->Key] = $option;
1327:
1328: foreach ($names as $name) {
1329: $this->OptionsByName[$name] = $option;
1330: }
1331: }
1332:
1333: /**
1334: * @return $this
1335: */
1336: private function assertHasRun()
1337: {
1338: if ($this->OptionValues === null) {
1339: throw new LogicException('Command must be invoked first');
1340: }
1341:
1342: return $this;
1343: }
1344:
1345: /**
1346: * True if the command is currently running
1347: */
1348: final protected function isRunning(): bool
1349: {
1350: return $this->IsRunning;
1351: }
1352:
1353: /**
1354: * Set the command's return value / exit status
1355: *
1356: * @return $this
1357: *
1358: * @see CliCommand::run()
1359: */
1360: final protected function setExitStatus(int $status)
1361: {
1362: $this->ExitStatus = $status;
1363:
1364: return $this;
1365: }
1366:
1367: /**
1368: * Get the current return value / exit status
1369: *
1370: * @see CliCommand::setExitStatus()
1371: * @see CliCommand::run()
1372: */
1373: final protected function getExitStatus(): int
1374: {
1375: return $this->ExitStatus;
1376: }
1377:
1378: /**
1379: * Get the number of times the command has run, including the current run
1380: * (if applicable)
1381: */
1382: final public function getRuns(): int
1383: {
1384: return $this->Runs;
1385: }
1386:
1387: private function reset(): void
1388: {
1389: $this->Arguments = [];
1390: $this->ArgumentValues = [];
1391: $this->OptionValues = null;
1392: $this->OptionErrors = [];
1393: $this->NextArgumentIndex = null;
1394: $this->HasHelpArgument = false;
1395: $this->HasVersionArgument = false;
1396: $this->ExitStatus = 0;
1397: }
1398: }
1399: