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: | |
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: | |
68: | |
69: | final class CliOption implements Buildable, JsonSchemaInterface, Immutable, Readable |
70: | { |
71: | |
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: | |
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: | |
126: | |
127: | protected ?string $Name; |
128: | |
129: | |
130: | |
131: | |
132: | protected ?string $Long; |
133: | |
134: | |
135: | |
136: | |
137: | protected ?string $Short; |
138: | |
139: | |
140: | |
141: | |
142: | protected string $Key; |
143: | |
144: | |
145: | |
146: | |
147: | protected ?string $ValueName; |
148: | |
149: | |
150: | |
151: | |
152: | protected string $DisplayName; |
153: | |
154: | |
155: | |
156: | |
157: | protected ?string $Description; |
158: | |
159: | |
160: | |
161: | |
162: | |
163: | |
164: | protected int $OptionType; |
165: | |
166: | |
167: | |
168: | |
169: | protected bool $IsFlag; |
170: | |
171: | |
172: | |
173: | |
174: | protected bool $IsOneOf; |
175: | |
176: | |
177: | |
178: | |
179: | protected bool $IsPositional; |
180: | |
181: | |
182: | |
183: | |
184: | protected bool $ValueRequired; |
185: | |
186: | |
187: | |
188: | |
189: | protected bool $ValueOptional; |
190: | |
191: | |
192: | |
193: | |
194: | |
195: | |
196: | protected int $ValueType; |
197: | |
198: | |
199: | |
200: | |
201: | |
202: | |
203: | |
204: | protected ?array $AllowedValues; |
205: | |
206: | |
207: | |
208: | |
209: | |
210: | |
211: | |
212: | protected bool $CaseSensitive = true; |
213: | |
214: | |
215: | |
216: | |
217: | |
218: | |
219: | protected ?int $UnknownValuePolicy; |
220: | |
221: | |
222: | |
223: | |
224: | protected bool $Required; |
225: | |
226: | |
227: | |
228: | |
229: | |
230: | protected bool $WasRequired; |
231: | |
232: | |
233: | |
234: | |
235: | protected bool $MultipleAllowed; |
236: | |
237: | |
238: | |
239: | |
240: | protected bool $Unique; |
241: | |
242: | |
243: | |
244: | |
245: | |
246: | protected bool $AddAll; |
247: | |
248: | |
249: | |
250: | |
251: | |
252: | |
253: | protected $DefaultValue; |
254: | |
255: | |
256: | |
257: | |
258: | |
259: | |
260: | protected $OriginalDefaultValue; |
261: | |
262: | |
263: | |
264: | |
265: | |
266: | protected bool $Nullable; |
267: | |
268: | |
269: | |
270: | |
271: | |
272: | protected ?string $EnvVariable; |
273: | |
274: | |
275: | |
276: | |
277: | |
278: | |
279: | protected ?string $Delimiter; |
280: | |
281: | |
282: | |
283: | |
284: | |
285: | |
286: | |
287: | |
288: | |
289: | |
290: | protected $ValueCallback; |
291: | |
292: | |
293: | |
294: | |
295: | |
296: | |
297: | protected int $Visibility; |
298: | |
299: | |
300: | |
301: | |
302: | protected bool $IsBound; |
303: | |
304: | |
305: | |
306: | |
307: | |
308: | private $BindTo; |
309: | |
310: | private bool $IsLoaded = false; |
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: | return explode($this->Delimiter, $value); |
830: | } |
831: | return [$value]; |
832: | } |
833: | |
834: | |
835: | |
836: | |
837: | |
838: | |
839: | |
840: | |
841: | |
842: | |
843: | |
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: | |
858: | |
859: | |
860: | |
861: | |
862: | |
863: | |
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: | |
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: | |
930: | |
931: | |
932: | |
933: | |
934: | |
935: | |
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: | |
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: | |
989: | |
990: | |
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: | |
1008: | |
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: | |
1021: | return $this->normaliseValueType($value, false); |
1022: | } |
1023: | |
1024: | foreach ($value as $value) { |
1025: | |
1026: | $value = $this->normaliseValueType($value, false); |
1027: | $values[] = $value; |
1028: | } |
1029: | return $values ?? []; |
1030: | } |
1031: | |
1032: | |
1033: | |
1034: | |
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: | |
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: | |
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: | |
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: | |
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: | |
1195: | |
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: | |
1214: | |
1215: | |
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: | |