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\Contract\Cli\CliOptionType;
8: use Salient\Contract\Cli\CliOptionValueType;
9: use Salient\Contract\Cli\CliOptionValueUnknownPolicy;
10: use Salient\Contract\Cli\CliOptionVisibility;
11: use Salient\Contract\Core\Entity\Readable;
12: use Salient\Contract\Core\Buildable;
13: use Salient\Contract\Core\HasJsonSchema;
14: use Salient\Contract\Core\Immutable;
15: use Salient\Core\Concern\BuildableTrait;
16: use Salient\Core\Concern\ReadableProtectedPropertiesTrait;
17: use Salient\Core\Facade\Console;
18: use Salient\Utility\Arr;
19: use Salient\Utility\Env;
20: use Salient\Utility\File;
21: use Salient\Utility\Format;
22: use Salient\Utility\Get;
23: use Salient\Utility\Inflect;
24: use Salient\Utility\Reflect;
25: use Salient\Utility\Regex;
26: use Salient\Utility\Str;
27: use Salient\Utility\Test;
28: use DateTimeImmutable;
29: use LogicException;
30:
31: /**
32: * A getopt-style option for a CLI command
33: *
34: * @property-read string|null $Name The name of the option
35: * @property-read string|null $Long The long form of the option, e.g. "verbose"
36: * @property-read string|null $Short The short form of the option, e.g. "v"
37: * @property-read string $Key The option's internal identifier
38: * @property-read string|null $ValueName The name of the option's value as it appears in usage information
39: * @property-read string $DisplayName The option's name as it appears in error messages
40: * @property-read string|null $Description A description of the option
41: * @property-read CliOptionType::* $OptionType The option's type
42: * @property-read bool $IsFlag True if the option is a flag
43: * @property-read bool $IsOneOf True if the option accepts values from a list
44: * @property-read bool $IsPositional True if the option is positional
45: * @property-read bool $ValueRequired True if the option has a mandatory value
46: * @property-read bool $ValueOptional True if the option has an optional value
47: * @property-read CliOptionValueType::* $ValueType The data type of the option's value
48: * @property-read array<string|int|bool|float>|null $AllowedValues The option's possible values, indexed by lowercase value if not case-sensitive
49: * @property-read bool $CaseSensitive True if the option's values are case-sensitive
50: * @property-read CliOptionValueUnknownPolicy::*|null $UnknownValuePolicy The action taken if an unknown value is given
51: * @property-read bool $Required True if the option is mandatory
52: * @property-read bool $WasRequired True if the option was mandatory before applying values from the environment
53: * @property-read bool $MultipleAllowed True if the option may be given more than once
54: * @property-read bool $Unique True if the same value may not be given more than once
55: * @property-read bool $AddAll True if "ALL" should be added to the list of possible values when the option can be given more than once
56: * @property-read array<string|int|bool|float>|string|int|bool|float|null $DefaultValue Assigned to the option if no value is given on the command line
57: * @property-read array<string|int|bool|float>|string|int|bool|float|null $OriginalDefaultValue The option's default value before applying values from the environment
58: * @property-read bool $Nullable True if the option's value should be null if it is not given on the command line
59: * @property-read string|null $EnvVariable The name of a value in the environment that replaces the option's default value
60: * @property-read non-empty-string|null $Delimiter The separator between values passed to the option as a single argument
61: * @property-read (callable(array<string|int|bool|float>|string|int|bool|float): mixed)|null $ValueCallback Applied to the option's value as it is assigned
62: * @property-read int-mask-of<CliOptionVisibility::*> $Visibility The option's visibility to users
63: * @property-read bool $IsBound True if the option is bound to a variable
64: *
65: * @implements Buildable<CliOptionBuilder>
66: */
67: final class CliOption implements Buildable, HasJsonSchema, Immutable, Readable
68: {
69: /** @use BuildableTrait<CliOptionBuilder> */
70: use BuildableTrait;
71: use ReadableProtectedPropertiesTrait;
72:
73: private const LONG_REGEX = '/^[a-z0-9_][-a-z0-9_]++$/iD';
74: private const SHORT_REGEX = '/^[a-z0-9_]$/iD';
75:
76: private const ONE_OF_INDEX = [
77: CliOptionType::ONE_OF => true,
78: CliOptionType::ONE_OF_OPTIONAL => true,
79: CliOptionType::ONE_OF_POSITIONAL => true,
80: ];
81:
82: private const POSITIONAL_INDEX = [
83: CliOptionType::VALUE_POSITIONAL => true,
84: CliOptionType::ONE_OF_POSITIONAL => true,
85: ];
86:
87: private const VALUE_REQUIRED_INDEX = [
88: CliOptionType::VALUE => true,
89: CliOptionType::VALUE_POSITIONAL => true,
90: CliOptionType::ONE_OF => true,
91: CliOptionType::ONE_OF_POSITIONAL => true,
92: ];
93:
94: private const VALUE_OPTIONAL_INDEX = [
95: CliOptionType::VALUE_OPTIONAL => true,
96: CliOptionType::ONE_OF_OPTIONAL => true,
97: ];
98:
99: /**
100: * @var array<CliOptionValueType::*,string>
101: */
102: private const JSON_SCHEMA_TYPE_MAP = [
103: CliOptionValueType::BOOLEAN => 'boolean',
104: CliOptionValueType::INTEGER => 'integer',
105: CliOptionValueType::STRING => 'string',
106: CliOptionValueType::FLOAT => 'number',
107: CliOptionValueType::DATE => 'string',
108: CliOptionValueType::PATH => 'string',
109: CliOptionValueType::FILE => 'string',
110: CliOptionValueType::DIRECTORY => 'string',
111: CliOptionValueType::PATH_OR_DASH => 'string',
112: CliOptionValueType::FILE_OR_DASH => 'string',
113: CliOptionValueType::DIRECTORY_OR_DASH => 'string',
114: CliOptionValueType::NEW_PATH => 'string',
115: CliOptionValueType::NEW_FILE => 'string',
116: CliOptionValueType::NEW_DIRECTORY => 'string',
117: CliOptionValueType::NEW_PATH_OR_DASH => 'string',
118: CliOptionValueType::NEW_FILE_OR_DASH => 'string',
119: CliOptionValueType::NEW_DIRECTORY_OR_DASH => 'string',
120: ];
121:
122: /**
123: * The name of the option
124: */
125: protected ?string $Name;
126:
127: /**
128: * The long form of the option, e.g. "verbose"
129: */
130: protected ?string $Long;
131:
132: /**
133: * The short form of the option, e.g. "v"
134: */
135: protected ?string $Short;
136:
137: /**
138: * The option's internal identifier
139: */
140: protected string $Key;
141:
142: /**
143: * The name of the option's value as it appears in usage information
144: */
145: protected ?string $ValueName;
146:
147: /**
148: * The option's name as it appears in error messages
149: */
150: protected string $DisplayName;
151:
152: /**
153: * A description of the option
154: */
155: protected ?string $Description;
156:
157: /**
158: * The option's type
159: *
160: * @var CliOptionType::*
161: */
162: protected int $OptionType;
163:
164: /**
165: * True if the option is a flag
166: */
167: protected bool $IsFlag;
168:
169: /**
170: * True if the option accepts values from a list
171: */
172: protected bool $IsOneOf;
173:
174: /**
175: * True if the option is positional
176: */
177: protected bool $IsPositional;
178:
179: /**
180: * True if the option has a mandatory value
181: */
182: protected bool $ValueRequired;
183:
184: /**
185: * True if the option has an optional value
186: */
187: protected bool $ValueOptional;
188:
189: /**
190: * The data type of the option's value
191: *
192: * @var CliOptionValueType::*
193: */
194: protected int $ValueType;
195:
196: /**
197: * The option's possible values, indexed by lowercase value if not
198: * case-sensitive
199: *
200: * @var array<string|int|bool|float>|null
201: */
202: protected ?array $AllowedValues;
203:
204: /**
205: * True if the option's values are case-sensitive
206: *
207: * If strings in {@see CliOption::$AllowedValues} are unique after
208: * conversion to lowercase, {@see CliOption::$CaseSensitive} is `false`.
209: */
210: protected bool $CaseSensitive = true;
211:
212: /**
213: * The action taken if an unknown value is given
214: *
215: * @var CliOptionValueUnknownPolicy::*|null
216: */
217: protected ?int $UnknownValuePolicy;
218:
219: /**
220: * True if the option is mandatory
221: */
222: protected bool $Required;
223:
224: /**
225: * True if the option was mandatory before applying values from the
226: * environment
227: */
228: protected bool $WasRequired;
229:
230: /**
231: * True if the option may be given more than once
232: */
233: protected bool $MultipleAllowed;
234:
235: /**
236: * True if the same value may not be given more than once
237: */
238: protected bool $Unique;
239:
240: /**
241: * True if "ALL" should be added to the list of possible values when the
242: * option can be given more than once
243: */
244: protected bool $AddAll;
245:
246: /**
247: * Assigned to the option if no value is given on the command line
248: *
249: * @var array<string|int|bool|float>|string|int|bool|float|null
250: */
251: protected $DefaultValue;
252:
253: /**
254: * The option's default value before applying values from the environment
255: *
256: * @var array<string|int|bool|float>|string|int|bool|float|null
257: */
258: protected $OriginalDefaultValue;
259:
260: /**
261: * True if the option's value should be null if it is not given on the
262: * command line
263: */
264: protected bool $Nullable;
265:
266: /**
267: * The name of a value in the environment that replaces the option's default
268: * value
269: */
270: protected ?string $EnvVariable;
271:
272: /**
273: * The separator between values passed to the option as a single argument
274: *
275: * @var non-empty-string|null
276: */
277: protected ?string $Delimiter;
278:
279: /**
280: * Applied to the option's value as it is assigned
281: *
282: * Providing a {@see CliOption::$ValueCallback} disables conversion of the
283: * option's value to {@see CliOption::$ValueType}. The callback should
284: * return a value of the expected type.
285: *
286: * @var (callable(array<string|int|bool|float>|string|int|bool|float): mixed)|null
287: */
288: protected $ValueCallback;
289:
290: /**
291: * The option's visibility to users
292: *
293: * @var int-mask-of<CliOptionVisibility::*>
294: */
295: protected int $Visibility;
296:
297: /**
298: * True if the option is bound to a variable
299: */
300: protected bool $IsBound;
301:
302: /**
303: * @var mixed
304: * @phpstan-ignore property.onlyWritten
305: */
306: private $BindTo;
307:
308: private bool $IsLoaded = false;
309:
310: /**
311: * @internal
312: *
313: * @template TValue
314: *
315: * @param string|null $name The name of the option (ignored if not
316: * positional; must start with a letter, number or underscore, followed by
317: * one or more letters, numbers, underscores or hyphens)
318: * @param string|null $long The long form of the option, e.g. "verbose"
319: * (ignored if positional and name is given; must start with a letter,
320: * number or underscore, followed by one or more letters, numbers,
321: * underscores or hyphens)
322: * @param string|null $short The short form of the option, e.g. "v" (ignored
323: * if positional; must contain one letter, number or underscore)
324: * @param CliOptionType::* $optionType
325: * @param CliOptionValueType::* $valueType
326: * @param array<string|int|bool|float>|null $allowedValues
327: * @param CliOptionValueUnknownPolicy::* $unknownValuePolicy
328: * @param array<string|int|bool|float>|string|int|bool|float|null $defaultValue
329: * @param string|null $envVariable The name of a value in the environment
330: * that replaces the option's default value (ignored if positional)
331: * @param (callable(array<string|int|bool|float>|string|int|bool|float): TValue)|null $valueCallback
332: * @param int-mask-of<CliOptionVisibility::*> $visibility
333: * @param bool $inSchema True if the option should be included when
334: * generating a JSON Schema.
335: * @param bool $hide True if the option's visibility should be
336: * {@see CliOptionVisibility::NONE}.
337: * @param TValue $bindTo Bind the option's value to a variable.
338: */
339: public function __construct(
340: ?string $name,
341: ?string $long,
342: ?string $short,
343: ?string $valueName,
344: ?string $description,
345: int $optionType = CliOptionType::FLAG,
346: int $valueType = CliOptionValueType::STRING,
347: ?array $allowedValues = null,
348: int $unknownValuePolicy = CliOptionValueUnknownPolicy::REJECT,
349: bool $required = false,
350: bool $multipleAllowed = false,
351: bool $unique = false,
352: bool $addAll = false,
353: $defaultValue = null,
354: bool $nullable = false,
355: ?string $envVariable = null,
356: ?string $delimiter = ',',
357: ?callable $valueCallback = null,
358: int $visibility = CliOptionVisibility::ALL,
359: bool $inSchema = false,
360: bool $hide = false,
361: &$bindTo = null
362: ) {
363: $this->OptionType = $optionType;
364: $this->IsFlag = $optionType === CliOptionType::FLAG;
365: $this->IsOneOf = self::ONE_OF_INDEX[$optionType] ?? false;
366: $this->IsPositional = self::POSITIONAL_INDEX[$optionType] ?? false;
367: $this->ValueRequired = self::VALUE_REQUIRED_INDEX[$optionType] ?? false;
368: $this->ValueOptional = self::VALUE_OPTIONAL_INDEX[$optionType] ?? false;
369:
370: if ($this->IsPositional) {
371: $name = Str::coalesce($name, $long, null);
372: $long = null;
373: $short = null;
374: $envVariable = null;
375: } else {
376: $name = null;
377: }
378:
379: $this->Name = Str::coalesce($name, $long, $short, null);
380: $this->Long = Str::coalesce($long, null);
381: $this->Short = Str::coalesce($short, null);
382: $this->Key = sprintf('%s|%s', $this->Short, $this->Long ?? $this->Name);
383: $this->EnvVariable = Str::coalesce($envVariable, null);
384:
385: if ($this->IsPositional) {
386: $this->ValueName = Str::coalesce($valueName, Str::kebab((string) $name, '='), 'value');
387: $this->DisplayName = $this->getValueName();
388: } else {
389: $this->ValueName = $this->IsFlag ? null : Str::coalesce($valueName, 'value');
390: $this->DisplayName = $this->Long !== null ? '--' . $long : '-' . $short;
391: }
392:
393: if ($this->IsFlag) {
394: if ($multipleAllowed) {
395: $valueType = CliOptionValueType::INTEGER;
396: $defaultValue = 0;
397: } else {
398: $valueType = CliOptionValueType::BOOLEAN;
399: $defaultValue = false;
400: }
401: $required = false;
402: $this->Delimiter = null;
403: } elseif ($multipleAllowed) {
404: $this->Delimiter = Str::coalesce($delimiter, null);
405: $defaultValue = $this->maybeSplitValue($defaultValue);
406: } else {
407: $this->Delimiter = null;
408: }
409:
410: $this->ValueType = $valueType;
411: $this->WasRequired = $this->Required = $required;
412: $this->MultipleAllowed = $multipleAllowed;
413: $this->Unique = $unique && $multipleAllowed;
414: $this->Description = $description;
415: $this->OriginalDefaultValue = $this->DefaultValue = $defaultValue;
416:
417: if ($this->IsOneOf) {
418: $this->AllowedValues = $allowedValues;
419: $this->UnknownValuePolicy = $unknownValuePolicy;
420: $this->AddAll = $addAll && $multipleAllowed;
421: } else {
422: $this->AllowedValues = null;
423: $this->UnknownValuePolicy = null;
424: $this->AddAll = false;
425: }
426:
427: $this->Nullable = $nullable;
428: $this->ValueCallback = $valueCallback;
429: $this->Visibility = $hide ? CliOptionVisibility::NONE : $visibility;
430:
431: if ($inSchema) {
432: $this->Visibility |= CliOptionVisibility::SCHEMA;
433: }
434:
435: if (func_num_args() >= 22) {
436: $this->BindTo = &$bindTo;
437: $this->IsBound = true;
438: } else {
439: $this->IsBound = false;
440: }
441: }
442:
443: /**
444: * Prepare the option for use with a command
445: *
446: * @return static
447: */
448: public function load()
449: {
450: if ($this->IsLoaded) {
451: // @codeCoverageIgnoreStart
452: return $this;
453: // @codeCoverageIgnoreEnd
454: }
455:
456: $clone = clone $this;
457: $clone->doLoad();
458: $clone->IsLoaded = true;
459: return $clone;
460: }
461:
462: private function doLoad(): void
463: {
464: if ($this->IsPositional) {
465: if ($this->Name === null) {
466: throw new LogicException("'name' or 'long' must be set");
467: }
468: if (!Regex::match(self::LONG_REGEX, $this->Name)) {
469: throw new LogicException("'name' must start with a letter, number or underscore, followed by one or more letters, numbers, underscores or hyphens");
470: }
471: } else {
472: if ($this->Long === null && $this->Short === null) {
473: throw new LogicException("At least one of 'long' and 'short' must be set");
474: }
475: if ($this->Long !== null && !Regex::match(self::LONG_REGEX, $this->Long)) {
476: throw new LogicException("'long' must start with a letter, number or underscore, followed by one or more letters, numbers, underscores or hyphens");
477: }
478: if ($this->Short !== null && !Regex::match(self::SHORT_REGEX, $this->Short)) {
479: throw new LogicException("'short' must contain one letter, number or underscore");
480: }
481: }
482:
483: if (
484: $this->ValueOptional
485: && ($this->DefaultValue === null || $this->DefaultValue === [])
486: && !($this->MultipleAllowed && $this->Nullable)
487: ) {
488: throw new LogicException("'defaultValue' cannot be empty when value is optional");
489: }
490:
491: if (is_array($this->DefaultValue)) {
492: if (!$this->checkValueTypes($this->DefaultValue)) {
493: throw new LogicException(sprintf("'defaultValue' must be a value, or an array of values, of type %s", $this->getValueTypeName()));
494: }
495: } elseif ($this->DefaultValue !== null && !$this->IsFlag) {
496: if (!$this->checkValueType($this->DefaultValue)) {
497: throw new LogicException(sprintf("'defaultValue' must be a value of type %s", $this->getValueTypeName()));
498: }
499: }
500:
501: if ($this->EnvVariable !== null && Env::has($this->EnvVariable)) {
502: $value = Env::get($this->EnvVariable);
503: if ($this->IsFlag) {
504: if ($this->MultipleAllowed && Test::isInteger($value) && (int) $value >= 0) {
505: $this->DefaultValue = (int) $value;
506: } elseif (Test::isBoolean($value)) {
507: $value = Get::boolean($value);
508: $this->DefaultValue = $this->MultipleAllowed ? (int) $value : $value;
509: } else {
510: $this->throwEnvVariableException($value);
511: }
512: } elseif ($this->MultipleAllowed) {
513: $values = $this->maybeSplitValue($value);
514: if (!$this->checkValueTypes($values)) {
515: $this->throwEnvVariableException($value);
516: }
517: $this->DefaultValue = $values;
518: } else {
519: if (!$this->checkValueType($value)) {
520: $this->throwEnvVariableException($value);
521: }
522: $this->DefaultValue = $value;
523: }
524: if ($this->DefaultValue !== []) {
525: $this->Required = false;
526: }
527: }
528:
529: if (!$this->IsOneOf) {
530: return;
531: }
532:
533: if (
534: !$this->AllowedValues
535: || !$this->checkValueTypes($this->AllowedValues)
536: ) {
537: throw new LogicException(sprintf("'allowedValues' must be an array of values of type %s", $this->getValueTypeName()));
538: }
539:
540: if (count(Arr::unique($this->AllowedValues)) !== count($this->AllowedValues)) {
541: throw new LogicException("Values in 'allowedValues' must be unique");
542: }
543:
544: if ($this->ValueType === CliOptionValueType::STRING) {
545: $lower = Arr::lower($this->AllowedValues);
546: if (count(Arr::unique($lower)) === count($this->AllowedValues)) {
547: $this->CaseSensitive = false;
548: $this->AllowedValues = Arr::combine($lower, $this->AllowedValues);
549: }
550: }
551:
552: if ($this->AddAll) {
553: if ($this->CaseSensitive) {
554: $values = array_diff($this->AllowedValues, ['ALL']);
555: if ($values === $this->AllowedValues) {
556: $this->AllowedValues[] = 'ALL';
557: }
558: } else {
559: $values = $this->AllowedValues;
560: unset($values['all']);
561: if ($values === $this->AllowedValues) {
562: $this->AllowedValues['all'] = 'ALL';
563: }
564: }
565:
566: if (!$values) {
567: throw new LogicException("'allowedValues' must have at least one value other than 'ALL'");
568: }
569:
570: if (
571: $this->DefaultValue && (
572: Arr::sameValues((array) $this->DefaultValue, $values)
573: || in_array('ALL', (array) $this->DefaultValue, true)
574: )
575: ) {
576: $this->DefaultValue = ['ALL'];
577: }
578: }
579:
580: if ($this->OriginalDefaultValue !== null) {
581: try {
582: $this->filterValue(
583: $this->OriginalDefaultValue,
584: "'defaultValue'",
585: CliOptionValueUnknownPolicy::REJECT
586: );
587: } catch (CliUnknownValueException $ex) {
588: throw new LogicException(Str::upperFirst($ex->getMessage()));
589: }
590: }
591:
592: if (
593: $this->EnvVariable !== null
594: && $this->DefaultValue !== null
595: && $this->DefaultValue !== $this->OriginalDefaultValue
596: ) {
597: $this->DefaultValue = $this->filterValue(
598: $this->DefaultValue,
599: sprintf("environment variable '%s'", $this->EnvVariable)
600: );
601: }
602: }
603:
604: /**
605: * @return never
606: */
607: private function throwEnvVariableException(string $value): void
608: {
609: throw new CliInvalidArgumentsException(sprintf(
610: "invalid %s value in environment variable '%s' (expected%s %s): %s",
611: $this->DisplayName,
612: $this->EnvVariable,
613: $this->MultipleAllowed ? ' list of' : '',
614: $this->getValueTypeName(),
615: $value,
616: ));
617: }
618:
619: /**
620: * Get a JSON Schema for the option
621: *
622: * @return array{description?:string,type?:string[]|string,enum?:array<string|int|bool|float|null>,items?:array{type?:string[]|string,enum?:array<string|int|bool|float|null>},uniqueItems?:bool,default?:array<string|int|bool|float>|string|int|bool|float}
623: */
624: public function getJsonSchema(): array
625: {
626: $schema = [];
627:
628: $summary = $this->getSummary();
629: if ($summary !== null) {
630: $schema['description'][] = $summary;
631: }
632:
633: if ($this->IsOneOf) {
634: $type['enum'] = $this->normaliseForSchema(array_values((array) $this->AllowedValues));
635: } else {
636: $type['type'][] = self::JSON_SCHEMA_TYPE_MAP[$this->ValueType];
637: }
638:
639: if ($this->ValueOptional) {
640: if ($this->ValueType !== CliOptionValueType::BOOLEAN) {
641: if (isset($type['enum'])) {
642: $type['enum'][] = true;
643: } else {
644: $type['type'][] = 'boolean';
645: }
646: $types[] = 'true';
647: }
648: if (isset($type['enum'])) {
649: $type['enum'][] = null;
650: } else {
651: $type['type'][] = 'null';
652: }
653: $types[] = 'null';
654: $schema['description'][] = sprintf(
655: 'The %s applied if %s is: %s',
656: $this->getValueNameWords(),
657: implode(' or ', $types),
658: Format::value($this->DefaultValue),
659: );
660: }
661:
662: if (isset($type['type'])) {
663: if (count($type['type']) === 1) {
664: $type['type'] = $type['type'][0];
665: }
666: }
667:
668: if ($this->MultipleAllowed && !$this->IsFlag) {
669: $schema['type'] = 'array';
670: $schema['items'] = $type;
671: if ($this->Unique) {
672: $schema['uniqueItems'] = true;
673: }
674: } else {
675: $schema += $type;
676: }
677:
678: if (
679: $this->OriginalDefaultValue !== null
680: && $this->OriginalDefaultValue !== []
681: ) {
682: $schema['default'] = $this->ValueOptional
683: ? false
684: : $this->normaliseForSchema($this->OriginalDefaultValue);
685: }
686:
687: if (array_key_exists('description', $schema)) {
688: $schema['description'] = implode(' ', $schema['description']);
689: }
690:
691: return $schema;
692: }
693:
694: /**
695: * Get the option's names
696: *
697: * @return string[]
698: */
699: public function getNames(): array
700: {
701: return Arr::unique(Arr::whereNotNull([$this->Long ?? $this->Name, $this->Short]));
702: }
703:
704: /**
705: * Get the option's value name as lowercase, space-separated words
706: */
707: public function getValueNameWords(): string
708: {
709: if ($this->ValueName === null) {
710: return '';
711: }
712:
713: return Str::lower(Str::words($this->ValueName));
714: }
715:
716: /**
717: * Get the option's value name
718: *
719: * - If {@see CliOption::$ValueName} contains one or more angle brackets, it
720: * is returned as-is, e.g. `<key>=<VALUE>`
721: * - If it contains uppercase characters and no lowercase characters, it is
722: * converted to kebab-case and capitalised, e.g. `VALUE-NAME`
723: * - Otherwise, it is converted to kebab-case and enclosed between angle
724: * brackets, e.g. `<value-name>`
725: *
726: * If `$encloseUpper` is `true`, capitalised value names are enclosed
727: * between angle brackets, e.g. `<VALUE-NAME>`.
728: *
729: * In conversions to kebab-case, `=` is preserved.
730: */
731: public function getValueName(bool $encloseUpper = false): string
732: {
733: if (
734: $this->ValueName === null
735: || strpbrk($this->ValueName, '<>') !== false
736: ) {
737: return (string) $this->ValueName;
738: }
739:
740: $name = Str::kebab($this->ValueName, '=');
741:
742: if (
743: strpbrk($this->ValueName, Str::UPPER) !== false
744: && strpbrk($this->ValueName, Str::LOWER) === false
745: ) {
746: $name = Str::upper($name);
747: if (!$encloseUpper) {
748: return $name;
749: }
750: }
751:
752: return '<' . $name . '>';
753: }
754:
755: /**
756: * Get the option's allowed values
757: *
758: * Example: `" (one or more of: first,last)"`
759: *
760: * Returns an empty string if the option doesn't have allowed values.
761: *
762: * @param string $format `"{}"` is replaced with a delimited list of values,
763: * and if {@see CliOption::$MultipleAllowed} is `true`, `"?"` is replaced
764: * with `" or more"`.
765: */
766: public function formatAllowedValues(string $format = ' (one? of: {})'): string
767: {
768: if (!$this->AllowedValues) {
769: return '';
770: }
771:
772: $delimiter = Str::coalesce(
773: $this->MultipleAllowed ? $this->Delimiter : null,
774: ','
775: );
776:
777: return str_replace(
778: ['?', '{}'],
779: [
780: $this->MultipleAllowed ? ' or more' : '',
781: implode($delimiter, $this->AllowedValues),
782: ],
783: $format
784: );
785: }
786:
787: /**
788: * Get the first paragraph of the option's description, unwrapping any line
789: * breaks
790: */
791: public function getSummary(bool $withFullStop = true): ?string
792: {
793: if ($this->Description === null) {
794: return null;
795: }
796: $desc = ltrim($this->Description);
797: $desc = Str::setEol($desc);
798: $desc = explode("\n\n", $desc, 2)[0];
799: $desc = rtrim($desc);
800: if ($desc === '') {
801: return null;
802: }
803: if ($withFullStop && strpbrk($desc[-1], '.!?') === false) {
804: $desc .= '.';
805: }
806: return Regex::replace('/\s+/', ' ', $desc);
807: }
808:
809: /**
810: * If a value is a non-empty string, split it on the option's delimiter,
811: * otherwise wrap it in an array if needed
812: *
813: * If `$value` is `null` or an empty string, an empty array is returned.
814: *
815: * @template T of DateTimeImmutable|string|int|bool|float
816: *
817: * @param T[]|T|null $value
818: * @return T[]
819: */
820: public function maybeSplitValue($value): array
821: {
822: if (is_array($value)) {
823: return $value;
824: }
825: if ($value === null || $value === '') {
826: return [];
827: }
828: if ($this->Delimiter !== null && is_string($value)) {
829: // @phpstan-ignore return.type
830: return explode($this->Delimiter, $value);
831: }
832: return [$value];
833: }
834:
835: /**
836: * Normalise a value, assign it to the option's bound variable, and return
837: * it to the caller
838: *
839: * @param array<string|int|bool|float>|string|int|bool|float|null $value
840: * @param bool $normalise `false` if `$value` has already been normalised.
841: * @param bool $expand If `true` and the option has an optional value,
842: * expand `null` or `true` to the default value of the option. Ignored if
843: * `$normalise` is `false`.
844: * @return mixed
845: */
846: public function applyValue($value, bool $normalise = true, bool $expand = false)
847: {
848: if ($normalise) {
849: $value = $this->normaliseValue($value, $expand);
850: }
851: if ($this->IsBound) {
852: $this->BindTo = $value;
853: }
854: return $value;
855: }
856:
857: /**
858: * If the option has a callback, apply it to a value, otherwise convert the
859: * value to the option's value type
860: *
861: * @param array<string|int|bool|float>|string|int|bool|float|null $value
862: * @param bool $expand If `true` and the option has an optional value,
863: * expand `null` or `true` to the default value of the option.
864: * @return mixed
865: */
866: public function normaliseValue($value, bool $expand = false)
867: {
868: if (
869: $expand
870: && $this->ValueOptional
871: && ($value === null
872: || ($this->ValueType !== CliOptionValueType::BOOLEAN
873: && $value === true))
874: ) {
875: $value = $this->DefaultValue;
876: }
877:
878: if ($value === null) {
879: return $this->Nullable
880: ? null
881: : ($this->OptionType === CliOptionType::FLAG
882: ? ($this->MultipleAllowed ? 0 : false)
883: : ($this->MultipleAllowed ? [] : null));
884: }
885:
886: if ($this->AllowedValues) {
887: $value = $this->filterValue($value);
888: if ($this->AddAll && in_array('ALL', Arr::wrap($value), true)) {
889: $value = array_values(array_diff($this->AllowedValues, ['ALL']));
890: }
891: } else {
892: if (is_array($value)) {
893: if ($this->IsFlag && (
894: !$this->MultipleAllowed || Arr::unique($value) !== [true]
895: )) {
896: $this->throwValueTypeException($value);
897: } elseif (!$this->MultipleAllowed) {
898: throw new CliInvalidArgumentsException(sprintf(
899: '%s does not accept multiple values',
900: $this->DisplayName,
901: ));
902: }
903: }
904: if ($this->IsFlag && $this->MultipleAllowed && !is_int($value)) {
905: if (is_array($value)) {
906: $value = count($value);
907: } elseif (Test::isBoolean($value)) {
908: $value = Get::boolean($value) ? 1 : 0;
909: }
910: }
911: }
912:
913: if ($this->ValueCallback !== null) {
914: $this->maybeCheckUnique($value);
915: // @phpstan-ignore callable.nonCallable
916: return ($this->ValueCallback)($value);
917: }
918:
919: if (!is_array($value)) {
920: return $this->normaliseValueType($value);
921: }
922:
923: $values = [];
924: foreach ($value as $value) {
925: $values[] = $this->normaliseValueType($value);
926: }
927: $this->maybeCheckUnique($values);
928: return $values;
929: }
930:
931: /**
932: * If the option has allowed values, check a given value is valid and apply
933: * the option's unknown value policy if not
934: *
935: * @param array<string|int|bool|float>|string|int|bool|float|null $value
936: * @param CliOptionValueUnknownPolicy::*|null $policy Overrides the option's
937: * unknown value policy if given.
938: * @return ($value is null ? null : array<string|int|bool|float>|string|int|bool|float)
939: */
940: private function filterValue($value, ?string $source = null, ?int $policy = null)
941: {
942: $policy ??= $this->UnknownValuePolicy;
943:
944: if (
945: $value === null
946: || $value === ''
947: || $value === []
948: || !$this->AllowedValues
949: || ($this->CaseSensitive && $policy === CliOptionValueUnknownPolicy::ACCEPT)
950: ) {
951: return $value;
952: }
953:
954: $value = $this->maybeSplitValue($value);
955:
956: if (!$this->CaseSensitive) {
957: $normalised = [];
958: foreach ($value as $value) {
959: $lower = Str::lower((string) $value);
960: $normalised[] = $this->AllowedValues[$lower] ?? $value;
961: }
962: $value = $normalised;
963: }
964:
965: if ($policy !== CliOptionValueUnknownPolicy::ACCEPT) {
966: $invalid = array_diff($value, $this->AllowedValues);
967: if ($invalid) {
968: // "invalid --field values 'title','name' (expected one of: first,last)"
969: $message = Inflect::format(
970: $invalid,
971: 'invalid %s {{#:value}} %s%s%s',
972: $this->DisplayName,
973: "'" . implode("','", $invalid) . "'",
974: $source !== null ? " in $source" : '',
975: $this->formatAllowedValues(' (expected one? of: {})'),
976: );
977:
978: if ($policy !== CliOptionValueUnknownPolicy::DISCARD) {
979: throw new CliUnknownValueException($message);
980: }
981:
982: Console::message('__Warning:__', $message, Console::LEVEL_WARNING, Console::TYPE_UNFORMATTED);
983: $value = array_intersect($value, $this->AllowedValues);
984: }
985: }
986:
987: return $this->MultipleAllowed ? $value : Arr::first($value);
988: }
989:
990: /**
991: * Normalise a value for inclusion in a help message
992: *
993: * @param string|int|bool|float|null $value
994: */
995: public function normaliseValueForHelp($value): string
996: {
997: switch ($this->ValueType) {
998: case CliOptionValueType::BOOLEAN:
999: if (!$this->IsFlag && $value !== null) {
1000: $value = Get::boolean($value);
1001: return Format::yn($value);
1002: }
1003: break;
1004: }
1005:
1006: return (string) $value;
1007: }
1008:
1009: /**
1010: * @param array<string|int|bool|float>|string|int|bool|float $value
1011: * @return ($value is array ? array<string|int|bool|float> : string|int|bool|float)
1012: */
1013: private function normaliseForSchema($value)
1014: {
1015: if (
1016: $this->ValueType === CliOptionValueType::DATE
1017: || $this->ValueCallback
1018: ) {
1019: return $value;
1020: }
1021:
1022: if (!is_array($value)) {
1023: /** @var string|int|bool|float */
1024: return $this->normaliseValueType($value, false);
1025: }
1026:
1027: foreach ($value as $value) {
1028: /** @var string|int|bool|float */
1029: $value = $this->normaliseValueType($value, false);
1030: $values[] = $value;
1031: }
1032: return $values ?? [];
1033: }
1034:
1035: /**
1036: * @param string|int|bool|float $value
1037: * @return DateTimeImmutable|string|int|bool|float
1038: */
1039: private function normaliseValueType($value, bool $checkPathExists = true)
1040: {
1041: if (!$this->checkValueType($value)) {
1042: $this->throwValueTypeException($value);
1043: }
1044:
1045: switch ($this->ValueType) {
1046: case CliOptionValueType::BOOLEAN:
1047: return Get::boolean($value);
1048:
1049: case CliOptionValueType::INTEGER:
1050: return (int) $value;
1051:
1052: case CliOptionValueType::STRING:
1053: return (string) $value;
1054:
1055: case CliOptionValueType::FLOAT:
1056: return (float) $value;
1057:
1058: case CliOptionValueType::DATE:
1059: return new DateTimeImmutable((string) $value);
1060:
1061: case CliOptionValueType::PATH:
1062: return $this->checkPath((string) $value, $checkPathExists, 'path', 'file_exists');
1063:
1064: case CliOptionValueType::FILE:
1065: return $this->checkPath((string) $value, $checkPathExists, 'file', 'is_file');
1066:
1067: case CliOptionValueType::DIRECTORY:
1068: return $this->checkPath((string) $value, $checkPathExists, 'directory', 'is_dir');
1069:
1070: case CliOptionValueType::PATH_OR_DASH:
1071: return $this->checkPath((string) $value, $checkPathExists, 'path', 'file_exists', true);
1072:
1073: case CliOptionValueType::FILE_OR_DASH:
1074: return $this->checkPath((string) $value, $checkPathExists, 'file', 'is_file', true);
1075:
1076: case CliOptionValueType::DIRECTORY_OR_DASH:
1077: return $this->checkPath((string) $value, $checkPathExists, 'directory', 'is_dir', true);
1078:
1079: case CliOptionValueType::NEW_PATH:
1080: return $this->checkNewPath((string) $value, $checkPathExists, 'path', 'file_exists');
1081:
1082: case CliOptionValueType::NEW_FILE:
1083: return $this->checkNewPath((string) $value, $checkPathExists, 'file', 'is_file');
1084:
1085: case CliOptionValueType::NEW_DIRECTORY:
1086: return $this->checkNewPath((string) $value, $checkPathExists, 'directory', 'is_dir');
1087:
1088: case CliOptionValueType::NEW_PATH_OR_DASH:
1089: return $this->checkNewPath((string) $value, $checkPathExists, 'path', 'file_exists', true);
1090:
1091: case CliOptionValueType::NEW_FILE_OR_DASH:
1092: return $this->checkNewPath((string) $value, $checkPathExists, 'file', 'is_file', true);
1093:
1094: case CliOptionValueType::NEW_DIRECTORY_OR_DASH:
1095: return $this->checkNewPath((string) $value, $checkPathExists, 'directory', 'is_dir', true);
1096: }
1097: }
1098:
1099: /**
1100: * @param callable(string): bool $callback
1101: */
1102: private function checkPath(string $value, bool $checkExists, string $fileType, callable $callback, bool $dashOk = false): string
1103: {
1104: if (
1105: !$checkExists
1106: || ($dashOk && $value === '-')
1107: || $callback((string) $value)
1108: ) {
1109: return $value;
1110: }
1111:
1112: throw new CliInvalidArgumentsException(sprintf(
1113: '%s not found: %s',
1114: $fileType,
1115: $value,
1116: ));
1117: }
1118:
1119: /**
1120: * @param callable(string): bool $callback
1121: */
1122: private function checkNewPath(string $value, bool $checkExists, string $fileType, callable $callback, bool $dashOk = false): string
1123: {
1124: if (!$checkExists || ($dashOk && $value === '-')) {
1125: return $value;
1126: }
1127:
1128: if ($callback((string) $value)) {
1129: if (is_writable((string) $value)) {
1130: return $value;
1131: }
1132: } elseif (File::isCreatable((string) $value)) {
1133: return $value;
1134: }
1135:
1136: throw new CliInvalidArgumentsException(sprintf(
1137: '%s not writable: %s',
1138: $fileType,
1139: $value,
1140: ));
1141: }
1142:
1143: /**
1144: * @param array<string|int|bool|float> $values
1145: */
1146: private function checkValueTypes(array $values): bool
1147: {
1148: foreach ($values as $value) {
1149: if (!$this->checkValueType($value)) {
1150: return false;
1151: }
1152: }
1153: return true;
1154: }
1155:
1156: /**
1157: * @param mixed $value
1158: */
1159: private function checkValueType($value): bool
1160: {
1161: switch ($this->ValueType) {
1162: case CliOptionValueType::BOOLEAN:
1163: return Test::isBoolean($value);
1164:
1165: case CliOptionValueType::INTEGER:
1166: return Test::isInteger($value);
1167:
1168: case CliOptionValueType::STRING:
1169: return is_scalar($value);
1170:
1171: case CliOptionValueType::FLOAT:
1172: return Test::isFloat($value) || Test::isInteger($value);
1173:
1174: case CliOptionValueType::DATE:
1175: return Test::isDateString($value);
1176:
1177: case CliOptionValueType::PATH:
1178: case CliOptionValueType::FILE:
1179: case CliOptionValueType::DIRECTORY:
1180: case CliOptionValueType::PATH_OR_DASH:
1181: case CliOptionValueType::FILE_OR_DASH:
1182: case CliOptionValueType::DIRECTORY_OR_DASH:
1183: case CliOptionValueType::NEW_PATH:
1184: case CliOptionValueType::NEW_FILE:
1185: case CliOptionValueType::NEW_DIRECTORY:
1186: case CliOptionValueType::NEW_PATH_OR_DASH:
1187: case CliOptionValueType::NEW_FILE_OR_DASH:
1188: case CliOptionValueType::NEW_DIRECTORY_OR_DASH:
1189: return is_string($value);
1190:
1191: default:
1192: return false;
1193: }
1194: }
1195:
1196: /**
1197: * @param array<string|int|bool|float>|string|int|bool|float $value
1198: * @return never
1199: */
1200: private function throwValueTypeException($value): void
1201: {
1202: throw new CliInvalidArgumentsException(sprintf(
1203: 'invalid %s value (%s expected): %s',
1204: $this->DisplayName,
1205: $this->getValueTypeName(),
1206: Get::code($value),
1207: ));
1208: }
1209:
1210: private function getValueTypeName(): string
1211: {
1212: return Reflect::getConstantName(CliOptionValueType::class, $this->ValueType);
1213: }
1214:
1215: /**
1216: * @param array<DateTimeImmutable|string|int|bool|float>|DateTimeImmutable|string|int|bool|float|null $value
1217: */
1218: private function maybeCheckUnique($value): void
1219: {
1220: if (
1221: $value === null
1222: || $value === ''
1223: || $value === []
1224: || !$this->Unique
1225: ) {
1226: return;
1227: }
1228:
1229: $value = $this->maybeSplitValue($value);
1230:
1231: $strict = $this->ValueType !== CliOptionValueType::DATE;
1232: if (count(Arr::unique($value, false, $strict)) !== count($value)) {
1233: throw new CliInvalidArgumentsException(sprintf(
1234: '%s does not accept the same value multiple times',
1235: $this->DisplayName,
1236: ));
1237: }
1238: }
1239: }
1240: