Skip to content
Merged
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
8 changes: 8 additions & 0 deletions system/CLI/AbstractCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ abstract class AbstractCommand
*/
private readonly array $aliases;

private readonly bool $hidden;

/**
* @var list<non-empty-string>
*/
Expand Down Expand Up @@ -142,6 +144,7 @@ public function __construct(private readonly Commands $commands)
$this->description = $attribute->description;
$this->group = $attribute->group;
$this->aliases = $attribute->aliases;
$this->hidden = $attribute->hidden;

$this->configure();
$this->provideDefaultOptions();
Expand Down Expand Up @@ -177,6 +180,11 @@ public function getAliases(): array
return $this->aliases;
}

public function isHidden(): bool
{
return $this->hidden;
}

/**
* @return list<non-empty-string>
*/
Expand Down
1 change: 1 addition & 0 deletions system/CLI/Attributes/Command.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ public function __construct(
public string $description = '',
public string $group = '',
array $aliases = [],
public bool $hidden = false,
) {
if ($name === '') {
throw new LogicException(lang('Commands.emptyCommandName'));
Expand Down
23 changes: 21 additions & 2 deletions system/CLI/Commands.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,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, aliases: list<string>}>
* @phpstan-type modern_commands array<string, array{class: class-string<AbstractCommand>, file: string, group: string, description: string, aliases: list<string>, hidden: bool}>
*/
class Commands
{
Expand Down Expand Up @@ -192,6 +192,20 @@ public function hasModernCommand(string $name): bool
return $this->resolveCommand($name) !== null;
}

/**
* Checks whether the given command name or alias resolves to a hidden modern command that no legacy command shadows.
*/
public function isHiddenCommand(string $name): bool
{
if (isset($this->commands[$name])) {
return false;
}

$resolved = $this->resolveCommand($name);

return $resolved !== null && $this->modernCommands[$resolved]['hidden'];
}

/**
* @return ($legacy is true ? BaseCommand : AbstractCommand)
*
Expand Down Expand Up @@ -356,7 +370,7 @@ public function verifyCommand(string $command, array $commands = [], bool $legac
}

/**
* Finds alternative of `$name` across both legacy and modern commands.
* Finds alternative of `$name` across both legacy and modern commands, skipping hidden ones.
*
* @param legacy_commands $collection (no longer used)
*
Expand All @@ -372,6 +386,10 @@ public function getCommandAlternatives(string $name, array $collection = []): ar
$alternatives = [];

foreach (array_keys($this->commands + $this->modernCommands + $this->aliases) as $commandName) {
if ($this->isHiddenCommand($commandName)) {
continue;
}

$lev = levenshtein($name, $commandName);

if ($lev <= strlen($commandName) / 3 || str_contains($commandName, $name)) {
Expand Down Expand Up @@ -454,6 +472,7 @@ private function registerModernCommand(ReflectionClass $class, string $file): vo
'group' => $attribute->group,
'description' => $attribute->description,
'aliases' => $attribute->aliases,
'hidden' => $attribute->hidden,
];
}
}
13 changes: 11 additions & 2 deletions system/Commands/ListCommands.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,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(
$runner->getCommands() + $runner->getModernCommands() + $runner->getCommandAliases(),
$commands = array_filter(
array_keys($runner->getCommands() + $runner->getModernCommands() + $runner->getCommandAliases()),
static fn (string $command): bool => ! $runner->isHiddenCommand($command),
);
sort($commands);

Expand All @@ -73,13 +74,21 @@ private function describeCommandsDetailed(): int
$all = $runner->getCommands() + $modern;

foreach ($all as $command => $details) {
if ($runner->isHiddenCommand($command)) {
continue;
}

$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) {
if ($runner->isHiddenCommand($alias)) {
continue;
}

$maxPad = max($maxPad, strlen($alias) + 4);

$entries[] = [$modern[$canonical]['group'], $alias, lang('CLI.commandAlias', [$canonical])];
Expand Down
35 changes: 35 additions & 0 deletions tests/_support/Commands/Modern/HiddenCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?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:hidden',
description: 'Fixture command exercising hidden commands.',
group: 'Fixtures',
aliases: ['fixture:secret'],
hidden: true,
)]
final class HiddenCommand extends AbstractCommand
{
protected function execute(array $arguments, array $options): int
{
CLI::write('Ran fixture:hidden.');

return EXIT_SUCCESS;
}
}
36 changes: 36 additions & 0 deletions tests/_support/Duplicates/HiddenDuplicateModern.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?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\Duplicates;

use CodeIgniter\CLI\AbstractCommand;
use CodeIgniter\CLI\Attributes\Command;

/**
* Hidden modern fixture shadowed by the legacy command of the same name.
*
* @internal
*/
#[Command(
name: 'dup:test',
description: 'Hidden modern fixture that collides with a legacy command of the same name.',
group: 'Duplicates',
hidden: true,
)]
final class HiddenDuplicateModern extends AbstractCommand
{
protected function execute(array $arguments, array $options): int
{
return EXIT_SUCCESS;
}
}
7 changes: 7 additions & 0 deletions tests/system/CLI/AbstractCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
use ReflectionClass;
use ReflectionProperty;
use Tests\Support\Commands\Modern\AppAboutCommand;
use Tests\Support\Commands\Modern\HiddenCommand;
use Tests\Support\Commands\Modern\InteractFixtureCommand;
use Tests\Support\Commands\Modern\InteractiveStateProbeCommand;
use Tests\Support\Commands\Modern\ParentCallsInteractFixtureCommand;
Expand Down Expand Up @@ -82,10 +83,16 @@ public function testConstructorSetsNeededProperties(): void
$this->assertSame($attribute->name, $command->getName());
$this->assertSame($attribute->description, $command->getDescription());
$this->assertSame($attribute->group, $command->getGroup());
$this->assertSame($attribute->hidden, $command->isHidden());
$this->assertSame($commands, $command->getCommandRunner());
$this->assertSame('help [options] [--] [<command_name>]', $command->getUsages()[0]);
}

public function testHiddenCommandReportsItself(): void
{
$this->assertTrue((new HiddenCommand(new Commands()))->isHidden());
}

public function testCommandRequiresCommandAttribute(): void
{
$this->expectException(LogicException::class);
Expand Down
6 changes: 6 additions & 0 deletions tests/system/CLI/Attributes/CommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ public function testAttributeAllowsOmittedDescriptionAndGroup(): void
$this->assertSame('', $command->description);
$this->assertSame('', $command->group);
$this->assertSame([], $command->aliases);
$this->assertFalse($command->hidden);
}

public function testAttributeExposesHidden(): void
{
$this->assertTrue((new Command(name: 'app:about', hidden: true))->hidden);
}

public function testAttributeExposesAliases(): void
Expand Down
126 changes: 126 additions & 0 deletions tests/system/CLI/CommandsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
use Tests\Support\Commands\Modern\AppAboutCommand;
use Tests\Support\Duplicates\DuplicateLegacy;
use Tests\Support\Duplicates\DuplicateModern;
use Tests\Support\Duplicates\HiddenDuplicateModern;
use Tests\Support\InvalidCommands\AliasClashCommand;
use Tests\Support\InvalidCommands\AliasSecondClashCommand;
use Tests\Support\InvalidCommands\AliasTargetCommand;
Expand Down Expand Up @@ -349,6 +350,131 @@ public function testRunCommandViaAlias(): void
$this->assertStringContainsString('Ran fixture:aliased.', $this->getStreamFilterBuffer());
}

public function testHiddenCommandIsRegisteredWithItsFlag(): void
{
$commands = (new Commands())->getModernCommands();

$this->assertTrue($commands['fixture:hidden']['hidden']);
$this->assertFalse($commands['fixture:aliased']['hidden']);
}

public function testIsHiddenCommand(): void
{
$commands = new Commands();

$this->assertTrue($commands->isHiddenCommand('fixture:hidden'));
$this->assertTrue($commands->isHiddenCommand('fixture:secret'));
$this->assertFalse($commands->isHiddenCommand('fixture:aliased'));
$this->assertFalse($commands->isHiddenCommand('fixture:alias'));
$this->assertFalse($commands->isHiddenCommand('app:info'));
$this->assertFalse($commands->isHiddenCommand('app:unknown'));
}

public function testIsHiddenCommandIsFalseWhenLegacyCommandShadowsIt(): void
{
$this->injectFixtureLocator([
DuplicateLegacy::class => SUPPORTPATH . 'Duplicates/DuplicateLegacy.php',
HiddenDuplicateModern::class => SUPPORTPATH . 'Duplicates/HiddenDuplicateModern.php',
]);

$this->assertFalse((new Commands())->isHiddenCommand('dup:test'));
}

public function testHiddenCommandRunsByName(): void
{
command('fixture:hidden');

$this->assertSame(
<<<'EOT'

Ran fixture:hidden.

EOT,
$this->getUndecoratedBuffer(),
);
}

public function testHiddenCommandRunsByAlias(): void
{
command('fixture:secret');

$this->assertSame(
<<<'EOT'

Ran fixture:hidden.

EOT,
$this->getUndecoratedBuffer(),
);
}

public function testHiddenCommandRunsThroughModernCall(): void
{
$commands = new Commands();
$call = $this->getPrivateMethodInvoker(new AliasedCommand($commands), 'call');

$this->assertSame(EXIT_SUCCESS, $call('fixture:hidden'));
$this->assertSame(EXIT_SUCCESS, $call('fixture:secret'));
$this->assertSame(
<<<'EOT'

Ran fixture:hidden.
Ran fixture:hidden.

EOT,
$this->getUndecoratedBuffer(),
);
}

public function testHiddenCommandRunsThroughLegacyCall(): void
{
$commands = new Commands();
$call = $this->getPrivateMethodInvoker(new AppInfo(service('logger'), $commands), 'call');

$this->assertSame(EXIT_SUCCESS, $call('fixture:hidden'));
$this->assertSame(EXIT_SUCCESS, $call('fixture:secret'));
$this->assertSame(
<<<'EOT'

Ran fixture:hidden.
Ran fixture:hidden.

EOT,
$this->getUndecoratedBuffer(),
);
}

public function testHiddenCommandAndItsAliasesAreNotSuggested(): void
{
$this->assertSame(['fixture:alias', 'fixture:aliased'], (new Commands())->getCommandAlternatives('fixture:'));
}

public function testMistypedHiddenCommandIsReportedWithoutSuggestion(): void
{
$commands = new Commands();

$this->assertSame([], $commands->getCommandAlternatives('fixture:secre'));
$this->assertSame(EXIT_ERROR, $commands->runCommand('fixture:hiddenn', [], []));
$this->assertSame(
<<<'EOT'

Command "fixture:hiddenn" not found.

EOT,
$this->getUndecoratedBuffer(),
);
}

public function testLegacyCommandShadowingHiddenModernCommandIsStillSuggested(): void
{
$this->injectFixtureLocator([
DuplicateLegacy::class => SUPPORTPATH . 'Duplicates/DuplicateLegacy.php',
HiddenDuplicateModern::class => SUPPORTPATH . 'Duplicates/HiddenDuplicateModern.php',
]);

$this->assertSame(['dup:test'], (new Commands())->getCommandAlternatives('dup:tes'));
}

public function testAliasClashingWithCommandNameFailsHard(): void
{
$this->injectFixtureLocator([
Expand Down
Loading
Loading