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: | |
33: | |
34: | |
35: | |
36: | |
37: | |
38: | |
39: | |
40: | |
41: | |
42: | |
43: | |
44: | |
45: | |
46: | |
47: | |
48: | |
49: | |
50: | |
51: | |
52: | |
53: | |
54: | |
55: | |
56: | |
57: | |
58: | |
59: | |
60: | |
61: | |
62: | |
63: | |
64: | |
65: | |
66: | |
67: | final class CliOption implements Buildable, HasJsonSchema, Immutable, Readable |
68: | { |
69: | |
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: | |
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: | |
124: | |
125: | protected ?string $Name; |
126: | |
127: | |
128: | |
129: | |
130: | protected ?string $Long; |
131: | |
132: | |
133: | |
134: | |
135: | protected ?string $Short; |
136: | |
137: | |
138: | |
139: | |
140: | protected string $Key; |
141: | |
142: | |
143: | |
144: | |
145: | protected ?string $ValueName; |
146: | |
147: | |
148: | |
149: | |
150: | protected string $DisplayName; |
151: | |
152: | |
153: | |
154: | |
155: | protected ?string $Description; |
156: | |
157: | |
158: | |
159: | |
160: | |
161: | |
162: | protected int $OptionType; |
163: | |
164: | |
165: | |
166: | |
167: | protected bool $IsFlag; |
168: | |
169: | |
170: | |
171: | |
172: | protected bool $IsOneOf; |
173: | |
174: | |
175: | |
176: | |
177: | protected bool $IsPositional; |
178: | |
179: | |
180: | |
181: | |
182: | protected bool $ValueRequired; |
183: | |
184: | |
185: | |
186: | |
187: | protected bool $ValueOptional; |
188: | |
189: | |
190: | |
191: | |
192: | |
193: | |
194: | protected int $ValueType; |
195: | |
196: | |
197: | |
198: | |
199: | |
200: | |
201: | |
202: | protected ?array $AllowedValues; |
203: | |
204: | |
205: | |
206: | |
207: | |
208: | |
209: | |
210: | protected bool $CaseSensitive = true; |
211: | |
212: | |
213: | |
214: | |
215: | |
216: | |
217: | protected ?int $UnknownValuePolicy; |
218: | |
219: | |
220: | |
221: | |
222: | protected bool $Required; |
223: | |
224: | |
225: | |
226: | |
227: | |
228: | protected bool $WasRequired; |
229: | |
230: | |
231: | |
232: | |
233: | protected bool $MultipleAllowed; |
234: | |
235: | |
236: | |
237: | |
238: | protected bool $Unique; |
239: | |
240: | |
241: | |
242: | |
243: | |
244: | protected bool $AddAll; |
245: | |
246: | |
247: | |
248: | |
249: | |
250: | |
251: | protected $DefaultValue; |
252: | |
253: | |
254: | |
255: | |
256: | |
257: | |
258: | protected $OriginalDefaultValue; |
259: | |
260: | |
261: | |
262: | |
263: | |
264: | protected bool $Nullable; |
265: | |
266: | |
267: | |
268: | |
269: | |
270: | protected ?string $EnvVariable; |
271: | |
272: | |
273: | |
274: | |
275: | |
276: | |
277: | protected ?string $Delimiter; |
278: | |
279: | |
280: | |
281: | |
282: | |
283: | |
284: | |
285: | |
286: | |
287: | |
288: | protected $ValueCallback; |
289: | |
290: | |
291: | |
292: | |
293: | |
294: | |
295: | protected int $Visibility; |
296: | |
297: | |
298: | |
299: | |
300: | protected bool $IsBound; |
301: | |
302: | |
303: | |
304: | |
305: | |
306: | private $BindTo; |
307: | |
308: | private bool $IsLoaded = false; |
309: | |
310: | |
311: | |
312: | |
313: | |
314: | |
315: | |
316: | |
317: | |
318: | |
319: | |
320: | |
321: | |
322: | |
323: | |
324: | |
325: | |
326: | |
327: | |
328: | |
329: | |
330: | |
331: | |
332: | |
333: | |
334: | |
335: | |
336: | |
337: | |
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: | |
445: | |
446: | |
447: | |
448: | public function load() |
449: | { |
450: | if ($this->IsLoaded) { |
451: | |
452: | return $this; |
453: | |
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: | |
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: | |
621: | |
622: | |
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: | |
696: | |
697: | |
698: | |
699: | public function getNames(): array |
700: | { |
701: | return Arr::unique(Arr::whereNotNull([$this->Long ?? $this->Name, $this->Short])); |
702: | } |
703: | |
704: | |
705: | |
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: | |
718: | |
719: | |
720: | |
721: | |
722: | |
723: | |
724: | |
725: | |
726: | |
727: | |
728: | |
729: | |
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: | |
757: | |
758: | |
759: | |
760: | |
761: | |
762: | |
763: | |
764: | |
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: | |
789: | |
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: | |
811: | |
812: | |
813: | |
814: | |
815: | |
816: | |
817: | |
818: | |
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: | |
830: | return explode($this->Delimiter, $value); |
831: | } |
832: | return [$value]; |
833: | } |
834: | |
835: | |
836: | |
837: | |
838: | |
839: | |
840: | |
841: | |
842: | |
843: | |
844: | |
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: | |
859: | |
860: | |
861: | |
862: | |
863: | |
864: | |
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: | |
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: | |
933: | |
934: | |
935: | |
936: | |
937: | |
938: | |
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: | |
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: | |
992: | |
993: | |
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: | |
1011: | |
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: | |
1024: | return $this->normaliseValueType($value, false); |
1025: | } |
1026: | |
1027: | foreach ($value as $value) { |
1028: | |
1029: | $value = $this->normaliseValueType($value, false); |
1030: | $values[] = $value; |
1031: | } |
1032: | return $values ?? []; |
1033: | } |
1034: | |
1035: | |
1036: | |
1037: | |
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: | |
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: | |
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: | |
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: | |
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: | |
1198: | |
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: | |
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: | |