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

private readonly bool $hidden;
private readonly bool $headerless;

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

$this->configure();
$this->provideDefaultOptions();
Expand Down Expand Up @@ -185,6 +187,11 @@ public function isHidden(): bool
return $this->hidden;
}

public function isHeaderless(): bool
{
return $this->headerless;
}

/**
* @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 @@ -45,6 +45,7 @@ public function __construct(
public string $group = '',
array $aliases = [],
public bool $hidden = false,
public bool $headerless = false,
) {
if ($name === '') {
throw new LogicException(lang('Commands.emptyCommandName'));
Expand Down
19 changes: 17 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>, hidden: bool}>
* @phpstan-type modern_commands array<string, array{class: class-string<AbstractCommand>, file: string, group: string, description: string, aliases: list<string>, hidden: bool, headerless: bool}>
*/
class Commands
{
Expand Down Expand Up @@ -214,6 +214,20 @@ public function isHiddenCommand(string $name): bool
return $resolved !== null && $this->modernCommands[$resolved]['hidden'];
}

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

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

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

/**
* @return ($legacy is true ? BaseCommand : AbstractCommand)
*
Expand Down Expand Up @@ -295,7 +309,7 @@ public function discoverCommands()
ksort($this->modernCommands);

foreach (array_keys(array_intersect_key($this->commands, $this->modernCommands)) as $name) {
CLI::write(
CLI::error(
Comment thread
paulbalandan marked this conversation as resolved.
CLI::wrap(
lang('Commands.duplicateCommandName', [
$name,
Expand Down Expand Up @@ -481,6 +495,7 @@ private function registerModernCommand(ReflectionClass $class, string $file): vo
'description' => $attribute->description,
'aliases' => $attribute->aliases,
'hidden' => $attribute->hidden,
'headerless' => $attribute->headerless,
];
}
}
5 changes: 3 additions & 2 deletions system/CLI/Console.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,7 @@ public function run(array $tokens = [])

$arguments = $parser->getArguments();
$this->options = $parser->getOptions();

$this->showHeader($this->hasParameterOption(['no-header']));
$noHeader = $this->hasParameterOption(['no-header']);

if ($this->hasParameterOption(['help', 'h'])) {
if ($arguments === []) {
Expand All @@ -69,6 +68,8 @@ public function run(array $tokens = [])

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

$this->showHeader($noHeader || $commands->isHeaderlessCommand($this->command));

if (
$this->isInteractive()
&& ! $commands->hasLegacyCommand($this->command)
Expand Down
35 changes: 35 additions & 0 deletions tests/_support/Commands/Modern/HeaderlessCommand.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: 'test:headerless',
description: 'Fixture command exercising commands without the header.',
group: 'Fixtures',
aliases: ['test:quiet'],
headerless: true,
)]
final class HeaderlessCommand extends AbstractCommand
{
protected function execute(array $arguments, array $options): int
{
CLI::write('Ran test:headerless.');

return EXIT_SUCCESS;
}
}
36 changes: 36 additions & 0 deletions tests/_support/Duplicates/HeaderlessDuplicateModern.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;

/**
* Headerless modern fixture shadowed by the legacy command of the same name.
*
* @internal
*/
#[Command(
name: 'dup:test',
description: 'Headerless modern fixture that collides with a legacy command of the same name.',
group: 'Duplicates',
headerless: true,
)]
final class HeaderlessDuplicateModern 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\HeaderlessCommand;
use Tests\Support\Commands\Modern\HiddenCommand;
use Tests\Support\Commands\Modern\InteractFixtureCommand;
use Tests\Support\Commands\Modern\InteractiveStateProbeCommand;
Expand Down Expand Up @@ -84,6 +85,7 @@ public function testConstructorSetsNeededProperties(): void
$this->assertSame($attribute->description, $command->getDescription());
$this->assertSame($attribute->group, $command->getGroup());
$this->assertSame($attribute->hidden, $command->isHidden());
$this->assertSame($attribute->headerless, $command->isHeaderless());
$this->assertSame($commands, $command->getCommandRunner());
$this->assertSame('help [options] [--] [<command_name>]', $command->getUsages()[0]);
}
Expand All @@ -93,6 +95,11 @@ public function testHiddenCommandReportsItself(): void
$this->assertTrue((new HiddenCommand(new Commands()))->isHidden());
}

public function testHeaderlessCommandReportsItself(): void
{
$this->assertTrue((new HeaderlessCommand(new Commands()))->isHeaderless());
}

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 @@ -43,13 +43,19 @@ public function testAttributeAllowsOmittedDescriptionAndGroup(): void
$this->assertSame('', $command->group);
$this->assertSame([], $command->aliases);
$this->assertFalse($command->hidden);
$this->assertFalse($command->headerless);
}

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

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

