Skip to content

Commit dc9c9f0

Browse files
authored
feat: allow hiding modern commands from list and suggestions (#10586)
1 parent 534a9e7 commit dc9c9f0

16 files changed

Lines changed: 431 additions & 7 deletions

File tree

‎system/CLI/AbstractCommand.php‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ abstract class AbstractCommand
4545
*/
4646
private readonly array $aliases;
4747

48+
private readonly bool $hidden;
49+
4850
/**
4951
* @var list<non-empty-string>
5052
*/
@@ -142,6 +144,7 @@ public function __construct(private readonly Commands $commands)
142144
$this->description = $attribute->description;
143145
$this->group = $attribute->group;
144146
$this->aliases = $attribute->aliases;
147+
$this->hidden = $attribute->hidden;
145148

146149
$this->configure();
147150
$this->provideDefaultOptions();
@@ -177,6 +180,11 @@ public function getAliases(): array
177180
return $this->aliases;
178181
}
179182

183+
public function isHidden(): bool
184+
{
185+
return $this->hidden;
186+
}
187+
180188
/**
181189
* @return list<non-empty-string>
182190
*/

‎system/CLI/Attributes/Command.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ public function __construct(
4444
public string $description = '',
4545
public string $group = '',
4646
array $aliases = [],
47+
public bool $hidden = false,
4748
) {
4849
if ($name === '') {
4950
throw new LogicException(lang('Commands.emptyCommandName'));

‎system/CLI/Commands.php‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
* Command discovery and execution class.
2929
*
3030
* @phpstan-type legacy_commands array<string, array{class: class-string<BaseCommand>, file: string, group: string, description: string}>
31-
* @phpstan-type modern_commands array<string, array{class: class-string<AbstractCommand>, file: string, group: string, description: string, aliases: list<string>}>
31+
* @phpstan-type modern_commands array<string, array{class: class-string<AbstractCommand>, file: string, group: string, description: string, aliases: list<string>, hidden: bool}>
3232
*/
3333
class Commands
3434
{
@@ -192,6 +192,20 @@ public function hasModernCommand(string $name): bool
192192
return $this->resolveCommand($name) !== null;
193193
}
194194

195+
/**
196+
* Checks whether the given command name or alias resolves to a hidden modern command that no legacy command shadows.
197+
*/
198+
public function isHiddenCommand(string $name): bool
199+
{
200+
if (isset($this->commands[$name])) {
201+
return false;
202+
}
203+
204+
$resolved = $this->resolveCommand($name);
205+
206+
return $resolved !== null && $this->modernCommands[$resolved]['hidden'];
207+
}
208+
195209
/**
196210
* @return ($legacy is true ? BaseCommand : AbstractCommand)
197211
*
@@ -356,7 +370,7 @@ public function verifyCommand(string $command, array $commands = [], bool $legac
356370
}
357371

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

374388
foreach (array_keys($this->commands + $this->modernCommands + $this->aliases) as $commandName) {
389+
if ($this->isHiddenCommand($commandName)) {
390+
continue;
391+
}
392+
375393
$lev = levenshtein($name, $commandName);
376394

377395
if ($lev <= strlen($commandName) / 3 || str_contains($commandName, $name)) {
@@ -454,6 +472,7 @@ private function registerModernCommand(ReflectionClass $class, string $file): vo
454472
'group' => $attribute->group,
455473
'description' => $attribute->description,
456474
'aliases' => $attribute->aliases,
475+
'hidden' => $attribute->hidden,
457476
];
458477
}
459478
}

‎system/Commands/ListCommands.php‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,9 @@ private function describeCommandsSimple(): int
4646
// Legacy takes precedence on key collision so the listing reflects the
4747
// command that would actually be invoked.
4848
$runner = $this->getCommandRunner();
49-
$commands = array_keys(
50-
$runner->getCommands() + $runner->getModernCommands() + $runner->getCommandAliases(),
49+
$commands = array_filter(
50+
array_keys($runner->getCommands() + $runner->getModernCommands() + $runner->getCommandAliases()),
51+
static fn (string $command): bool => ! $runner->isHiddenCommand($command),
5152
);
5253
sort($commands);
5354

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

7576
foreach ($all as $command => $details) {
77+
if ($runner->isHiddenCommand($command)) {
78+
continue;
79+
}
80+
7681
$maxPad = max($maxPad, strlen($command) + 4);
7782

7883
$entries[] = [$details['group'], $command, $details['description']];
7984
}
8085

8186
// Aliases are listed as their own rows under the group of the command they resolve to.
8287
foreach ($runner->getCommandAliases() as $alias => $canonical) {
88+
if ($runner->isHiddenCommand($alias)) {
89+
continue;
90+
}
91+
8392
$maxPad = max($maxPad, strlen($alias) + 4);
8493

8594
$entries[] = [$modern[$canonical]['group'], $alias, lang('CLI.commandAlias', [$canonical])];
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) CodeIgniter Foundation <admin@codeigniter.com>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace Tests\Support\Commands\Modern;
15+
16+
use CodeIgniter\CLI\AbstractCommand;
17+
use CodeIgniter\CLI\Attributes\Command;
18+
use CodeIgniter\CLI\CLI;
19+
20+
#[Command(
21+
name: 'fixture:hidden',
22+
description: 'Fixture command exercising hidden commands.',
23+
group: 'Fixtures',
24+
aliases: ['fixture:secret'],
25+
hidden: true,
26+
)]
27+
final class HiddenCommand extends AbstractCommand
28+
{
29+
protected function execute(array $arguments, array $options): int
30+
{
31+
CLI::write('Ran fixture:hidden.');
32+
33+
return EXIT_SUCCESS;
34+
}
35+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) CodeIgniter Foundation <admin@codeigniter.com>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace Tests\Support\Duplicates;
15+
16+
use CodeIgniter\CLI\AbstractCommand;
17+
use CodeIgniter\CLI\Attributes\Command;
18+
19+
/**
20+
* Hidden modern fixture shadowed by the legacy command of the same name.
21+
*
22+
* @internal
23+
*/
24+
#[Command(
25+
name: 'dup:test',
26+
description: 'Hidden modern fixture that collides with a legacy command of the same name.',
27+
group: 'Duplicates',
28+
hidden: true,
29+
)]
30+
final class HiddenDuplicateModern extends AbstractCommand
31+
{
32+
protected function execute(array $arguments, array $options): int
33+
{
34+
return EXIT_SUCCESS;
35+
}
36+
}

