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>|string|int|bool|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>|string|int|bool|null> $values
132: * @return array<array<string|int|bool>|string|int|bool|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>|string|int|bool|null> $values
147: * @param bool $normalised `true` if `$values` have been normalised,
148: * otherwise `false`.
149: * @return array<array<string|int|bool>|string|int|bool|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>|string|int|bool|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>|string|int|bool|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>|string|int|bool|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: ) {
826: continue;
827: }
828: if (!is_array($value)) {
829: return false;
830: }
831: foreach ($value as $v) {
832: if (!(is_string($v) || is_int($v) || is_bool($v))) {
833: return false;
834: }
835: }
836: }
837: return true;
838: }
839:
840: /**
841: * Get an array that maps option names to values
842: *
843: * @param bool $export If `true`, only options given on the command line are
844: * returned.
845: * @param bool $schema If `true`, an array that maps schema option names to
846: * values is returned.
847: * @param bool $unexpand If `true` and an option has an optional value not
848: * given on the command line, replace its value with `null` or `true`.
849: * @return array<array<string|int|bool>|string|int|bool|null>
850: */
851: final protected function getOptionValues(
852: bool $export = false,
853: bool $schema = false,
854: bool $unexpand = false
855: ): array {
856: $this->assertHasRun();
857: $this->loadOptions();
858: $options = $schema ? $this->SchemaOptions : $this->Options;
859: foreach ($options as $key => $option) {
860: $given = array_key_exists($option->Key, $this->ArgumentValues);
861: if ($export && !$given) {
862: continue;
863: }
864: if ($option->ValueOptional && !$option->Required && !$given) {
865: continue;
866: }
867: $name = $schema ? $key : $option->Name;
868: if (
869: $unexpand
870: && $given
871: && $option->ValueOptional
872: && $this->ArgumentValues[$option->Key] === null
873: ) {
874: $value = $option->ValueType !== CliOptionValueType::BOOLEAN
875: ? true
876: : null;
877: } else {
878: $value = $this->OptionValues[$option->Key] ?? null;
879: }
880: $values[$name] = $value;
881: }
882:
883: /** @var array<array<string|int|bool>|string|int|bool|null> */
884: return $schema
885: ? $this->filterGetSchemaValues($values ?? [])
886: : $values ?? [];
887: }
888:
889: /**
890: * Get an array that maps option names to default values
891: *
892: * @param bool $schema If `true`, an array that maps schema option names to
893: * default values is returned.
894: * @return array<array<string|int|bool>|string|int|bool|null>
895: */
896: final protected function getDefaultOptionValues(bool $schema = false): array
897: {
898: $this->loadOptions();
899: $options = $schema ? $this->SchemaOptions : $this->Options;
900: foreach ($options as $key => $option) {
901: if ($option->ValueOptional && !$option->Required) {
902: continue;
903: }
904: $name = $schema ? $key : $option->Name;
905: $values[$name] = $option->OriginalDefaultValue;
906: }
907:
908: /** @var array<array<string|int|bool>|string|int|bool|null> */
909: return $schema
910: ? $this->filterGetSchemaValues($values ?? [])
911: : $values ?? [];
912: }
913:
914: /**
915: * Get the value of a given option
916: *
917: * @return mixed
918: */
919: final protected function getOptionValue(string $name)
920: {
921: $this->assertHasRun();
922: $this->loadOptions();
923: $option = $this->_getOption($name, false);
924: return $this->OptionValues[$option->Key] ?? null;
925: }
926:
927: /**
928: * True if an option was given on the command line
929: */
930: final protected function optionHasArgument(string $name): bool
931: {
932: $this->assertHasRun();
933: $this->loadOptions();
934: $option = $this->_getOption($name, false);
935: return array_key_exists($option->Key, $this->ArgumentValues);
936: }
937:
938: /**
939: * Get the given option
940: */
941: final protected function getOption(string $name): CliOption
942: {
943: $this->loadOptions();
944: return $this->_getOption($name, false);
945: }
946:
947: /**
948: * True if the command has a given option
949: */
950: final protected function hasOption(string $name): bool
951: {
952: $this->loadOptions();
953: return isset($this->OptionsByName[$name])
954: || isset($this->SchemaOptions[$name]);
955: }
956:
957: private function _getOption(string $name, bool $schema): CliOption
958: {
959: if ($schema) {
960: $option = $this->SchemaOptions[$name] ?? null;
961: } else {
962: $option = $this->OptionsByName[$name]
963: ?? $this->SchemaOptions[$name]
964: ?? null;
965: }
966: if (!$option) {
967: throw new InvalidArgumentException(sprintf(
968: '%s not found: %s',
969: $schema ? 'Schema option' : 'option',
970: $name
971: ));
972: }
973: return $option;
974: }
975:
976: /**
977: * @phpstan-assert !null $this->NextArgumentIndex
978: */
979: private function loadOptionValues(): void
980: {
981: $this->loadOptions();
982:
983: try {
984: $merged = $this->mergeArguments(
985: $this->Arguments,
986: $this->ArgumentValues,
987: $this->NextArgumentIndex,
988: $this->HasHelpArgument,
989: $this->HasVersionArgument
990: );
991:
992: $this->OptionValues = [];
993:
994: foreach ($this->Options as $option) {
995: if ($option->Required
996: && (!array_key_exists($option->Key, $merged) || $merged[$option->Key] === [])) {
997: if (!(count($this->Arguments) === 1 && ($this->HasHelpArgument || $this->HasVersionArgument))) {
998: $this->optionError(sprintf(
999: '%s required%s',
1000: $option->DisplayName,
1001: $option->formatAllowedValues()
1002: ));
1003: }
1004: continue;
1005: }
1006:
1007: $value = $merged[$option->Key]
1008: ?? ($option->ValueRequired ? $option->DefaultValue : null);
1009: try {
1010: $value = $option->applyValue($value);
1011: if ($value !== null || array_key_exists($option->Key, $merged)) {
1012: $this->OptionValues[$option->Key] = $value;
1013: }
1014: } catch (CliInvalidArgumentsException $ex) {
1015: foreach ($ex->getErrors() as $error) {
1016: $this->optionError($error);
1017: }
1018: } catch (CliUnknownValueException $ex) {
1019: $this->optionError($ex->getMessage());
1020: }
1021: }
1022:
1023: if ($this->OptionErrors) {
1024: throw new CliInvalidArgumentsException(
1025: ...$this->OptionErrors,
1026: ...$this->DeferredOptionErrors
1027: );
1028: }
1029: } catch (Throwable $ex) {
1030: $this->OptionValues = null;
1031:
1032: throw $ex;
1033: }
1034: }
1035:
1036: /**
1037: * @param string[] $args
1038: * @param array<string,array<string|int|bool>|string|int|bool|null> $argValues
1039: * @return array<string,array<string|int|bool>|string|int|bool|null>
1040: */
1041: private function mergeArguments(
1042: array $args,
1043: array &$argValues,
1044: ?int &$nextArgumentIndex,
1045: bool &$hasHelpArgument,
1046: bool &$hasVersionArgument
1047: ): array {
1048: $saveArgValue =
1049: function (string $key, $value) use (&$argValues, &$saved, &$option) {
1050: if ($saved) {
1051: return;
1052: }
1053: $saved = true;
1054: /** @var CliOption $option */
1055: if (!array_key_exists($key, $argValues)
1056: || ($option->IsFlag && !$option->MultipleAllowed)) {
1057: $argValues[$key] = $value;
1058: } else {
1059: $argValues[$key] = array_merge((array) $argValues[$key], Arr::wrap($value));
1060: }
1061: };
1062:
1063: $merged = [];
1064: $positional = [];
1065: $totalArgs = count($args);
1066:
1067: for ($i = 0; $i < $totalArgs; $i++) {
1068: $arg = $args[$i];
1069: $short = false;
1070: $saved = false;
1071: if (Regex::match('/^-([a-z0-9_])(.*)/i', $arg, $matches)) {
1072: $name = $matches[1];
1073: $value = Str::coalesce($matches[2], null);
1074: $short = true;
1075: } elseif (Regex::match(
1076: '/^--([a-z0-9_][-a-z0-9_]+)(?:=(.*))?$/i',
1077: $arg,
1078: $matches,
1079: \PREG_UNMATCHED_AS_NULL,
1080: )) {
1081: $name = $matches[1];
1082: $value = $matches[2];
1083: } elseif ($arg === '--') {
1084: $i++;
1085: break;
1086: } elseif ($arg === '-' || ($arg !== '' && $arg[0] !== '-')) {
1087: $positional[] = $arg;
1088: continue;
1089: } else {
1090: $this->optionError(sprintf("invalid argument '%s'", $arg));
1091: continue;
1092: }
1093:
1094: $option = $this->OptionsByName[$name] ?? null;
1095: if (!$option || $option->IsPositional) {
1096: $this->optionError(sprintf("unknown option '%s'", $name));
1097: continue;
1098: }
1099:
1100: if ($option->Long === 'help') {
1101: $hasHelpArgument = true;
1102: } elseif ($option->Long === 'version') {
1103: $hasVersionArgument = true;
1104: }
1105:
1106: $key = $option->Key;
1107: if ($option->IsFlag) {
1108: // Handle multiple short flags per argument, e.g. `cp -rv`
1109: if ($short && $value !== null) {
1110: $args[$i] = "-$value";
1111: $i--;
1112: }
1113: $value = true;
1114: $saveArgValue($key, $value);
1115: } elseif ($option->ValueOptional) {
1116: // Don't use the default value if `--option=` was given
1117: if ($value === null) {
1118: $saveArgValue($key, $value);
1119: $value = $option->DefaultValue;
1120: }
1121: } elseif ($value === null) {
1122: $i++;
1123: $value = $args[$i] ?? null;
1124: if ($value === null) {
1125: // Allow null to be stored to prevent an additional
1126: // "argument required" error
1127: $this->optionError(sprintf(
1128: '%s requires a value%s',
1129: $option->DisplayName,
1130: $option->formatAllowedValues(),
1131: ));
1132: $i--;
1133: }
1134: }
1135:
1136: if ($option->MultipleAllowed && !$option->IsFlag) {
1137: // Interpret "--opt=" as "clear default or previous values" and
1138: // "--opt ''" as "apply empty string"
1139: if ($option->ValueRequired && $value === '') {
1140: if ($args[$i] === '') {
1141: $value = [''];
1142: } else {
1143: $merged[$key] = [];
1144: $argValues[$key] = [];
1145: continue;
1146: }
1147: } else {
1148: $value = $option->maybeSplitValue($value);
1149: }
1150: $saveArgValue($key, $value);
1151: }
1152:
1153: if (array_key_exists($key, $merged)
1154: && !($option->IsFlag && !$option->MultipleAllowed)) {
1155: $merged[$key] = array_merge((array) $merged[$key], (array) $value);
1156: } else {
1157: $merged[$key] = $value;
1158: }
1159: $saveArgValue($key, $value);
1160: }
1161:
1162: // Splice $positional into $args to ensure $nextArgumentIndex is correct
1163: if ($positional) {
1164: $positionalArgs = count($positional);
1165: $i -= $positionalArgs;
1166: array_splice($args, $i, $positionalArgs, $positional);
1167: }
1168: $pending = count($this->PositionalOptions);
1169: foreach ($this->PositionalOptions as $option) {
1170: if ($i >= $totalArgs) {
1171: break;
1172: }
1173: $pending--;
1174: $key = $option->Key;
1175: $saved = false;
1176: if ($option->Required || !$option->MultipleAllowed) {
1177: $arg = $args[$i++];
1178: $merged[$key] = $option->MultipleAllowed ? [$arg] : $arg;
1179: $saveArgValue($key, $arg);
1180: if (!$option->MultipleAllowed) {
1181: continue;
1182: }
1183: }
1184: // Only one positional option can accept multiple values, so collect
1185: // arguments until all that remains is one per pending option
1186: while ($totalArgs - $i - $pending > 0) {
1187: $saved = false;
1188: $arg = $args[$i++];
1189: $merged[$key] = array_merge((array) ($merged[$key] ?? null), [$arg]);
1190: $saveArgValue($key, $arg);
1191: }
1192: }
1193:
1194: $nextArgumentIndex = $i;
1195:
1196: return $merged;
1197: }
1198:
1199: private function optionError(string $message): void
1200: {
1201: $this->OptionErrors[] = $message;
1202: }
1203:
1204: /**
1205: * Get the command's options
1206: *
1207: * @return list<CliOption>
1208: */
1209: final protected function getOptions(): array
1210: {
1211: return array_values($this->_getOptions());
1212: }
1213:
1214: /**
1215: * @return array<string,CliOption>
1216: */
1217: private function _getOptions(): array
1218: {
1219: /** @var array<string,CliOption> */
1220: $options = $this->loadOptions()->Options;
1221:
1222: return $options;
1223: }
1224:
1225: /**
1226: * @return $this
1227: * @phpstan-assert !null $this->Options
1228: */
1229: private function loadOptions()
1230: {
1231: if ($this->Options !== null) {
1232: return $this;
1233: }
1234:
1235: try {
1236: foreach ($this->getOptionList() as $option) {
1237: $this->addOption($option);
1238: }
1239:
1240: return $this->maybeAddOption('help')->maybeAddOption('version');
1241: } catch (Throwable $ex) {
1242: $this->Options = null;
1243: $this->OptionsByName = [];
1244: $this->PositionalOptions = [];
1245: $this->SchemaOptions = [];
1246: $this->DeferredOptionErrors = [];
1247:
1248: throw $ex;
1249: }
1250: }
1251:
1252: /**
1253: * @return $this
1254: */
1255: private function maybeAddOption(string $long)
1256: {
1257: if (!isset($this->OptionsByName[$long])) {
1258: $this->addOption(CliOption::build()->long($long)->hide());
1259: }
1260: return $this;
1261: }
1262:
1263: /**
1264: * @param CliOption|CliOptionBuilder $option
1265: */
1266: private function addOption($option): void
1267: {
1268: $option = CliOption::resolve($option);
1269: try {
1270: $option = $option->load();
1271: } catch (CliUnknownValueException $ex) {
1272: // If an exception is thrown over a value found in the environment,
1273: // defer it in case we're only loading options for a help message
1274: $this->DeferredOptionErrors[] = $ex->getMessage();
1275: }
1276:
1277: $names = $option->getNames();
1278:
1279: if (array_intersect_key(array_flip($names), $this->OptionsByName)) {
1280: throw new LogicException(sprintf('Option names must be unique: %s', implode(', ', $names)));
1281: }
1282:
1283: if ($option->Visibility & CliOptionVisibility::SCHEMA) {
1284: $name = Str::camel((string) $option->Name);
1285: if ($name === '' || isset($this->SchemaOptions[$name])) {
1286: throw new LogicException(sprintf(
1287: 'Schema option names must be unique and non-empty after camelCase conversion: %s',
1288: $option->Name
1289: ));
1290: }
1291: $this->SchemaOptions[$name] = $option;
1292: }
1293:
1294: if ($option->IsPositional) {
1295: if ($option->WasRequired
1296: && array_filter(
1297: $this->PositionalOptions,
1298: fn(CliOption $opt) =>
1299: !$opt->WasRequired && !$opt->MultipleAllowed
1300: )) {
1301: throw new LogicException('Required positional options must be added before optional ones');
1302: }
1303:
1304: if (!$option->WasRequired
1305: && array_filter(
1306: $this->PositionalOptions,
1307: fn(CliOption $opt) =>
1308: $opt->MultipleAllowed
1309: )) {
1310: throw new LogicException("'multipleAllowed' positional options must be added after optional ones");
1311: }
1312:
1313: if ($option->MultipleAllowed
1314: && array_filter(
1315: $this->PositionalOptions,
1316: fn(CliOption $opt) =>
1317: $opt->MultipleAllowed
1318: )) {
1319: throw new LogicException("'multipleAllowed' cannot be set on more than one positional option");
1320: }
1321:
1322: $this->PositionalOptions[$option->Key] = $option;
1323: }
1324:
1325: $this->Options[$option->Key] = $option;
1326:
1327: foreach ($names as $name) {
1328: $this->OptionsByName[$name] = $option;
1329: }
1330: }
1331:
1332: /**
1333: * @return $this
1334: */
1335: private function assertHasRun()
1336: {
1337: if ($this->OptionValues === null) {
1338: throw new LogicException('Command must be invoked first');
1339: }
1340:
1341: return $this;
1342: }
1343:
1344: /**
1345: * True if the command is currently running
1346: */
1347: final protected function isRunning(): bool
1348: {
1349: return $this->IsRunning;
1350: }
1351:
1352: /**
1353: * Set the command's return value / exit status
1354: *
1355: * @return $this
1356: *
1357: * @see CliCommand::run()
1358: */
1359: final protected function setExitStatus(int $status)
1360: {
1361: $this->ExitStatus = $status;
1362:
1363: return $this;
1364: }
1365:
1366: /**
1367: * Get the current return value / exit status
1368: *
1369: * @see CliCommand::setExitStatus()
1370: * @see CliCommand::run()
1371: */
1372: final protected function getExitStatus(): int
1373: {
1374: return $this->ExitStatus;
1375: }
1376:
1377: /**
1378: * Get the number of times the command has run, including the current run
1379: * (if applicable)
1380: */
1381: final public function getRuns(): int
1382: {
1383: return $this->Runs;
1384: }
1385:
1386: private function reset(): void
1387: {
1388: $this->Arguments = [];
1389: $this->ArgumentValues = [];
1390: $this->OptionValues = null;
1391: $this->OptionErrors = [];
1392: $this->NextArgumentIndex = null;
1393: $this->HasHelpArgument = false;
1394: $this->HasVersionArgument = false;
1395: $this->ExitStatus = 0;
1396: }
1397: }
1398: