diff --git a/system/CLI/AbstractCommand.php b/system/CLI/AbstractCommand.php index 26edc9218dc5..011058598d99 100644 --- a/system/CLI/AbstractCommand.php +++ b/system/CLI/AbstractCommand.php @@ -40,6 +40,11 @@ abstract class AbstractCommand private readonly string $description; private readonly string $group; + /** + * @var list + */ + private readonly array $aliases; + /** * @var list */ @@ -138,6 +143,7 @@ public function __construct(private readonly Commands $commands) $this->name = $attribute->name; $this->description = $attribute->description; $this->group = $attribute->group; + $this->aliases = $attribute->aliases; $this->configure(); $this->provideDefaultOptions(); @@ -165,6 +171,14 @@ public function getGroup(): string return $this->group; } + /** + * @return list + */ + public function getAliases(): array + { + return $this->aliases; + } + /** * @return list */ diff --git a/system/CLI/Attributes/Command.php b/system/CLI/Attributes/Command.php index b05a8c34e887..d22ff880c2ea 100644 --- a/system/CLI/Attributes/Command.php +++ b/system/CLI/Attributes/Command.php @@ -22,27 +22,57 @@ #[Attribute(Attribute::TARGET_CLASS)] final readonly class Command { + private const NAME_PATTERN = '/^[^\s\:]++(\:[^\s\:]++)*$/'; + /** * @var non-empty-string */ public string $name; /** + * @var list + */ + public array $aliases; + + /** + * @param list $aliases + * * @throws LogicException */ public function __construct( string $name, public string $description = '', public string $group = '', + array $aliases = [], ) { if ($name === '') { throw new LogicException(lang('Commands.emptyCommandName')); } - if (preg_match('/^[^\s\:]++(\:[^\s\:]++)*$/', $name) !== 1) { + if (preg_match(self::NAME_PATTERN, $name) !== 1) { throw new LogicException(lang('Commands.invalidCommandName', [$name])); } $this->name = $name; + + $seen = []; + + foreach ($aliases as $alias) { + if ($alias === '' || preg_match(self::NAME_PATTERN, $alias) !== 1) { + throw new LogicException(lang('Commands.invalidCommandAlias', [$alias])); + } + + if ($alias === $name) { + throw new LogicException(lang('Commands.commandAliasSameAsName', [$alias])); + } + + if (isset($seen[$alias])) { + throw new LogicException(lang('Commands.duplicateCommandAlias', [$alias])); + } + + $seen[$alias] = true; + } + + $this->aliases = array_values($aliases); } } diff --git a/system/CLI/Commands.php b/system/CLI/Commands.php index 1b298f4159ef..6997a9208ac0 100644 --- a/system/CLI/Commands.php +++ b/system/CLI/Commands.php @@ -27,7 +27,7 @@ * Command discovery and execution class. * * @phpstan-type legacy_commands array, file: string, group: string, description: string}> - * @phpstan-type modern_commands array, file: string, group: string, description: string}> + * @phpstan-type modern_commands array, file: string, group: string, description: string, aliases: list}> */ class Commands { @@ -49,6 +49,13 @@ class Commands */ private array $modernCommands = []; + /** + * Maps an alias name to the canonical modern command name it resolves to. + * + * @var array + */ + private array $aliases = []; + /** * Guards {@see discoverCommands()} from re-scanning the filesystem on repeat calls. */ @@ -153,6 +160,16 @@ public function getModernCommands(): array return $this->modernCommands; } + /** + * Provide access to the alias map of modern commands. + * + * @return array Alias name mapped to its canonical command name. + */ + public function getCommandAliases(): array + { + return $this->aliases; + } + /** * Checks if a legacy command with the given name has been discovered. */ @@ -162,15 +179,16 @@ public function hasLegacyCommand(string $name): bool } /** - * Checks if a modern command with the given name has been discovered. + * Checks whether the given name resolves to a modern command, either as a + * command name or as one of its aliases. * - * A name present in both registries signals a collision; legacy wins - * at runtime. Callers can combine this with {@see hasLegacyCommand()} - * to detect that case. + * A name present in both registries signals a collision. Legacy wins at + * runtime. Callers can combine this with `hasLegacyCommand()` to detect + * that case. */ public function hasModernCommand(string $name): bool { - return array_key_exists($name, $this->modernCommands); + return $this->resolveCommand($name) !== null; } /** @@ -186,15 +204,33 @@ public function getCommand(string $command, bool $legacy = false): AbstractComma return new $className($this->logger, $this); } - if (! $legacy && isset($this->modernCommands[$command])) { - $className = $this->modernCommands[$command]['class']; + if (! $legacy) { + $resolved = $this->resolveCommand($command); + + if ($resolved !== null) { + $className = $this->modernCommands[$resolved]['class']; - return new $className($this); + return new $className($this); + } } throw new CommandNotFoundException($command); } + /** + * Resolves a modern command name or alias to its canonical command name, + * or `null` when neither matches. The command name takes precedence so an + * alias can never shadow a real command. + */ + private function resolveCommand(string $name): ?string + { + if (isset($this->modernCommands[$name])) { + return $name; + } + + return $this->aliases[$name] ?? null; + } + /** * Discovers all commands in the framework and within user code, * and collects instances of them to work with. @@ -247,6 +283,38 @@ public function discoverCommands() 'yellow', ); } + + $this->registerAliases(); + } + + /** + * Builds the alias map from the discovered modern commands. Fails hard when + * an alias collides with an existing command name or another alias. + * + * @throws LogicException + */ + private function registerAliases(): void + { + foreach ($this->modernCommands as $name => $details) { + // A legacy command of the same name shadows this modern command at + // dispatch, so its aliases would resolve to a command `spark ` + // never reaches. Drop them entirely. + if (isset($this->commands[$name])) { + continue; + } + + foreach ($details['aliases'] as $alias) { + if (isset($this->commands[$alias]) || isset($this->modernCommands[$alias])) { + throw new LogicException(lang('Commands.aliasClashesWithCommandName', [$alias, $name])); + } + + if (isset($this->aliases[$alias])) { + throw new LogicException(lang('Commands.aliasClashesWithAlias', [$alias, $name, $this->aliases[$alias]])); + } + + $this->aliases[$alias] = $name; + } + } } /** @@ -264,7 +332,7 @@ public function verifyCommand(string $command, array $commands = [], bool $legac return true; } - if (isset($this->modernCommands[$command]) && ! $legacy) { + if (! $legacy && $this->resolveCommand($command) !== null) { return true; } @@ -302,7 +370,7 @@ protected function getCommandAlternatives(string $name, array $collection = []): /** @var array */ $alternatives = []; - foreach (array_keys($this->commands + $this->modernCommands) as $commandName) { + foreach (array_keys($this->commands + $this->modernCommands + $this->aliases) as $commandName) { $lev = levenshtein($name, $commandName); if ($lev <= strlen($commandName) / 3 || str_contains($commandName, $name)) { @@ -370,6 +438,7 @@ private function registerModernCommand(ReflectionClass $class, string $file): vo 'file' => $file, 'group' => $attribute->group, 'description' => $attribute->description, + 'aliases' => $attribute->aliases, ]; } } diff --git a/system/Commands/Help.php b/system/Commands/Help.php index d64727ae23c0..f799a0547377 100644 --- a/system/Commands/Help.php +++ b/system/Commands/Help.php @@ -69,6 +69,15 @@ private function describeHelp(AbstractCommand $command): void CLI::write($this->addPadding($command->getDescription())); } + if ($command->getAliases() !== []) { + CLI::newLine(); + CLI::write(lang('CLI.helpAliases'), 'yellow'); + + foreach ($command->getAliases() as $alias) { + CLI::write($this->addPadding($alias)); + } + } + $maxPadding = $this->getMaxPadding($command); if ($command->getArgumentsDefinition() !== []) { diff --git a/system/Commands/ListCommands.php b/system/Commands/ListCommands.php index 4599ed67b77e..a4a011f30a87 100644 --- a/system/Commands/ListCommands.php +++ b/system/Commands/ListCommands.php @@ -45,8 +45,9 @@ private function describeCommandsSimple(): int { // Legacy takes precedence on key collision so the listing reflects the // command that would actually be invoked. + $runner = $this->getCommandRunner(); $commands = array_keys( - $this->getCommandRunner()->getCommands() + $this->getCommandRunner()->getModernCommands(), + $runner->getCommands() + $runner->getModernCommands() + $runner->getCommandAliases(), ); sort($commands); @@ -67,7 +68,9 @@ private function describeCommandsDetailed(): int // Legacy takes precedence on key collision so the listing reflects the // command that would actually be invoked. - $all = $this->getCommandRunner()->getCommands() + $this->getCommandRunner()->getModernCommands(); + $runner = $this->getCommandRunner(); + $modern = $runner->getModernCommands(); + $all = $runner->getCommands() + $modern; foreach ($all as $command => $details) { $maxPad = max($maxPad, strlen($command) + 4); @@ -75,6 +78,13 @@ private function describeCommandsDetailed(): int $entries[] = [$details['group'], $command, $details['description']]; } + // Aliases are listed as their own rows under the group of the command they resolve to. + foreach ($runner->getCommandAliases() as $alias => $canonical) { + $maxPad = max($maxPad, strlen($alias) + 4); + + $entries[] = [$modern[$canonical]['group'], $alias, lang('CLI.commandAlias', [$canonical])]; + } + usort($entries, static function (array $a, array $b): int { $cmp = strcmp($a[0], $b[0]); diff --git a/system/Language/en/CLI.php b/system/Language/en/CLI.php index 2e878bdefbd3..def36f4331f7 100644 --- a/system/Language/en/CLI.php +++ b/system/Language/en/CLI.php @@ -15,6 +15,7 @@ return [ 'altCommandPlural' => 'Did you mean one of these?', 'altCommandSingular' => 'Did you mean this?', + 'commandAlias' => '[alias of {0}]', 'commandNotFound' => 'Command "{0}" not found.', 'generator' => [ 'cancelOperation' => 'Operation has been cancelled.', @@ -48,6 +49,7 @@ 'cell' => 'Cell view name', ], ], + 'helpAliases' => 'Aliases:', 'helpArguments' => 'Arguments:', 'helpAvailableCommands' => 'Available commands:', 'helpDescription' => 'Description:', diff --git a/system/Language/en/Commands.php b/system/Language/en/Commands.php index 46320b37d26a..142138338d16 100644 --- a/system/Language/en/Commands.php +++ b/system/Language/en/Commands.php @@ -13,13 +13,17 @@ // Commands language settings return [ + 'aliasClashesWithAlias' => 'Command alias "{0}" of the "{1}" command is already used as an alias of the "{2}" command.', + 'aliasClashesWithCommandName' => 'Command alias "{0}" of the "{1}" command clashes with an existing command of the same name.', 'arrayArgumentInvalidDefault' => 'Array argument "{0}" must have an array default value or null.', 'arrayArgumentCannotBeRequired' => 'Array argument "{0}" cannot be required.', 'arrayOptionInvalidDefault' => 'Array option "--{0}" must have an array default value or null.', 'arrayOptionMustRequireValue' => 'Array option "--{0}" must require a value.', 'arrayOptionEmptyArrayDefault' => 'Array option "--{0}" cannot have an empty array as the default value.', 'argumentAfterArrayArgument' => 'Argument "{0}" cannot be defined after array argument "{1}".', + 'commandAliasSameAsName' => 'Command alias "{0}" cannot be the same as the command name.', 'duplicateArgument' => 'An argument with the name "{0}" is already defined.', + 'duplicateCommandAlias' => 'Command alias "{0}" is defined more than once.', '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.', 'duplicateOption' => 'An option with the name "--{0}" is already defined.', 'duplicateShortcut' => 'Shortcut "-{0}" cannot be used for option "--{1}"; it is already assigned to option "--{2}".', @@ -28,6 +32,7 @@ 'emptyOptionName' => 'Option name cannot be empty.', 'emptyShortcutName' => 'Shortcut name cannot be empty.', 'flagOptionPassedMultipleTimes' => 'Option "--{0}" is passed multiple times.', + 'invalidCommandAlias' => 'Command alias "{0}" is not valid.', 'invalidCommandName' => 'Command name "{0}" is not valid.', 'invalidArgumentName' => 'Argument name "{0}" is not valid.', 'invalidOptionName' => 'Option name "--{0}" is not valid.', diff --git a/tests/_support/Commands/Modern/AliasedCommand.php b/tests/_support/Commands/Modern/AliasedCommand.php new file mode 100644 index 000000000000..9d1390f6cfc3 --- /dev/null +++ b/tests/_support/Commands/Modern/AliasedCommand.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Commands\Modern; + +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; +use CodeIgniter\CLI\CLI; + +#[Command( + name: 'fixture:aliased', + description: 'Fixture command exercising command aliases.', + group: 'Fixtures', + aliases: ['fixture:alias', 'fa'], +)] +final class AliasedCommand extends AbstractCommand +{ + protected function execute(array $arguments, array $options): int + { + CLI::write('Ran fixture:aliased.'); + + return EXIT_SUCCESS; + } +} diff --git a/tests/_support/Duplicates/DuplicateModern.php b/tests/_support/Duplicates/DuplicateModern.php index cf0c5dc88600..082e82cb4a9b 100644 --- a/tests/_support/Duplicates/DuplicateModern.php +++ b/tests/_support/Duplicates/DuplicateModern.php @@ -22,7 +22,12 @@ * * @internal */ -#[Command(name: 'dup:test', description: 'Modern fixture that collides with a legacy command of the same name.', group: 'Duplicates')] +#[Command( + name: 'dup:test', + description: 'Modern fixture that collides with a legacy command of the same name.', + group: 'Duplicates', + aliases: ['dup:alias'], +)] final class DuplicateModern extends AbstractCommand { protected function execute(array $arguments, array $options): int diff --git a/tests/_support/InvalidCommands/AliasClashCommand.php b/tests/_support/InvalidCommands/AliasClashCommand.php new file mode 100644 index 000000000000..2c16e74553ac --- /dev/null +++ b/tests/_support/InvalidCommands/AliasClashCommand.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\InvalidCommands; + +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; + +#[Command(name: 'alias:source', description: 'Declares an alias used for collision tests.', group: 'Fixtures', aliases: ['alias:target'])] +final class AliasClashCommand extends AbstractCommand +{ + protected function execute(array $arguments, array $options): int + { + return EXIT_SUCCESS; + } +} diff --git a/tests/_support/InvalidCommands/AliasSecondClashCommand.php b/tests/_support/InvalidCommands/AliasSecondClashCommand.php new file mode 100644 index 000000000000..f04b2ae242c3 --- /dev/null +++ b/tests/_support/InvalidCommands/AliasSecondClashCommand.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\InvalidCommands; + +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; + +#[Command(name: 'alias:source-two', description: 'Declares an alias already used by another command.', group: 'Fixtures', aliases: ['alias:target'])] +final class AliasSecondClashCommand extends AbstractCommand +{ + protected function execute(array $arguments, array $options): int + { + return EXIT_SUCCESS; + } +} diff --git a/tests/_support/InvalidCommands/AliasTargetCommand.php b/tests/_support/InvalidCommands/AliasTargetCommand.php new file mode 100644 index 000000000000..aa0021de6eb6 --- /dev/null +++ b/tests/_support/InvalidCommands/AliasTargetCommand.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\InvalidCommands; + +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; + +#[Command(name: 'alias:target', description: 'Whose name an alias collides with.', group: 'Fixtures')] +final class AliasTargetCommand extends AbstractCommand +{ + protected function execute(array $arguments, array $options): int + { + return EXIT_SUCCESS; + } +} diff --git a/tests/system/CLI/Attributes/CommandTest.php b/tests/system/CLI/Attributes/CommandTest.php index 426275e5b3a0..ef8523d09122 100644 --- a/tests/system/CLI/Attributes/CommandTest.php +++ b/tests/system/CLI/Attributes/CommandTest.php @@ -41,6 +41,14 @@ public function testAttributeAllowsOmittedDescriptionAndGroup(): void $this->assertSame('', $command->description); $this->assertSame('', $command->group); + $this->assertSame([], $command->aliases); + } + + public function testAttributeExposesAliases(): void + { + $command = new Command(name: 'app:about', aliases: ['app:ab', 'ab']); + + $this->assertSame(['app:ab', 'ab'], $command->aliases); } /** @@ -84,5 +92,25 @@ public static function provideInvalidDefinitionsAreRejected(): iterable 'Command name "app::about" is not valid.', ['name' => 'app::about'], ]; + + yield 'empty alias' => [ + 'Command alias "" is not valid.', + ['name' => 'app:about', 'aliases' => ['']], + ]; + + yield 'alias with whitespace' => [ + 'Command alias "bad alias" is not valid.', + ['name' => 'app:about', 'aliases' => ['bad alias']], + ]; + + yield 'alias same as name' => [ + 'Command alias "app:about" cannot be the same as the command name.', + ['name' => 'app:about', 'aliases' => ['app:about']], + ]; + + yield 'duplicate alias' => [ + 'Command alias "ab" is defined more than once.', + ['name' => 'app:about', 'aliases' => ['ab', 'ab']], + ]; } } diff --git a/tests/system/CLI/CommandsTest.php b/tests/system/CLI/CommandsTest.php index 7959ac7764a5..3332259f1934 100644 --- a/tests/system/CLI/CommandsTest.php +++ b/tests/system/CLI/CommandsTest.php @@ -17,6 +17,7 @@ use CodeIgniter\Autoloader\FileLocatorInterface; use CodeIgniter\CLI\Exceptions\CommandNotFoundException; use CodeIgniter\CodeIgniter; +use CodeIgniter\Exceptions\LogicException; use CodeIgniter\Log\Logger; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\ReflectionHelper; @@ -30,9 +31,13 @@ use ReflectionClass; use RuntimeException; use Tests\Support\Commands\Legacy\AppInfo; +use Tests\Support\Commands\Modern\AliasedCommand; use Tests\Support\Commands\Modern\AppAboutCommand; use Tests\Support\Duplicates\DuplicateLegacy; use Tests\Support\Duplicates\DuplicateModern; +use Tests\Support\InvalidCommands\AliasClashCommand; +use Tests\Support\InvalidCommands\AliasSecondClashCommand; +use Tests\Support\InvalidCommands\AliasTargetCommand; use Tests\Support\InvalidCommands\EmptyCommandName; use Tests\Support\InvalidCommands\NoAttributeCommand; @@ -305,6 +310,76 @@ public function testCollidingCommandNameIsDetectableFromBothRegistries(): void $this->assertTrue($commands->hasModernCommand('dup:test')); } + public function testShadowedModernCommandAliasesAreNotRegistered(): void + { + $this->injectDuplicateLocator(); + + $commands = new Commands(); + + // The legacy command owns the name, so the shadowed modern command's + // alias is dropped: neither listed nor resolvable. + $this->assertSame([], $commands->getCommandAliases()); + $this->assertFalse($commands->hasModernCommand('dup:alias')); + } + + public function testModernCommandAliasesAreRegistered(): void + { + $aliases = (new Commands())->getCommandAliases(); + + $this->assertSame('fixture:aliased', $aliases['fixture:alias']); + $this->assertSame('fixture:aliased', $aliases['fa']); + } + + public function testHasModernCommandResolvesAliases(): void + { + $commands = new Commands(); + + $this->assertTrue($commands->hasModernCommand('fixture:alias')); + $this->assertTrue($commands->hasModernCommand('fa')); + } + + public function testGetCommandResolvesAliasToCanonicalCommand(): void + { + $command = (new Commands())->getCommand('fixture:alias'); + + $this->assertInstanceOf(AliasedCommand::class, $command); + $this->assertSame('fixture:aliased', $command->getName()); + } + + public function testRunCommandViaAlias(): void + { + $commands = new Commands(); + + $this->assertSame(EXIT_SUCCESS, $commands->runCommand('fa', [], [])); + $this->assertStringContainsString('Ran fixture:aliased.', $this->getStreamFilterBuffer()); + } + + public function testAliasClashingWithCommandNameFailsHard(): void + { + $this->injectAliasLocator([ + AliasTargetCommand::class => SUPPORTPATH . 'InvalidCommands/AliasTargetCommand.php', + AliasClashCommand::class => SUPPORTPATH . 'InvalidCommands/AliasClashCommand.php', + ]); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Command alias "alias:target" of the "alias:source" command clashes with an existing command of the same name.'); + + new Commands(); + } + + public function testAliasClashingWithAnotherAliasFailsHard(): void + { + $this->injectAliasLocator([ + AliasClashCommand::class => SUPPORTPATH . 'InvalidCommands/AliasClashCommand.php', + AliasSecondClashCommand::class => SUPPORTPATH . 'InvalidCommands/AliasSecondClashCommand.php', + ]); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Command alias "alias:target" of the "alias:source-two" command is already used as an alias of the "alias:source" command.'); + + new Commands(); + } + public function testDestructiveCommandIsNotRisky(): void { $this->expectException(RuntimeException::class); @@ -490,4 +565,28 @@ private function injectDuplicateLocator(): void ]); Services::injectMock('locator', $locator); } + + /** + * Partially mocks the real locator so `lang()` can still load language + * files while discovery is fed the given command fixtures. + * + * @param array $classToFile + */ + private function injectAliasLocator(array $classToFile): void + { + $map = []; + + foreach ($classToFile as $class => $file) { + $map[] = [$file, $class]; + } + + $locator = $this->getMockBuilder(FileLocator::class) + ->setConstructorArgs([service('autoloader')]) + ->onlyMethods(['listFiles', 'findQualifiedNameFromPath']) + ->getMock(); + $locator->method('listFiles')->with('Commands/')->willReturn(array_values($classToFile)); + $locator->method('findQualifiedNameFromPath')->willReturnMap($map); + + Services::injectMock('locator', $locator); + } } diff --git a/tests/system/Commands/HelpCommandTest.php b/tests/system/Commands/HelpCommandTest.php index a8308eca3f01..7981b3d20769 100644 --- a/tests/system/Commands/HelpCommandTest.php +++ b/tests/system/Commands/HelpCommandTest.php @@ -126,6 +126,60 @@ public function testDescribeSpecificCommand(): void ); } + public function testDescribeCommandWithAliases(): void + { + command('help fixture:aliased'); + + $this->assertSame( + <<<'EOT' + + Usage: + fixture:aliased [options] + + Description: + Fixture command exercising command aliases. + + Aliases: + fixture:alias + fa + + Options: + -h, --help Display help for the given command. + --no-header Do not display the banner when running the command. + -N, --no-interaction Do not ask any interactive questions. + + EOT, + $this->getUndecoratedBuffer(), + ); + } + + public function testDescribeCommandViaAliasResolvesToCanonical(): void + { + command('help fixture:alias'); + + $this->assertSame( + <<<'EOT' + + Usage: + fixture:aliased [options] + + Description: + Fixture command exercising command aliases. + + Aliases: + fixture:alias + fa + + Options: + -h, --help Display help for the given command. + --no-header Do not display the banner when running the command. + -N, --no-interaction Do not ask any interactive questions. + + EOT, + $this->getUndecoratedBuffer(), + ); + } + public function testDescribeUnavailableCommand(): void { command('help test:unavailable'); diff --git a/tests/system/Commands/ListCommandsTest.php b/tests/system/Commands/ListCommandsTest.php index 23c7551d5f4d..9683aacc2d64 100644 --- a/tests/system/Commands/ListCommandsTest.php +++ b/tests/system/Commands/ListCommandsTest.php @@ -13,14 +13,17 @@ namespace CodeIgniter\Commands; +use CodeIgniter\Autoloader\FileLocator; use CodeIgniter\CLI\CLI; use CodeIgniter\CLI\Commands; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\StreamFilterTrait; +use Config\Services; use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\Before; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Group; +use ReflectionClass; use Tests\Support\Duplicates\DuplicateLegacy; use Tests\Support\Duplicates\DuplicateModern; @@ -42,6 +45,11 @@ protected function resetAll(): void CLI::reset(); } + private function getUndecoratedBuffer(): string + { + return preg_replace('/\e\[[^m]+m/', '', $this->getStreamFilterBuffer()) ?? ''; + } + public function testRunCommand(): void { command('list'); @@ -66,6 +74,40 @@ public function testUnavailableCommandIsStillListed(): void $this->assertStringContainsString('Fixture command to test runtime availability checks.', $this->getStreamFilterBuffer()); } + public function testAliasIsListedAsItsOwnRowInDetailedOutput(): void + { + command('list'); + + $buffer = $this->getUndecoratedBuffer(); + + // The canonical command keeps its description on its own row... + $this->assertMatchesRegularExpression( + '/\n {2}fixture:aliased\s+Fixture command exercising command aliases\.\n/', + $buffer, + ); + // ...and each alias renders as a separate row pointing back to it. + $this->assertMatchesRegularExpression( + '/\n {2}fixture:alias\s+\[alias of fixture:aliased\]\n/', + $buffer, + ); + $this->assertMatchesRegularExpression( + '/\n {2}fa\s+\[alias of fixture:aliased\]\n/', + $buffer, + ); + } + + public function testAliasIsListedInSimpleOutput(): void + { + command('list --simple'); + + $buffer = $this->getUndecoratedBuffer(); + + // The canonical command and each alias are emitted as their own lines. + $this->assertStringContainsString("fixture:aliased\n", $buffer); + $this->assertStringContainsString("fixture:alias\n", $buffer); + $this->assertStringContainsString("fa\n", $buffer); + } + public function testDuplicateCommandNameListedOnceInSimpleOutput(): void { $list = new ListCommands($this->mockRunnerWithDuplicate()); @@ -87,6 +129,56 @@ public function testDuplicateCommandNameShowsLegacyDescriptionInDetailedOutput() $this->assertStringNotContainsString('Modern dup description', $buffer); } + public function testShadowedAliasIsNotListedInDetailedOutput(): void + { + $list = new ListCommands($this->discoveredRunnerWithDuplicate()); + $this->resetStreamFilterBuffer(); + + $list->run([], []); + + $buffer = $this->getUndecoratedBuffer(); + + $this->assertStringContainsString('dup:test', $buffer); + $this->assertStringNotContainsString('dup:alias', $buffer); + } + + public function testShadowedAliasIsNotListedInSimpleOutput(): void + { + $list = new ListCommands($this->discoveredRunnerWithDuplicate()); + $this->resetStreamFilterBuffer(); + + $list->run([], ['simple' => null]); + + $buffer = $this->getUndecoratedBuffer(); + + $this->assertStringContainsString('dup:test', $buffer); + $this->assertStringNotContainsString('dup:alias', $buffer); + } + + /** + * Runs real discovery against the colliding legacy/modern `dup:test` + * fixtures so the alias suppression in `Commands::registerAliases()` is + * exercised end to end, not stubbed. + */ + private function discoveredRunnerWithDuplicate(): Commands + { + $legacyFile = (new ReflectionClass(DuplicateLegacy::class))->getFileName(); + $modernFile = (new ReflectionClass(DuplicateModern::class))->getFileName(); + + $locator = $this->getMockBuilder(FileLocator::class) + ->setConstructorArgs([service('autoloader')]) + ->onlyMethods(['listFiles', 'findQualifiedNameFromPath']) + ->getMock(); + $locator->method('listFiles')->with('Commands/')->willReturn([$legacyFile, $modernFile]); + $locator->expects($this->exactly(2))->method('findQualifiedNameFromPath')->willReturnMap([ + [$legacyFile, DuplicateLegacy::class], + [$modernFile, DuplicateModern::class], + ]); + Services::injectMock('locator', $locator); + + return new Commands(); + } + private function mockRunnerWithDuplicate(): Commands { $runner = $this->createMock(Commands::class); @@ -108,8 +200,10 @@ private function mockRunnerWithDuplicate(): Commands 'file' => 'irrelevant', 'group' => 'Duplicates', 'description' => 'Modern dup description', + 'aliases' => [], ], ]); + $runner->method('getCommandAliases')->willReturn([]); return $runner; } diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index f1c369184f3b..aff1224779a4 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -186,6 +186,9 @@ Commands - Added a new attribute-based command style built on :php:class:`AbstractCommand ` and the ``#[Command]`` attribute, with ``configure()`` / ``isAvailable()`` / ``initialize()`` / ``interact()`` / ``execute()`` hooks and typed ``Argument`` / ``Option`` definitions. The legacy ``BaseCommand`` style continues to work. See :doc:`../cli/cli_modern_commands`. +- Modern commands can now declare command aliases through an ``aliases`` list on the ``#[Command]`` attribute. Aliases resolve to the command + at dispatch (``php spark `` and ``help ``), are listed as their own rows in ``spark list``, and appear in an ``Aliases:`` section + of ``help ``. An alias that collides with an existing command name or another alias is rejected at discovery. See :doc:`../cli/cli_modern_commands`. - Every modern command now ships with a ``--no-interaction`` / ``-N`` flag that skips the ``interact()`` hook, plus public ``isInteractive()`` / ``setInteractive()`` methods on ``AbstractCommand``. ``isInteractive()`` also auto-detects piped or CI environments by probing STDIN for a TTY, and the state cascades to sub-commands invoked via ``$this->call(...)``. @@ -318,8 +321,10 @@ Others Message Changes *************** -- Added new language key: +- Added new language keys: - ``Cache.unsupportedLockStore`` (``CacheException::forUnsupportedLockStore()``) + - ``CLI.commandAlias`` and ``CLI.helpAliases`` (command alias rendering in ``list`` and ``help``) + - ``Commands.invalidCommandAlias``, ``Commands.commandAliasSameAsName``, ``Commands.duplicateCommandAlias``, ``Commands.aliasClashesWithCommandName``, and ``Commands.aliasClashesWithAlias`` (command alias validation) - Removed deprecated language keys tied to removed exception constructors: - ``Core.missingExtension`` (``FrameworkException::forMissingExtension()``) diff --git a/user_guide_src/source/cli/cli_modern_commands.rst b/user_guide_src/source/cli/cli_modern_commands.rst index e3298d92f584..52e17ee5f55f 100644 --- a/user_guide_src/source/cli/cli_modern_commands.rst +++ b/user_guide_src/source/cli/cli_modern_commands.rst @@ -52,9 +52,18 @@ The attribute holds the command's identity: - ``description`` is shown in the ``list`` output and at the top of ``help ``. - ``group`` controls how the command is grouped in the ``list`` output. A command with an empty ``group`` is skipped by discovery. +- ``aliases`` is an optional list of alternative names the command can also be invoked by. Each alias + follows the same naming rules as ``name`` and must differ from it. Aliases resolve to the command at + dispatch (``php spark `` and ``help `` both work), are listed as their own rows in the + ``list`` output, and are shown in an ``Aliases:`` section of ``help ``. -The attribute itself validates these constraints at construction time — if you +The attribute itself validates these constraints at construction time. If you misspell ``name``, you will see the error at discovery rather than at run time. +An alias that collides with an existing command name or with another command's +alias is a hard error at discovery, since the runner could not tell which command +you meant. + +.. literalinclude:: cli_modern_commands/014.php ***************** Command Lifecycle @@ -412,7 +421,10 @@ Coexistence With Legacy Commands Legacy ``BaseCommand`` classes are still supported, and they are discovered alongside modern commands. If the same name is claimed by both a legacy and a modern command, the legacy one is invoked and a warning is printed once at -discovery time so you can rename or retire one of the two. +discovery time so you can rename or retire one of the two. Any aliases declared +by the shadowed modern command are dropped at discovery, so they are neither +listed nor runnable. Resolve the collision and the modern command, along with +its aliases, becomes reachable again. To detect the collision programmatically — for example, in a migration script that verifies the legacy copy was removed — the ``Commands`` runner exposes two diff --git a/user_guide_src/source/cli/cli_modern_commands/014.php b/user_guide_src/source/cli/cli_modern_commands/014.php new file mode 100644 index 000000000000..ee996f962a60 --- /dev/null +++ b/user_guide_src/source/cli/cli_modern_commands/014.php @@ -0,0 +1,22 @@ +