Skip to content

Commit 270cba9

Browse files
committed
address reviews
1 parent e79add0 commit 270cba9

File tree

18 files changed

+545
-74
lines changed

18 files changed

+545
-74
lines changed

system/CLI/AbstractCommand.php

Lines changed: 41 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -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
@@ -692,33 +698,38 @@ private function bind(array $arguments, array $options): array
692698

693699
// 4. If there are still options left that are not defined, we will mark them as extraneous.
694700
foreach ($options as $name => $value) {
695-
if (array_key_exists($name, $this->shortcuts)) {
701+
if ($this->hasShortcut($name)) {
696702
// This scenario can happen when the command has an array option with a shortcut,
697703
// and the shortcut is used alongside the long name, causing it to be not bound
698-
// in the previous loop.
704+
// in the previous loop. The leftover shortcut value can itself be an array when
705+
// the shortcut was passed multiple times, so merge arrays and append scalars.
699706
$option = $this->shortcuts[$name];
707+
$values = is_array($value) ? $value : [$value];
700708

701709
if (array_key_exists($option, $boundOptions) && is_array($boundOptions[$option])) {
702-
$boundOptions[$option][] = $value;
710+
$boundOptions[$option] = [...$boundOptions[$option], ...$values];
703711
} else {
704-
$boundOptions[$option] = [$boundOptions[$option], $value];
712+
$boundOptions[$option] = [$boundOptions[$option], ...$values];
705713
}
706714

707715
continue;
708716
}
709717

710-
if (array_key_exists($name, $this->negations)) {
718+
if ($this->hasNegation($name)) {
711719
// This scenario can happen when the command has a negatable option,
712720
// and both the option and its negation are used, causing the negation
713-
// to be not bound in the previous loop.
721+
// to be not bound in the previous loop. The leftover negation value can
722+
// be scalar (including a string when the negation was passed with a value)
723+
// or an array — normalise to an array before mapping null → false.
714724
$option = $this->negations[$name];
715-
$value = array_map(static fn (mixed $v): mixed => $v ?? false, $value ?? [null]);
725+
$values = is_array($value) ? $value : [$value];
726+
$values = array_map(static fn (mixed $v): mixed => $v ?? false, $values);
716727

717728
if (! is_array($boundOptions[$option])) {
718729
$boundOptions[$option] = [$boundOptions[$option]];
719730
}
720731

721-
$boundOptions[$option] = [...$boundOptions[$option], ...$value];
732+
$boundOptions[$option] = [...$boundOptions[$option], ...$values];
722733

723734
continue;
724735
}
@@ -816,8 +827,14 @@ private function validateOption(string $name, Option $definition, array|bool|str
816827
throw new OptionValueMismatchException(lang('Commands.nonArrayOptionWithArrayValue', [$name]));
817828
}
818829

819-
if ($definition->requiresValue && ! is_string($value) && ! is_array($value)) {
820-
throw new OptionValueMismatchException(lang('Commands.optionRequiresValue', [$name]));
830+
if ($definition->requiresValue) {
831+
$elements = is_array($value) ? $value : [$value];
832+
833+
foreach ($elements as $element) {
834+
if (! is_string($element)) {
835+
throw new OptionValueMismatchException(lang('Commands.optionRequiresValue', [$name]));
836+
}
837+
}
821838
}
822839

823840
if (! $definition->negatable || is_bool($value)) {
@@ -843,7 +860,12 @@ private function validateNegatableOption(string $name, Option $definition, array
843860
throw new OptionValueMismatchException(lang('Commands.negatedOptionNoValue', [$definition->negation]));
844861
}
845862

846-
if (array_values(array_intersect(array_unique($value), [true, false])) === [true, false]) {
863+
// Both forms appearing together is the primary user mistake; flag it
864+
// regardless of whether either form carried a value.
865+
if (
866+
array_key_exists($name, $this->unboundOptions)
867+
&& array_key_exists($definition->negation, $this->unboundOptions)
868+
) {
847869
throw new LogicException(lang('Commands.negatableOptionWithNegation', [$name, $definition->negation]));
848870
}
849871

@@ -859,7 +881,7 @@ private function validateNegatableOption(string $name, Option $definition, array
859881
*/
860882
private function getOptionDefinitionFor(string $name): Option
861883
{
862-
if (! array_key_exists($name, $this->optionsDefinition)) {
884+
if (! $this->hasOption($name)) {
863885
throw new LogicException(sprintf('Option "%s" is not defined on this command.', $name));
864886
}
865887

system/CLI/Commands.php

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,26 @@ public function getModernCommands(): array
153153
return $this->modernCommands;
154154
}
155155

156+
/**
157+
* Checks if a legacy command with the given name has been discovered.
158+
*/
159+
public function hasLegacyCommand(string $name): bool
160+
{
161+
return array_key_exists($name, $this->commands);
162+
}
163+
164+
/**
165+
* Checks if a modern command with the given name has been discovered.
166+
*
167+
* A name present in both registries signals a collision; legacy wins
168+
* at runtime. Callers can combine this with {@see hasLegacyCommand()}
169+
* to detect that case.
170+
*/
171+
public function hasModernCommand(string $name): bool
172+
{
173+
return array_key_exists($name, $this->modernCommands);
174+
}
175+
156176
/**
157177
* @return ($legacy is true ? BaseCommand : AbstractCommand)
158178
*
@@ -217,11 +237,13 @@ public function discoverCommands()
217237

218238
foreach (array_keys(array_intersect_key($this->commands, $this->modernCommands)) as $name) {
219239
CLI::write(
220-
lang('Commands.duplicateCommandName', [
221-
$name,
222-
$this->commands[$name]['class'],
223-
$this->modernCommands[$name]['class'],
224-
]),
240+
CLI::wrap(
241+
lang('Commands.duplicateCommandName', [
242+
$name,
243+
$this->commands[$name]['class'],
244+
$this->modernCommands[$name]['class'],
245+
]),
246+
),
225247
'yellow',
226248
);
227249
}
@@ -248,7 +270,7 @@ public function verifyCommand(string $command, array $commands = [], bool $legac
248270

249271
$message = lang('CLI.commandNotFound', [$command]);
250272

251-
$alternatives = $this->getCommandAlternatives($command, legacy: $legacy);
273+
$alternatives = $this->getCommandAlternatives($command);
252274

253275
if ($alternatives !== []) {
254276
$message = sprintf(
@@ -265,24 +287,22 @@ public function verifyCommand(string $command, array $commands = [], bool $legac
265287
}
266288

267289
/**
268-
* Finds alternative of `$name` among collection of commands.
290+
* Finds alternative of `$name` across both legacy and modern commands.
269291
*
270292
* @param legacy_commands $collection (no longer used)
271293
*
272294
* @return list<string>
273295
*/
274-
protected function getCommandAlternatives(string $name, array $collection = [], bool $legacy = true): array
296+
protected function getCommandAlternatives(string $name, array $collection = []): array
275297
{
276298
if ($collection !== []) {
277299
@trigger_error(sprintf('Since v4.8.0, the $collection parameter of %s() is no longer used.', __METHOD__), E_USER_DEPRECATED);
278300
}
279301

280-
$commandCollection = $legacy ? $this->commands : $this->modernCommands;
281-
282302
/** @var array<string, int> */
283303
$alternatives = [];
284304

285-
foreach (array_keys($commandCollection) as $commandName) {
305+
foreach (array_keys($this->commands + $this->modernCommands) as $commandName) {
286306
$lev = levenshtein($name, $commandName);
287307

288308
if ($lev <= strlen($commandName) / 3 || str_contains($commandName, $name)) {

system/CLI/Console.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ public function run(array $tokens = [])
6969

7070
$this->command = array_shift($arguments) ?? self::DEFAULT_COMMAND;
7171

72-
if (array_key_exists($this->command, $commands->getCommands())) {
72+
if ($commands->hasLegacyCommand($this->command)) {
7373
return $commands->runLegacy($this->command, array_merge($arguments, $this->options));
7474
}
7575

system/Commands/ListCommands.php

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,11 @@ protected function execute(array $arguments, array $options): int
4343

4444
private function describeCommandsSimple(): int
4545
{
46-
$commands = [
47-
...array_keys($this->getCommandRunner()->getCommands()),
48-
...array_keys($this->getCommandRunner()->getModernCommands()),
49-
];
46+
// Legacy takes precedence on key collision so the listing reflects the
47+
// command that would actually be invoked.
48+
$commands = array_keys(
49+
$this->getCommandRunner()->getCommands() + $this->getCommandRunner()->getModernCommands(),
50+
);
5051
sort($commands);
5152

5253
foreach ($commands as $command) {
@@ -64,10 +65,11 @@ private function describeCommandsDetailed(): int
6465
$entries = [];
6566
$maxPad = 0;
6667

67-
foreach ([
68-
...$this->getCommandRunner()->getCommands(),
69-
...$this->getCommandRunner()->getModernCommands(),
70-
] as $command => $details) {
68+
// Legacy takes precedence on key collision so the listing reflects the
69+
// command that would actually be invoked.
70+
$all = $this->getCommandRunner()->getCommands() + $this->getCommandRunner()->getModernCommands();
71+
72+
foreach ($all as $command => $details) {
7173
$maxPad = max($maxPad, strlen($command) + 4);
7274

7375
$entries[] = [$details['group'], $command, $details['description']];
@@ -105,7 +107,7 @@ private function describeCommandsDetailed(): int
105107
CLI::write(sprintf(
106108
'%s%s',
107109
CLI::color($this->addPadding($command[0], 2, $maxPad), 'green'),
108-
CLI::wrap($command[1]),
110+
CLI::wrap($command[1], 0, $maxPad),
109111
));
110112
}
111113
}

system/Commands/Server/Serve.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,9 @@ class Serve extends AbstractCommand
3737
protected function configure(): void
3838
{
3939
$this
40-
->addOption(new Option(name: 'php', description: 'The PHP binary to use.', acceptsValue: true, default: PHP_BINARY))
41-
->addOption(new Option(name: 'host', description: 'The host to serve on.', acceptsValue: true, default: 'localhost'))
42-
->addOption(new Option(name: 'port', description: 'The port to serve on.', acceptsValue: true, default: '8080'));
40+
->addOption(new Option(name: 'php', description: 'The PHP binary to use.', requiresValue: true, default: PHP_BINARY))
41+
->addOption(new Option(name: 'host', description: 'The host to serve on.', requiresValue: true, default: 'localhost'))
42+
->addOption(new Option(name: 'port', description: 'The port to serve on.', requiresValue: true, default: '8080'));
4343
}
4444

4545
protected function execute(array $arguments, array $options): int

system/Language/en/Commands.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
'arrayOptionEmptyArrayDefault' => 'Array option "--{0}" cannot have an empty array as the default value.',
2121
'argumentAfterArrayArgument' => 'Argument "{0}" cannot be defined after array argument "{1}".',
2222
'duplicateArgument' => 'An argument with the name "{0}" is already defined.',
23-
'duplicateCommandName' => 'Warning: command "{0}" is defined as both legacy ({1}) and modern ({2}); the legacy command will execute. Please rename or remove one.',
23+
'duplicateCommandName' => 'Warning: The "{0}" command is defined as both legacy ({1}) and modern ({2}). The legacy command will be executed. Please rename or remove one.',
2424
'duplicateOption' => 'An option with the name "--{0}" is already defined.',
2525
'duplicateShortcut' => 'Shortcut "-{0}" cannot be used for option "--{1}"; it is already assigned to option "--{2}".',
2626
'emptyCommandName' => 'Command name cannot be empty.',
@@ -47,6 +47,7 @@
4747
'noArgumentsExpected' => 'No arguments expected for "{0}" command. Received: "{1}".',
4848
'nonArrayArgumentWithArrayDefault' => 'Argument "{0}" does not accept an array default value.',
4949
'nonArrayOptionWithArrayValue' => 'Option "--{0}" does not accept an array value.',
50+
'optionClashesWithExistingNegation' => 'Option "--{0}" clashes with the negation of negatable option "--{1}".',
5051
'optionNoValueAndNoDefault' => 'Option "--{0}" does not accept a value and cannot have a default value.',
5152
'optionNotAcceptingValue' => 'Option "--{0}" does not accept a value.',
5253
'optionalArgumentNoDefault' => 'Argument "{0}" is optional and must have a default value.',

tests/_support/_command/DuplicateLegacy.php renamed to tests/_support/Duplicates/DuplicateLegacy.php

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,19 @@
1111
* the LICENSE file that was distributed with this source code.
1212
*/
1313

14-
namespace App\Commands;
14+
namespace Tests\Support\Duplicates;
1515

1616
use CodeIgniter\CLI\BaseCommand;
1717

1818
/**
19+
* Lives outside any `Commands/` directory so discovery does not pick it up
20+
* automatically. Tests inject this via a mocked FileLocator.
21+
*
1922
* @internal
2023
*/
2124
final class DuplicateLegacy extends BaseCommand
2225
{
23-
protected $group = 'Fixtures';
26+
protected $group = 'Duplicates';
2427
protected $name = 'dup:test';
2528
protected $description = 'Legacy fixture that collides with a modern command of the same name.';
2629

tests/_support/_command/DuplicateModern.php renamed to tests/_support/Duplicates/DuplicateModern.php

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,18 @@
1111
* the LICENSE file that was distributed with this source code.
1212
*/
1313

14-
namespace App\Commands;
14+
namespace Tests\Support\Duplicates;
1515

1616
use CodeIgniter\CLI\AbstractCommand;
1717
use CodeIgniter\CLI\Attributes\Command;
1818

1919
/**
20+
* Lives outside any `Commands/` directory so discovery does not pick it up
21+
* automatically. Tests inject this via a mocked FileLocator.
22+
*
2023
* @internal
2124
*/
22-
#[Command(name: 'dup:test', description: 'Modern fixture that collides with a legacy command of the same name.', group: 'Fixtures')]
25+
#[Command(name: 'dup:test', description: 'Modern fixture that collides with a legacy command of the same name.', group: 'Duplicates')]
2326
final class DuplicateModern extends AbstractCommand
2427
{
2528
protected function execute(array $arguments, array $options): int

0 commit comments

Comments
 (0)