‎tests/system/CLI/AbstractCommandTest.php‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
use ReflectionClass;
3939
use ReflectionProperty;
4040
use Tests\Support\Commands\Modern\AppAboutCommand;
41+
use Tests\Support\Commands\Modern\HiddenCommand;
4142
use Tests\Support\Commands\Modern\InteractFixtureCommand;
4243
use Tests\Support\Commands\Modern\InteractiveStateProbeCommand;
4344
use Tests\Support\Commands\Modern\ParentCallsInteractFixtureCommand;
@@ -82,10 +83,16 @@ public function testConstructorSetsNeededProperties(): void
8283
$this->assertSame($attribute->name, $command->getName());
8384
$this->assertSame($attribute->description, $command->getDescription());
8485
$this->assertSame($attribute->group, $command->getGroup());
86+
$this->assertSame($attribute->hidden, $command->isHidden());
8587
$this->assertSame($commands, $command->getCommandRunner());
8688
$this->assertSame('help [options] [--] [<command_name>]', $command->getUsages()[0]);
8789
}
8890

91+
public function testHiddenCommandReportsItself(): void
92+
{
93+
$this->assertTrue((new HiddenCommand(new Commands()))->isHidden());
94+
}
95+
8996
public function testCommandRequiresCommandAttribute(): void
9097
{
9198
$this->expectException(LogicException::class);

‎tests/system/CLI/Attributes/CommandTest.php‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@ public function testAttributeAllowsOmittedDescriptionAndGroup(): void
4242
$this->assertSame('', $command->description);
4343
$this->assertSame('', $command->group);
4444
$this->assertSame([], $command->aliases);
45+
$this->assertFalse($command->hidden);
46+
}
47+
48+
public function testAttributeExposesHidden(): void
49+
{
50+
$this->assertTrue((new Command(name: 'app:about', hidden: true))->hidden);
4551
}
4652

4753
public function testAttributeExposesAliases(): void

‎tests/system/CLI/CommandsTest.php‎

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
use Tests\Support\Commands\Modern\AppAboutCommand;
3838
use Tests\Support\Duplicates\DuplicateLegacy;
3939
use Tests\Support\Duplicates\DuplicateModern;
40+
use Tests\Support\Duplicates\HiddenDuplicateModern;
4041
use Tests\Support\InvalidCommands\AliasClashCommand;
4142
use Tests\Support\InvalidCommands\AliasSecondClashCommand;
4243
use Tests\Support\InvalidCommands\AliasTargetCommand;
@@ -349,6 +350,131 @@ public function testRunCommandViaAlias(): void
349350
$this->assertStringContainsString('Ran fixture:aliased.', $this->getStreamFilterBuffer());
350351
}
351352

