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\Console\ConsoleMessageType as MessageType;
12: use Salient\Contract\Core\Entity\Readable;
13: use Salient\Contract\Core\Buildable;
14: use Salient\Contract\Core\Immutable;
15: use Salient\Contract\Core\JsonSchemaInterface;
16: use Salient\Contract\Core\MessageLevel as Level;
17: use Salient\Core\Concern\HasBuilder;
18: use Salient\Core\Concern\ReadsProtectedProperties;
19: use Salient\Core\Facade\Console;
20: use Salient\Utility\Arr;
21: use Salient\Utility\Env;
22: use Salient\Utility\File;
23: use Salient\Utility\Format;
24: use Salient\Utility\Get;
25: use Salient\Utility\Inflect;
26: use Salient\Utility\Reflect;
27: use Salient\Utility\Regex;
28: use Salient\Utility\Str;
29: use Salient\Utility\Test;
30: use DateTimeImmutable;
31: use LogicException;
32:
33: /**
34: * A getopt-style option for a CLI command
35: *
36: * @property-read string|null $Name The name of the option
37: * @property-read string|null $Long The long form of the option, e.g. "verbose"
38: * @property-read string|null $Short The short form of the option, e.g. "v"
39: * @property-read string $Key The option's internal identifier
40: * @property-read string|null $ValueName The name of the option's value as it appears in usage information
41: * @property-read string $DisplayName The option's name as it appears in error messages
42: * @property-read string|null $Description A description of the option
43: * @property-read CliOptionType::* $OptionType The option's type
44: * @property-read bool $IsFlag True if the option is a flag
45: * @property-read bool $IsOneOf True if the option accepts values from a list
46: * @property-read bool $IsPositional True if the option is positional
47: * @property-read bool $ValueRequired True if the option has a mandatory value
48: * @property-read bool $ValueOptional True if the option has an optional value
49: * @property-read CliOptionValueType::* $ValueType The data type of the option's value
50: * @property-read array<string|int|bool|float>|null $AllowedValues The option's possible values, indexed by lowercase value if not case-sensitive
51: * @property-read bool $CaseSensitive True if the option's values are case-sensitive
52: * @property-read CliOptionValueUnknownPolicy::*|null $UnknownValuePolicy The action taken if an unknown value is given
53: * @property-read bool $Required True if the option is mandatory
54: * @property-read bool $WasRequired True if the option was mandatory before applying values from the environment
55: * @property-read bool $MultipleAllowed True if the option may be given more than once
56: * @property-read bool $Unique True if the same value may not be given more than once
57: * @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
58: * @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
59: * @property-read array<string|int|bool|float>|string|int|bool|float|null $OriginalDefaultValue The option's default value before applying values from the environment
60: * @property-read bool $Nullable True if the option's value should be null if it is not given on the command line
61: * @property-read string|null $EnvVariable The name of a value in the environment that replaces the option's default value
62: * @property-read non-empty-string|null $Delimiter The separator between values passed to the option as a single argument
63: * @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
64: * @property-read int-mask-of<CliOptionVisibility::*> $Visibility The option's visibility to users
65: * @property-read bool $IsBound True if the option is bound to a variable
66: *
67: * @implements Buildable<CliOptionBuilder>
68: */
69: final class CliOption implements Buildable, JsonSchemaInterface, Immutable, Readable
70: {
71: /** @use HasBuilder<CliOptionBuilder> */
72: use HasBuilder;
73: use ReadsProtectedProperties;
74:
75: private const LONG_REGEX = '/^[a-z0-9_][-a-z0-9_]++$/iD';
76: private const SHORT_REGEX = '/^[a-z0-9_]$/iD';
77:
78: private const ONE_OF_INDEX = [
79: CliOptionType::ONE_OF => true,
80: CliOptionType::ONE_OF_OPTIONAL => true,
81: CliOptionType::ONE_OF_POSITIONAL => true,
82: ];
83:
84: private const POSITIONAL_INDEX = [
85: CliOptionType::VALUE_POSITIONAL => true,
86: CliOptionType::ONE_OF_POSITIONAL => true,
87: ];
88:
89: private const VALUE_REQUIRED_INDEX = [
90: CliOptionType::VALUE => true,
91: CliOptionType::VALUE_POSITIONAL => true,
92: CliOptionType::ONE_OF => true,
93: CliOptionType::ONE_OF_POSITIONAL => true,
94: ];
95:
96: private const VALUE_OPTIONAL_INDEX = [
97: CliOptionType::VALUE_OPTIONAL => true,
98: CliOptionType::ONE_OF_OPTIONAL => true,
99: ];
100:
101: /**
102: * @var array<CliOptionValueType::*,string>
103: */
104: private const JSON_SCHEMA_TYPE_MAP = [
105: CliOptionValueType::BOOLEAN => 'boolean',
106: CliOptionValueType::INTEGER => 'integer',
107: CliOptionValueType::STRING => 'string',
108: CliOptionValueType::FLOAT => 'number',
109: CliOptionValueType::DATE => 'string',
110: CliOptionValueType::PATH => 'string',
111: CliOptionValueType::FILE => 'string',
112: CliOptionValueType::DIRECTORY => 'string',
113: CliOptionValueType::PATH_OR_DASH => 'string',
114: CliOptionValueType::FILE_OR_DASH => 'string',
115: CliOptionValueType::DIRECTORY_OR_DASH => 'string',
116: CliOptionValueType::NEW_PATH => 'string',
117: CliOptionValueType::NEW_FILE => 'string',
118: CliOptionValueType::NEW_DIRECTORY => 'string',
119: CliOptionValueType::NEW_PATH_OR_DASH => 'string',
120: CliOptionValueType::NEW_FILE_OR_DASH => 'string',
121: CliOptionValueType::NEW_DIRECTORY_OR_DASH => 'string',
122: ];
123:
124: /**
125: * The name of the option
126: */
127: protected ?string $Name;
128:
129: /**
130: * The long form of the option, e.g. "verbose"
131: */
132: protected ?string $Long;
133:
134: /**
135: * The short form of the option, e.g. "v"
136: */
137: protected ?string $Short;
138:
139: /**
140: * The option's internal identifier
141: */
142: protected string $Key;
143:
144: /**
145: * The name of the option's value as it appears in usage information
146: */
147: protected ?string $ValueName;
148:
149: /**
150: * The option's name as it appears in error messages
151: */
152: protected string $DisplayName;
153:
154: /**
155: * A description of the option
156: */
157: protected ?string $Description;
158:
159: /**
160: * The option's type
161: *
162: * @var CliOptionType::*
163: */
164: protected int $OptionType;
165:
166: /**
167: * True if the option is a flag
168: */
169: protected bool $IsFlag;
170:
171: /**
172: * True if the option accepts values from a list
173: */
174: protected bool $IsOneOf;
175:
176: /**
177: * True if the option is positional
178: */
179: protected bool $IsPositional;
180:
181: /**
182: * True if the option has a mandatory value
183: */
184: protected bool $ValueRequired;
185:
186: /**
187: * True if the option has an optional value
188: */
189: protected bool $ValueOptional;
190:
191: /**
192: * The data type of the option's value
193: *
194: * @var CliOptionValueType::*
195: */
196: protected int $ValueType;
197:
198: /**
199: * The option's possible values, indexed by lowercase value if not
200: * case-sensitive
201: *
202: * @var array<string|int|bool|float>|null
203: */
204: protected ?array $AllowedValues;
205:
206: /**
207: * True if the option's values are case-sensitive
208: *
209: * If strings in {@see CliOption::$AllowedValues} are unique after
210: * conversion to lowercase, {@see CliOption::$CaseSensitive} is `false`.
211: */
212: protected bool $CaseSensitive = true;
213:
214: /**
215: * The action taken if an unknown value is given
216: *
217: * @var CliOptionValueUnknownPolicy::*|null
218: */
219: protected ?int $UnknownValuePolicy;
220:
221: /**
222: * True if the option is mandatory
223: */
224: protected bool $Required;
225:
226: /**
227: * True if the option was mandatory before applying values from the
228: * environment
229: */
230: protected bool $WasRequired;
231:
232: /**
233: * True if the option may be given more than once
234: */
235: protected bool $MultipleAllowed;
236:
237: /**
238: * True if the same value may not be given more than once
239: */
240: protected bool $Unique;
241:
242: /**
243: * True if "ALL" should be added to the list of possible values when the
244: * option can be given more than once
245: */
246: protected bool $AddAll;
247:
248: /**
249: * Assigned to the option if no value is given on the command line
250: *
251: * @var array<string|int|bool|float>|string|int|bool|float|null
252: */
253: protected $DefaultValue;
254:
255: /**
256: * The option's default value before applying values from the environment
257: *
258: * @var array<string|int|bool|float>|string|int|bool|float|null
259: */
260: protected $OriginalDefaultValue;
261:
262: /**
263: * True if the option's value should be null if it is not given on the
264: * command line
265: */
266: protected bool $Nullable;
267:
268: /**
269: * The name of a value in the environment that replaces the option's default
270: * value
271: */
272: protected ?string $EnvVariable;
273:
274: /**
275: * The separator between values passed to the option as a single argument
276: *
277: * @var non-empty-string|null
278: */
279: protected ?string $Delimiter;
280:
281: /**
282: * Applied to the option's value as it is assigned
283: *
284: * Providing a {@see CliOption::$ValueCallback} disables conversion of the
285: * option's value to {@see CliOption::$ValueType}. The callback should
286: * return a value of the expected type.
287: *
288: * @var (callable(array<string|int|bool|float>|string|int|bool|float): mixed)|null
289: */
290: protected $ValueCallback;
291:
292: /**
293: * The option's visibility to users
294: *
295: * @var int-mask-of<CliOptionVisibility::*>
296: */
297: protected int $Visibility;
298:
299: /**
300: * True if the option is bound to a variable
301: */
302: protected bool $IsBound;
303:
304: /**
305: * @var mixed
306: * @phpstan-ignore-next-line
307: */
308: private $BindTo;
309:
310: private bool $IsLoaded = false;
311:
312: /**
313: * @internal
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): mixed)|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 mixed $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 the option's JSON Schema
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: return explode($this->Delimiter, $value);
830: }
831: return [$value];
832: }
833:
834: /**
835: * Normalise a value, assign it to the option's bound variable, and return
836: * it to the caller
837: *
838: * @param array<string|int|bool|float>|string|int|bool|float|null $value
839: * @param bool $normalise `false` if `$value` has already been normalised.
840: * @param bool $expand If `true` and the option has an optional value,
841: * expand `null` or `true` to the default value of the option. Ignored if
842: * `$normalise` is `false`.
843: * @return mixed
844: */
845: public function applyValue($value, bool $normalise = true, bool $expand = false)
846: {
847: if ($normalise) {
848: $value = $this->normaliseValue($value, $expand);
849: }
850: if ($this->IsBound) {
851: $this->BindTo = $value;
852: }
853: return $value;
854: }
855:
856: /**
857: * If the option has a callback, apply it to a value, otherwise convert the
858: * value to the option's value type
859: *
860: * @param array<string|int|bool|float>|string|int|bool|float|null $value
861: * @param bool $expand If `true` and the option has an optional value,
862: * expand `null` or `true` to the default value of the option.
863: * @return mixed
864: */
865: public function normaliseValue($value, bool $expand = false)
866: {
867: if ($expand
868: && $this->ValueOptional
869: && ($value === null
870: || ($this->ValueType !== CliOptionValueType::BOOLEAN
871: && $value === true))) {
872: $value = $this->DefaultValue;
873: }
874:
875: if ($value === null) {
876: return $this->Nullable
877: ? null
878: : ($this->OptionType === CliOptionType::FLAG
879: ? ($this->MultipleAllowed ? 0 : false)
880: : ($this->MultipleAllowed ? [] : null));
881: }
882:
883: if ($this->AllowedValues) {
884: $value = $this->filterValue($value);
885: if ($this->AddAll && in_array('ALL', Arr::wrap($value), true)) {
886: $value = array_values(array_diff($this->AllowedValues, ['ALL']));
887: }
888: } else {
889: if (is_array($value)) {
890: if ($this->IsFlag && (
891: !$this->MultipleAllowed || Arr::unique($value) !== [true]
892: )) {
893: $this->throwValueTypeException($value);
894: } elseif (!$this->MultipleAllowed) {
895: throw new CliInvalidArgumentsException(sprintf(
896: '%s does not accept multiple values',
897: $this->DisplayName,
898: ));
899: }
900: }
901: if ($this->IsFlag && $this->MultipleAllowed && !is_int($value)) {
902: if (is_array($value)) {
903: $value = count($value);
904: } elseif (Test::isBoolean($value)) {
905: $value = Get::boolean($value) ? 1 : 0;
906: }
907: }
908: }
909:
910: if ($this->ValueCallback !== null) {
911: $this->maybeCheckUnique($value);
912: // @phpstan-ignore callable.nonCallable
913: return ($this->ValueCallback)($value);
914: }
915:
916: if (!is_array($value)) {
917: return $this->normaliseValueType($value);
918: }
919:
920: $values = [];
921: foreach ($value as $value) {
922: $values[] = $this->normaliseValueType($value);
923: }
924: $this->maybeCheckUnique($values);
925: return $values;
926: }
927:
928: /**
929: * If the option has allowed values, check a given value is valid and apply
930: * the option's unknown value policy if not
931: *
932: * @param array<string|int|bool|float>|string|int|bool|float|null $value
933: * @param CliOptionValueUnknownPolicy::*|null $policy Overrides the option's
934: * unknown value policy if given.
935: * @return ($value is null ? null : array<string|int|bool|float>|string|int|bool|float)
936: */
937: private function filterValue($value, ?string $source = null, ?int $policy = null)
938: {
939: $policy ??= $this->UnknownValuePolicy;
940:
941: if (
942: $value === null
943: || $value === ''
944: || $value === []
945: || !$this->AllowedValues
946: || ($this->CaseSensitive && $policy === CliOptionValueUnknownPolicy::ACCEPT)
947: ) {
948: return $value;
949: }
950:
951: $value = $this->maybeSplitValue($value);
952:
953: if (!$this->CaseSensitive) {
954: $normalised = [];
955: foreach ($value as $value) {
956: $lower = Str::lower((string) $value);
957: $normalised[] = $this->AllowedValues[$lower] ?? $value;
958: }
959: $value = $normalised;
960: }
961:
962: if ($policy !== CliOptionValueUnknownPolicy::ACCEPT) {
963: $invalid = array_diff($value, $this->AllowedValues);
964: if ($invalid) {
965: // "invalid --field values 'title','name' (expected one of: first,last)"
966: $message = Inflect::format(
967: $invalid,
968: 'invalid %s {{#:value}} %s%s%s',
969: $this->DisplayName,
970: "'" . implode("','", $invalid) . "'",
971: $source !== null ? " in $source" : '',
972: $this->formatAllowedValues(' (expected one? of: {})'),
973: );
974:
975: if ($policy !== CliOptionValueUnknownPolicy::DISCARD) {
976: throw new CliUnknownValueException($message);
977: }
978:
979: Console::message('__Warning:__', $message, Level::WARNING, MessageType::UNFORMATTED);
980: $value = array_intersect($value, $this->AllowedValues);
981: }
982: }
983:
984: return $this->MultipleAllowed ? $value : Arr::first($value);
985: }
986:
987: /**
988: * Normalise a value for inclusion in a help message
989: *
990: * @param string|int|bool|float|null $value
991: */
992: public function normaliseValueForHelp($value): string
993: {
994: switch ($this->ValueType) {
995: case CliOptionValueType::BOOLEAN:
996: if (!$this->IsFlag && $value !== null) {
997: $value = Get::boolean($value);
998: return Format::yn($value);
999: }
1000: break;
1001: }
1002:
1003: return (string) $value;
1004: }
1005:
1006: /**
1007: * @param array<string|int|bool|float>|string|int|bool|float $value
1008: * @return ($value is array ? array<string|int|bool|float> : string|int|bool|float)
1009: */
1010: private function normaliseForSchema($value)
1011: {
1012: if (
1013: $this->ValueType === CliOptionValueType::DATE
1014: || $this->ValueCallback
1015: ) {
1016: return $value;
1017: }
1018:
1019: if (!is_array($value)) {
1020: /** @var string|int|bool|float */
1021: return $this->normaliseValueType($value, false);
1022: }
1023:
1024: foreach ($value as $value) {
1025: /** @var string|int|bool|float */
1026: $value = $this->normaliseValueType($value, false);
1027: $values[] = $value;
1028: }
1029: return $values ?? [];
1030: }
1031:
1032: /**
1033: * @param string|int|bool|float $value
1034: * @return DateTimeImmutable|string|int|bool|float
1035: */
1036: private function normaliseValueType($value, bool $checkPathExists = true)
1037: {
1038: if (!$this->checkValueType($value)) {
1039: $this->throwValueTypeException($value);
1040: }
1041:
1042: switch ($this->ValueType) {
1043: case CliOptionValueType::BOOLEAN:
1044: return Get::boolean($value);
1045:
1046: case CliOptionValueType::INTEGER:
1047: return (int) $value;
1048:
1049: case CliOptionValueType::STRING:
1050: return (string) $value;
1051:
1052: case CliOptionValueType::FLOAT:
1053: return (float) $value;
1054:
1055: case CliOptionValueType::DATE:
1056: return new DateTimeImmutable((string) $value);
1057:
1058: case CliOptionValueType::PATH:
1059: return $this->checkPath((string) $value, $checkPathExists, 'path', 'file_exists');
1060:
1061: case CliOptionValueType::FILE:
1062: return $this->checkPath((string) $value, $checkPathExists, 'file', 'is_file');
1063:
1064: case CliOptionValueType::DIRECTORY:
1065: return $this->checkPath((string) $value, $checkPathExists, 'directory', 'is_dir');
1066:
1067: case CliOptionValueType::PATH_OR_DASH:
1068: return $this->checkPath((string) $value, $checkPathExists, 'path', 'file_exists', true);
1069:
1070: case CliOptionValueType::FILE_OR_DASH:
1071: return $this->checkPath((string) $value, $checkPathExists, 'file', 'is_file', true);
1072:
1073: case CliOptionValueType::DIRECTORY_OR_DASH:
1074: return $this->checkPath((string) $value, $checkPathExists, 'directory', 'is_dir', true);
1075:
1076: case CliOptionValueType::NEW_PATH:
1077: return $this->checkNewPath((string) $value, $checkPathExists, 'path', 'file_exists');
1078:
1079: case CliOptionValueType::NEW_FILE:
1080: return $this->checkNewPath((string) $value, $checkPathExists, 'file', 'is_file');
1081:
1082: case CliOptionValueType::NEW_DIRECTORY:
1083: return $this->checkNewPath((string) $value, $checkPathExists, 'directory', 'is_dir');
1084:
1085: case CliOptionValueType::NEW_PATH_OR_DASH:
1086: return $this->checkNewPath((string) $value, $checkPathExists, 'path', 'file_exists', true);
1087:
1088: case CliOptionValueType::NEW_FILE_OR_DASH:
1089: return $this->checkNewPath((string) $value, $checkPathExists, 'file', 'is_file', true);
1090:
1091: case CliOptionValueType::NEW_DIRECTORY_OR_DASH:
1092: return $this->checkNewPath((string) $value, $checkPathExists, 'directory', 'is_dir', true);
1093: }
1094: }
1095:
1096: /**
1097: * @param callable(string): bool $callback
1098: */
1099: private function checkPath(string $value, bool $checkExists, string $fileType, callable $callback, bool $dashOk = false): string
1100: {
1101: if (
1102: !$checkExists
1103: || ($dashOk && $value === '-')
1104: || $callback((string) $value)
1105: ) {
1106: return $value;
1107: }
1108:
1109: throw new CliInvalidArgumentsException(sprintf(
1110: '%s not found: %s',
1111: $fileType,
1112: $value,
1113: ));
1114: }
1115:
1116: /**
1117: * @param callable(string): bool $callback
1118: */
1119: private function checkNewPath(string $value, bool $checkExists, string $fileType, callable $callback, bool $dashOk = false): string
1120: {
1121: if (!$checkExists || ($dashOk && $value === '-')) {
1122: return $value;
1123: }
1124:
1125: if ($callback((string) $value)) {
1126: if (is_writable((string) $value)) {
1127: return $value;
1128: }
1129: } elseif (File::isCreatable((string) $value)) {
1130: return $value;
1131: }
1132:
1133: throw new CliInvalidArgumentsException(sprintf(
1134: '%s not writable: %s',
1135: $fileType,
1136: $value,
1137: ));
1138: }
1139:
1140: /**
1141: * @param array<string|int|bool|float> $values
1142: */
1143: private function checkValueTypes(array $values): bool
1144: {
1145: foreach ($values as $value) {
1146: if (!$this->checkValueType($value)) {
1147: return false;
1148: }
1149: }
1150: return true;
1151: }
1152:
1153: /**
1154: * @param string|int|bool|float $value
1155: */
1156: private function checkValueType($value): bool
1157: {
1158: switch ($this->ValueType) {
1159: case CliOptionValueType::BOOLEAN:
1160: return Test::isBoolean($value);
1161:
1162: case CliOptionValueType::INTEGER:
1163: return Test::isInteger($value);
1164:
1165: case CliOptionValueType::STRING:
1166: return is_scalar($value);
1167:
1168: case CliOptionValueType::FLOAT:
1169: return Test::isFloat($value) || Test::isInteger($value);
1170:
1171: case CliOptionValueType::DATE:
1172: return Test::isDateString($value);
1173:
1174: case CliOptionValueType::PATH:
1175: case CliOptionValueType::FILE:
1176: case CliOptionValueType::DIRECTORY:
1177: case CliOptionValueType::PATH_OR_DASH:
1178: case CliOptionValueType::FILE_OR_DASH:
1179: case CliOptionValueType::DIRECTORY_OR_DASH:
1180: case CliOptionValueType::NEW_PATH:
1181: case CliOptionValueType::NEW_FILE:
1182: case CliOptionValueType::NEW_DIRECTORY:
1183: case CliOptionValueType::NEW_PATH_OR_DASH:
1184: case CliOptionValueType::NEW_FILE_OR_DASH:
1185: case CliOptionValueType::NEW_DIRECTORY_OR_DASH:
1186: return is_string($value);
1187:
1188: default:
1189: return false;
1190: }
1191: }
1192:
1193: /**
1194: * @param array<string|int|bool|float>|string|int|bool|float $value
1195: * @return never
1196: */
1197: private function throwValueTypeException($value): void
1198: {
1199: throw new CliInvalidArgumentsException(sprintf(
1200: 'invalid %s value (%s expected): %s',
1201: $this->DisplayName,
1202: $this->getValueTypeName(),
1203: Get::code($value),
1204: ));
1205: }
1206:
1207: private function getValueTypeName(): string
1208: {
1209: return Reflect::getConstantName(CliOptionValueType::class, $this->ValueType);
1210: }
1211:
1212: /**
1213: * @param array<DateTimeImmutable|string|int|bool|float>|DateTimeImmutable|string|int|bool|float|null $value
1214: *
1215: * @phpstan-pure
1216: */
1217: private function maybeCheckUnique($value): void
1218: {
1219: if (
1220: $value === null
1221: || $value === ''
1222: || $value === []
1223: || !$this->Unique
1224: ) {
1225: return;
1226: }
1227:
1228: $value = $this->maybeSplitValue($value);
1229:
1230: $strict = $this->ValueType !== CliOptionValueType::DATE;
1231: if (count(Arr::unique($value, false, $strict)) !== count($value)) {
1232: throw new CliInvalidArgumentsException(sprintf(
1233: '%s does not accept the same value multiple times',
1234: $this->DisplayName,
1235: ));
1236: }
1237: }
1238: }
1239: