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