353+
public function testHiddenCommandIsRegisteredWithItsFlag(): void
354+
{
355+
$commands = (new Commands())->getModernCommands();
356+
357+
$this->assertTrue($commands['fixture:hidden']['hidden']);
358+
$this->assertFalse($commands['fixture:aliased']['hidden']);
359+
}
360+
361+
public function testIsHiddenCommand(): void
362+
{
363+
$commands = new Commands();
364+
365+
$this->assertTrue($commands->isHiddenCommand('fixture:hidden'));
366+
$this->assertTrue($commands->isHiddenCommand('fixture:secret'));
367+
$this->assertFalse($commands->isHiddenCommand('fixture:aliased'));
368+
$this->assertFalse($commands->isHiddenCommand('fixture:alias'));
369+
$this->assertFalse($commands->isHiddenCommand('app:info'));
370+
$this->assertFalse($commands->isHiddenCommand('app:unknown'));
371+
}
372+
373+
public function testIsHiddenCommandIsFalseWhenLegacyCommandShadowsIt(): void
374+
{
375+
$this->injectFixtureLocator([
376+
DuplicateLegacy::class => SUPPORTPATH . 'Duplicates/DuplicateLegacy.php',
377+
HiddenDuplicateModern::class => SUPPORTPATH . 'Duplicates/HiddenDuplicateModern.php',
378+
]);
379+
380+
$this->assertFalse((new Commands())->isHiddenCommand('dup:test'));
381+
}
382+
383+
public function testHiddenCommandRunsByName(): void
384+
{
385+
command('fixture:hidden');
386+
387+
$this->assertSame(
388+
<<<'EOT'
389+
390+
Ran fixture:hidden.
391+
392+
EOT,
393+
$this->getUndecoratedBuffer(),
394+
);
395+
}
396+
397+
public function testHiddenCommandRunsByAlias(): void
398+
{
399+
command('fixture:secret');
400+
401+
$this->assertSame(
402+
<<<'EOT'
403+
404+
Ran fixture:hidden.
405+
406+
EOT,
407+
$this->getUndecoratedBuffer(),
408+
);
409+
}
410+
411+
public function testHiddenCommandRunsThroughModernCall(): void
412+
{
413+
$commands = new Commands();
414+
$call = $this->getPrivateMethodInvoker(new AliasedCommand($commands), 'call');
415+
416+
$this->assertSame(EXIT_SUCCESS, $call('fixture:hidden'));
417+
$this->assertSame(EXIT_SUCCESS, $call('fixture:secret'));
418+
$this->assertSame(
419+
<<<'EOT'
420+
421+
Ran fixture:hidden.
422+
Ran fixture:hidden.
423+
424+
EOT,
425+
$this->getUndecoratedBuffer(),
426+
);
427+
}
428+
429+
public function testHiddenCommandRunsThroughLegacyCall(): void
430+
{
431+
$commands = new Commands();
432+
$call = $this->getPrivateMethodInvoker(new AppInfo(service('logger'), $commands), 'call');
433+
434+
$this->assertSame(EXIT_SUCCESS, $call('fixture:hidden'));
435+
$this->assertSame(EXIT_SUCCESS, $call('fixture:secret'));
436+
$this->assertSame(
437+
<<<'EOT'
438+
439+
Ran fixture:hidden.
440+
Ran fixture:hidden.
441+
442+
EOT,
443+
$this->getUndecoratedBuffer(),
444+
);
445+
}
446+
447+
public function testHiddenCommandAndItsAliasesAreNotSuggested(): void
448+
{
449+
$this->assertSame(['fixture:alias', 'fixture:aliased'], (new Commands())->getCommandAlternatives('fixture:'));
450+
}
451+
452+
public function testMistypedHiddenCommandIsReportedWithoutSuggestion(): void
453+
{
454+
$commands = new Commands();
455+
456+
$this->assertSame([], $commands->getCommandAlternatives('fixture:secre'));
457+
$this->assertSame(EXIT_ERROR, $commands->runCommand('fixture:hiddenn', [], []));
458+
$this->assertSame(
459+
<<<'EOT'
460+
461+
Command "fixture:hiddenn" not found.
462+
463+
EOT,
464+
$this->getUndecoratedBuffer(),
465+
);
466+
}
467+
468+
public function testLegacyCommandShadowingHiddenModernCommandIsStillSuggested(): void
469+
{
470+
$this->injectFixtureLocator([
471+
DuplicateLegacy::class => SUPPORTPATH . 'Duplicates/DuplicateLegacy.php',
472+
HiddenDuplicateModern::class => SUPPORTPATH . 'Duplicates/HiddenDuplicateModern.php',
473+
]);
474+
475+
$this->assertSame(['dup:test'], (new Commands())->getCommandAlternatives('dup:tes'));
476+
}
477+
352478
public function testAliasClashingWithCommandNameFailsHard(): void
353479
{
354480
$this->injectFixtureLocator([

0 commit comments

Comments
 (0)