@@ -217,7 +217,7 @@ public function addArgument(Argument $argument): static
217217 {
218218 $ name = $ argument ->name ;
219219
220- if (array_key_exists ( $ name , $ this ->argumentsDefinition )) {
220+ if ($ this ->hasArgument ( $ name )) {
221221 throw new InvalidArgumentDefinitionException (lang ('Commands.duplicateArgument ' , [$ name ]));
222222 }
223223
@@ -253,15 +253,19 @@ public function addOption(Option $option): static
253253 {
254254 $ name = $ option ->name ;
255255
256- if (array_key_exists ( $ name , $ this ->optionsDefinition )) {
256+ if ($ this ->hasOption ( $ name )) {
257257 throw new InvalidOptionDefinitionException (lang ('Commands.duplicateOption ' , [$ name ]));
258258 }
259259
260- if ($ option ->shortcut !== null && array_key_exists ($ option ->shortcut , $ this ->shortcuts )) {
260+ if ($ this ->hasNegation ($ name )) {
261+ throw new InvalidOptionDefinitionException (lang ('Commands.optionClashesWithExistingNegation ' , [$ name , $ this ->negations [$ name ]]));
262+ }
263+
264+ if ($ option ->shortcut !== null && $ this ->hasShortcut ($ option ->shortcut )) {
261265 throw new InvalidOptionDefinitionException (lang ('Commands.duplicateShortcut ' , [$ option ->shortcut , $ name , $ this ->shortcuts [$ option ->shortcut ]]));
262266 }
263267
264- if ($ option ->negation !== null && array_key_exists ( $ option -> negation , $ this -> optionsDefinition )) {
268+ if ($ option ->negation !== null && $ this -> hasOption ( $ option -> negation )) {
265269 throw new InvalidOptionDefinitionException (lang ('Commands.negatableOptionNegationExists ' , [$ name ]));
266270 }
267271
@@ -347,9 +351,11 @@ public function hasNegation(string $name): bool
347351 * 1. {@see initialize()} and {@see interact()} are handed the raw parsed
348352 * input by reference, in that order. Both can mutate the tokens before
349353 * the framework interprets them against the declared definitions.
350- * 2. The resulting raw input is snapshotted into `$unboundArguments` and
351- * `$unboundOptions` so the unbound accessors can report what the user
352- * actually typed (as opposed to what defaults resolved to).
354+ * 2. The post-hook input is snapshotted into `$unboundArguments` and
355+ * `$unboundOptions` so the unbound accessors can report the tokens
356+ * carried into binding (as opposed to what defaults resolved to).
357+ * Any mutations performed in `initialize()` or `interact()` are
358+ * therefore reflected in the snapshot.
353359 * 3. {@see bind()} maps the raw tokens onto the declared arguments and
354360 * options, applying defaults and coercing flag/negation values.
355361 * 4. {@see validate()} rejects the bound result if it violates any of the
@@ -485,30 +491,31 @@ protected function getUnboundOptions(): array
485491
486492 /**
487493 * Reads the raw (unbound) value of the option with the given declared name,
488- * resolving through its shortcut and negation. Returns `$default ` when the
494+ * resolving through its shortcut and negation. Returns `null ` when the
489495 * option was not provided under any of those aliases.
490496 *
491497 * Inside {@see interact()}, pass the `$options` parameter explicitly because
492498 * the instance state is not yet populated at that point. Elsewhere, omit
493499 * `$options` to read from the instance state.
494500 *
495501 * @param array<string, list<string|null>|string|null>|null $options
496- * @param list<string|null>|string|null $default
497502 *
498503 * @return list<string|null>|string|null
499504 *
500505 * @throws LogicException
501506 */
502- protected function getUnboundOption (string $ name , ?array $ options = null , array | string | null $ default = null ): array |string |null
507+ protected function getUnboundOption (string $ name , ?array $ options = null ): array |string |null
503508 {
504- $ definition = $ this ->getOptionDefinitionFor ($ name );
509+ $ this ->assertOptionIsDefined ($ name );
505510
506511 $ options ??= $ this ->unboundOptions ;
507512
508513 if (array_key_exists ($ name , $ options )) {
509514 return $ options [$ name ];
510515 }
511516
517+ $ definition = $ this ->optionsDefinition [$ name ];
518+
512519 if ($ definition ->shortcut !== null && array_key_exists ($ definition ->shortcut , $ options )) {
513520 return $ options [$ definition ->shortcut ];
514521 }
@@ -517,7 +524,7 @@ protected function getUnboundOption(string $name, ?array $options = null, array|
517524 return $ options [$ definition ->negation ];
518525 }
519526
520- return $ default ;
527+ return null ;
521528 }
522529
523530 /**
@@ -533,14 +540,16 @@ protected function getUnboundOption(string $name, ?array $options = null, array|
533540 */
534541 protected function hasUnboundOption (string $ name , ?array $ options = null ): bool
535542 {
536- $ definition = $ this ->getOptionDefinitionFor ($ name );
543+ $ this ->assertOptionIsDefined ($ name );
537544
538545 $ options ??= $ this ->unboundOptions ;
539546
540547 if (array_key_exists ($ name , $ options )) {
541548 return true ;
542549 }
543550
551+ $ definition = $ this ->optionsDefinition [$ name ];
552+
544553 if ($ definition ->shortcut !== null && array_key_exists ($ definition ->shortcut , $ options )) {
545554 return true ;
546555 }
@@ -692,33 +701,38 @@ private function bind(array $arguments, array $options): array
692701
693702 // 4. If there are still options left that are not defined, we will mark them as extraneous.
694703 foreach ($ options as $ name => $ value ) {
695- if (array_key_exists ( $ name , $ this ->shortcuts )) {
704+ if ($ this ->hasShortcut ( $ name )) {
696705 // This scenario can happen when the command has an array option with a shortcut,
697706 // and the shortcut is used alongside the long name, causing it to be not bound
698- // in the previous loop.
707+ // in the previous loop. The leftover shortcut value can itself be an array when
708+ // the shortcut was passed multiple times, so merge arrays and append scalars.
699709 $ option = $ this ->shortcuts [$ name ];
710+ $ values = is_array ($ value ) ? $ value : [$ value ];
700711
701712 if (array_key_exists ($ option , $ boundOptions ) && is_array ($ boundOptions [$ option ])) {
702- $ boundOptions [$ option ][] = $ value ;
713+ $ boundOptions [$ option ] = [... $ boundOptions [ $ option ], ... $ values ] ;
703714 } else {
704- $ boundOptions [$ option ] = [$ boundOptions [$ option ], $ value ];
715+ $ boundOptions [$ option ] = [$ boundOptions [$ option ], ... $ values ];
705716 }
706717
707718 continue ;
708719 }
709720
710- if (array_key_exists ( $ name , $ this ->negations )) {
721+ if ($ this ->hasNegation ( $ name )) {
711722 // This scenario can happen when the command has a negatable option,
712723 // and both the option and its negation are used, causing the negation
713- // to be not bound in the previous loop.
724+ // to be not bound in the previous loop. The leftover negation value can
725+ // be scalar (including a string when the negation was passed with a value)
726+ // or an array — normalise to an array before mapping null → false.
714727 $ option = $ this ->negations [$ name ];
715- $ value = array_map (static fn (mixed $ v ): mixed => $ v ?? false , $ value ?? [null ]);
728+ $ values = is_array ($ value ) ? $ value : [$ value ];
729+ $ values = array_map (static fn (mixed $ v ): mixed => $ v ?? false , $ values );
716730
717731 if (! is_array ($ boundOptions [$ option ])) {
718732 $ boundOptions [$ option ] = [$ boundOptions [$ option ]];
719733 }
720734
721- $ boundOptions [$ option ] = [...$ boundOptions [$ option ], ...$ value ];
735+ $ boundOptions [$ option ] = [...$ boundOptions [$ option ], ...$ values ];
722736
723737 continue ;
724738 }
@@ -816,8 +830,14 @@ private function validateOption(string $name, Option $definition, array|bool|str
816830 throw new OptionValueMismatchException (lang ('Commands.nonArrayOptionWithArrayValue ' , [$ name ]));
817831 }
818832
819- if ($ definition ->requiresValue && ! is_string ($ value ) && ! is_array ($ value )) {
820- throw new OptionValueMismatchException (lang ('Commands.optionRequiresValue ' , [$ name ]));
833+ if ($ definition ->requiresValue ) {
834+ $ elements = is_array ($ value ) ? $ value : [$ value ];
835+
836+ foreach ($ elements as $ element ) {
837+ if (! is_string ($ element )) {
838+ throw new OptionValueMismatchException (lang ('Commands.optionRequiresValue ' , [$ name ]));
839+ }
840+ }
821841 }
822842
823843 if (! $ definition ->negatable || is_bool ($ value )) {
@@ -843,7 +863,12 @@ private function validateNegatableOption(string $name, Option $definition, array
843863 throw new OptionValueMismatchException (lang ('Commands.negatedOptionNoValue ' , [$ definition ->negation ]));
844864 }
845865
846- if (array_values (array_intersect (array_unique ($ value ), [true , false ])) === [true , false ]) {
866+ // Both forms appearing together is the primary user mistake; flag it
867+ // regardless of whether either form carried a value.
868+ if (
869+ array_key_exists ($ name , $ this ->unboundOptions )
870+ && array_key_exists ($ definition ->negation , $ this ->unboundOptions )
871+ ) {
847872 throw new LogicException (lang ('Commands.negatableOptionWithNegation ' , [$ name , $ definition ->negation ]));
848873 }
849874
@@ -857,13 +882,11 @@ private function validateNegatableOption(string $name, Option $definition, array
857882 /**
858883 * @throws LogicException
859884 */
860- private function getOptionDefinitionFor (string $ name ): Option
885+ private function assertOptionIsDefined (string $ name ): void
861886 {
862- if (! array_key_exists ( $ name , $ this ->optionsDefinition )) {
887+ if (! $ this ->hasOption ( $ name )) {
863888 throw new LogicException (sprintf ('Option "%s" is not defined on this command. ' , $ name ));
864889 }
865-
866- return $ this ->optionsDefinition [$ name ];
867890 }
868891
869892 /**
0 commit comments