public function testAttributeExposesAliases(): void
{
$command = new Command(name: 'app:about', aliases: ['app:ab', 'ab']);
Expand Down
34 changes: 34 additions & 0 deletions tests/system/CLI/CommandsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
use CodeIgniter\Exceptions\LogicException;
use CodeIgniter\Log\Logger;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\Filters\CITestStreamFilter;
use CodeIgniter\Test\ReflectionHelper;
use CodeIgniter\Test\StreamFilterTrait;
use Config\Services;
Expand All @@ -37,6 +38,7 @@
use Tests\Support\Commands\Modern\AppAboutCommand;
use Tests\Support\Duplicates\DuplicateLegacy;
use Tests\Support\Duplicates\DuplicateModern;
use Tests\Support\Duplicates\HeaderlessDuplicateModern;
use Tests\Support\Duplicates\HiddenDuplicateModern;
use Tests\Support\InvalidCommands\AliasClashCommand;
use Tests\Support\InvalidCommands\AliasSecondClashCommand;
Expand Down Expand Up @@ -283,6 +285,8 @@ public function testDiscoveryWarnsWhenSameCommandNameExistsInBothRegistries(): v
CLI::getWidth(),
);

CITestStreamFilter::removeOutputFilter();

$commands = new Commands();

$this->assertSame("\n{$message}\n", $this->getUndecoratedBuffer());
Expand Down Expand Up @@ -392,6 +396,36 @@ public function testIsHiddenCommandIsFalseWhenLegacyCommandShadowsIt(): void
$this->assertFalse((new Commands())->isHiddenCommand('dup:test'));
}

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

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

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

$this->assertTrue($commands->isHeaderlessCommand('test:headerless'));
$this->assertTrue($commands->isHeaderlessCommand('test:quiet'));
$this->assertFalse($commands->isHeaderlessCommand('fixture:aliased'));
$this->assertFalse($commands->isHeaderlessCommand('fixture:alias'));
$this->assertFalse($commands->isHeaderlessCommand('app:info'));
$this->assertFalse($commands->isHeaderlessCommand('app:unknown'));
}

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

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

public function testHiddenCommandRunsByName(): void
{
command('fixture:hidden');
Expand Down
65 changes: 65 additions & 0 deletions tests/system/CLI/ConsoleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
use CodeIgniter\Test\Mock\MockInputOutput;
use CodeIgniter\Test\StreamFilterTrait;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Group;

/**
Expand Down Expand Up @@ -99,6 +100,70 @@ public function testHeaderDoesNotShowOnNoHeader(): void
);
}

#[DataProvider('provideHeaderlessCommandOmitsHeader')]
public function testHeaderlessCommandOmitsHeader(string $command): void
{
$this->initializeConsole($command);
(new Console())->run();

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

Ran test:headerless.

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

/**
* @return iterable<string, array{string}>
*/
public static function provideHeaderlessCommandOmitsHeader(): iterable
{
yield 'name' => ['test:headerless'];

yield 'alias' => ['test:quiet'];
}

/**
* @param list<string> $tokens
*/
#[DataProvider('provideHelpForHeaderlessCommandShowsHeader')]
public function testHelpForHeaderlessCommandShowsHeader(array $tokens): void
{
$this->initializeConsole(...$tokens);
(new Console())->run();

$this->assertStringContainsString(
sprintf('CodeIgniter v%s Command Line Tool', CodeIgniter::CI_VERSION),
$this->getStreamFilterBuffer(),
);
}

/**
* @return iterable<string, array{list<string>}>
*/
public static function provideHelpForHeaderlessCommandShowsHeader(): iterable
{
yield 'help command' => [['help', 'test:headerless']];

yield 'help option' => [['test:headerless', '--help']];

yield 'help shortcut on alias' => [['test:quiet', '-h']];
}

public function testNoHeaderOptionAppliesAlongsideHelpOption(): void
{
$this->initializeConsole('env', '--help', '--no-header');
(new Console())->run();

$this->assertStringNotContainsString(
sprintf('CodeIgniter v%s Command Line Tool', CodeIgniter::CI_VERSION),
$this->getStreamFilterBuffer(),
);
}

public function testRun(): void
{
$this->initializeConsole();
Expand Down
3 changes: 3 additions & 0 deletions user_guide_src/source/changelogs/v4.8.0.rst
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,9 @@ Commands
- Modern commands can now be hidden with ``hidden: true`` on the ``#[Command]`` attribute. A hidden command and its aliases are left out of
``spark list`` and of the suggestions for a mistyped command name, but still run by exact name or alias and still have a ``help`` page.
``AbstractCommand::isHidden()`` and ``Commands::isHiddenCommand()`` report the flag. See :ref:`hidden-commands`.
- Modern commands can now opt out of the spark header with ``headerless: true`` on the ``#[Command]`` attribute, with the same effect as always
passing ``--no-header``. ``help <command>`` and ``<command> --help`` still print the header. ``AbstractCommand::isHeaderless()`` and
``Commands::isHeaderlessCommand()`` report the flag. See :ref:`commands-without-the-header`.
- 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(...)``.
Expand Down
Loading
Loading