Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions system/CLI/AbstractCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ abstract class AbstractCommand
private readonly string $description;
private readonly string $group;

/**
* @var list<non-empty-string>
*/
private readonly array $aliases;

/**
* @var list<non-empty-string>
*/
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -165,6 +171,14 @@ public function getGroup(): string
return $this->group;
}

/**
* @return list<non-empty-string>
*/
public function getAliases(): array
{
return $this->aliases;
}

/**
* @return list<non-empty-string>
*/
Expand Down
32 changes: 31 additions & 1 deletion system/CLI/Attributes/Command.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<non-empty-string>
*/
public array $aliases;

/**
* @param list<string> $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);
}
}
91 changes: 80 additions & 11 deletions system/CLI/Commands.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
* Command discovery and execution class.
*
* @phpstan-type legacy_commands array<string, array{class: class-string<BaseCommand>, file: string, group: string, description: string}>
* @phpstan-type modern_commands array<string, array{class: class-string<AbstractCommand>, file: string, group: string, description: string}>
* @phpstan-type modern_commands array<string, array{class: class-string<AbstractCommand>, file: string, group: string, description: string, aliases: list<string>}>
*/
class Commands
{
Expand All @@ -49,6 +49,13 @@ class Commands
*/
private array $modernCommands = [];

/**
* Maps an alias name to the canonical modern command name it resolves to.
*
* @var array<string, string>
*/
private array $aliases = [];

/**
* Guards {@see discoverCommands()} from re-scanning the filesystem on repeat calls.
*/
Expand Down Expand Up @@ -153,6 +160,16 @@ public function getModernCommands(): array
return $this->modernCommands;
}

/**
* Provide access to the alias map of modern commands.
*
* @return array<string, string> 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.
*/
Expand All @@ -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;
}

/**
Expand All @@ -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.
Expand Down Expand Up @@ -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 <name>`
// 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;
}
}
}

/**
Expand All @@ -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;
}

Expand Down Expand Up @@ -302,7 +370,7 @@ protected function getCommandAlternatives(string $name, array $collection = []):
/** @var array<string, int> */
$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)) {
Expand Down Expand Up @@ -370,6 +438,7 @@ private function registerModernCommand(ReflectionClass $class, string $file): vo
'file' => $file,
'group' => $attribute->group,
'description' => $attribute->description,
'aliases' => $attribute->aliases,
];
}
}
9 changes: 9 additions & 0 deletions system/Commands/Help.php
Original file line number Diff line number Diff line change
Expand Up @@ -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() !== []) {
Expand Down
14 changes: 12 additions & 2 deletions system/Commands/ListCommands.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -67,14 +68,23 @@ 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);

$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]);

Expand Down
2 changes: 2 additions & 0 deletions system/Language/en/CLI.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down Expand Up @@ -48,6 +49,7 @@
'cell' => 'Cell view name',
],
],
'helpAliases' => 'Aliases:',
'helpArguments' => 'Arguments:',
'helpAvailableCommands' => 'Available commands:',
'helpDescription' => 'Description:',
Expand Down
5 changes: 5 additions & 0 deletions system/Language/en/Commands.php
Original file line number Diff line number Diff line change
Expand Up @@ -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}".',
Expand All @@ -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.',
Expand Down
34 changes: 34 additions & 0 deletions tests/_support/Commands/Modern/AliasedCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* 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;
}
}
Loading
Loading