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
24 changes: 24 additions & 0 deletions system/CLI/AbstractCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,30 @@ protected function call(string $command, array $arguments = [], array $options =
return $this->commands->runCommand($command, $arguments, $this->resolveChildInteractiveState($options, $noInteractionOverride));
}

/**
* Like `call()`, but suppresses the sub-command's output.
*
* @param list<string> $arguments Parsed arguments from command line.
* @param array<string, list<string>|string|null> $options Parsed options from command line.
* @param bool|null $noInteractionOverride See `call()` for the semantics.
*/
protected function callSilently(string $command, array $arguments = [], array $options = [], ?bool $noInteractionOverride = true): int
{
$priorInputOutput = CLI::getInputOutput();
$priorLastWrite = CLI::getLastWrite();

CLI::setInputOutput(new NullInputOutput());

try {
return $this->call($command, $arguments, $options, $noInteractionOverride);
} finally {
$priorInputOutput instanceof InputOutput
? CLI::setInputOutput($priorInputOutput)
: CLI::resetInputOutput();
CLI::setLastWrite($priorLastWrite);
}
}
Comment thread
michalsn marked this conversation as resolved.

/**
* Gets the unbound arguments that can be passed to other commands when called via the `call()` method.
*
Expand Down
28 changes: 24 additions & 4 deletions system/CLI/CLI.php
Original file line number Diff line number Diff line change
Expand Up @@ -1166,8 +1166,30 @@ public static function resetLastWrite(): void
}

/**
* Testing purpose only
*
* @internal
*/
public static function getLastWrite(): ?string
{
return static::$lastWrite;
}

/**
* @internal
*/
public static function setLastWrite(?string $value): void
{
static::$lastWrite = $value;
}

/**
* @internal
*/
public static function getInputOutput(): ?InputOutput
{
return static::$io;
}

/**
* @internal
*/
public static function setInputOutput(InputOutput $io): void
Expand All @@ -1176,8 +1198,6 @@ public static function setInputOutput(InputOutput $io): void
}

/**
* Testing purpose only
*
* @internal
*/
public static function resetInputOutput(): void
Expand Down
29 changes: 29 additions & 0 deletions system/CLI/NullInputOutput.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?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 CodeIgniter\CLI;

/**
* An InputOutput sink that discards all output and never reads input.
*/
final class NullInputOutput extends InputOutput
{
public function fwrite($handle, string $string): void
{
}

public function input(?string $prefix = null): string
{
return '';
}
}
10 changes: 10 additions & 0 deletions tests/_support/Commands/Modern/AppAboutCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,16 @@ public function helpMe(): int
return $this->call('help');
}

public function helpMeSilently(): int
{
return $this->callSilently('help');
}

public function callUnknownSilently(): int
{
return $this->callSilently('does:not:exist');
}

/**
* @param array<string, list<string|null>|string|null>|null $options
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,14 @@ final class ParentCallsInteractFixtureCommand extends AbstractCommand
*/
public array $childOptions = [];

public bool $useCallSilently = false;

protected function execute(array $arguments, array $options): int
{
if ($this->useCallSilently) {
return $this->callSilently('test:probe', options: $this->childOptions, noInteractionOverride: $this->childNoInteractionOverride);
}

return $this->call('test:probe', options: $this->childOptions, noInteractionOverride: $this->childNoInteractionOverride);
}
}
69 changes: 69 additions & 0 deletions tests/system/CLI/AbstractCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Group;
use ReflectionClass;
use ReflectionProperty;
use Tests\Support\Commands\Modern\AppAboutCommand;
use Tests\Support\Commands\Modern\InteractFixtureCommand;
use Tests\Support\Commands\Modern\InteractiveStateProbeCommand;
Expand Down Expand Up @@ -257,6 +258,74 @@ public function testCommandCanCallAnotherCommand(): void
$this->assertStringContainsString('help [options] [--] [<command_name>]', $this->getStreamFilterBuffer());
}

public function testCallSilentlySuppressesSubCommandOutputAndReturnsExitCode(): void
{
$command = new AppAboutCommand(new Commands());

$this->assertSame(EXIT_SUCCESS, $command->helpMeSilently());
$this->assertSame('', $this->getStreamFilterBuffer());
}

public function testCallSilentlyRestoresPriorIo(): void
{
$custom = new InputOutput();
CLI::setInputOutput($custom);

$command = new AppAboutCommand(new Commands());
$command->helpMeSilently();

$this->assertSame($custom, CLI::getInputOutput());
}

public function testCallSilentlyResetsToFreshInputOutputWhenPriorWasNull(): void
{
$property = new ReflectionProperty(CLI::class, 'io');
$property->setValue(null, null);

$command = new AppAboutCommand(new Commands());
$command->helpMeSilently();

$current = CLI::getInputOutput();
$this->assertInstanceOf(InputOutput::class, $current);
$this->assertNotInstanceOf(NullInputOutput::class, $current);
}

public function testCallSilentlyPropagatesSubCommandNonZeroExitCode(): void
{
$command = new AppAboutCommand(new Commands());

$this->assertSame(EXIT_ERROR, $command->callUnknownSilently());
$this->assertSame('', $this->getStreamFilterBuffer());
}

public function testCallSilentlyRestoresPriorLastWriteState(): void
{
CLI::setLastWrite(null);

$command = new AppAboutCommand(new Commands());
$command->helpMeSilently();

$this->assertNull(
CLI::getLastWrite(),
'callSilently() must not leak the silenced sub-command\'s $lastWrite mutation back to the parent.',
);
}

public function testCallSilentlyForwardsNoInteractionOverrideFalseToChild(): void
{
$command = new ParentCallsInteractFixtureCommand(new Commands());
$command->setInteractive(false);
$command->useCallSilently = true;
$command->childNoInteractionOverride = false;

$exitCode = $command->run([], []);

$this->assertSame(EXIT_SUCCESS, $exitCode);
$this->assertFalse($command->isInteractive());
$this->assertTrue(InteractiveStateProbeCommand::$interactCalled);
$this->assertTrue(InteractiveStateProbeCommand::$observedInteractive);
}

public function testRunCommand(): void
{
command('app:about a');
Expand Down
42 changes: 42 additions & 0 deletions tests/system/CLI/CLITest.php
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,48 @@ public function testWriteBackground(): void
$this->assertSame($expected, $this->getStreamFilterBuffer());
}

public function testGetLastWriteReturnsNullAfterReset(): void
{
CLI::resetLastWrite();

$this->assertNull(CLI::getLastWrite());
}

public function testGetLastWriteReflectsPriorWrite(): void
{
CLI::resetLastWrite();
CLI::write('hello');

$this->assertSame('write', CLI::getLastWrite());
}

public function testGetLastWriteReflectsPriorPrint(): void
{
CLI::resetLastWrite();
CLI::write('hello');
CLI::print('world');

$this->assertNull(CLI::getLastWrite());
}

public function testSetLastWriteRoundTrips(): void
{
CLI::setLastWrite('write');
$this->assertSame('write', CLI::getLastWrite());

CLI::setLastWrite(null);
$this->assertNull(CLI::getLastWrite());
}

public function testSetLastWriteSuppressesLeadingNewlineOnNextWrite(): void
{
CLI::setLastWrite('write');

CLI::write('hello');

$this->assertSame('hello' . PHP_EOL, $this->getStreamFilterBuffer());
}

public function testError(): void
{
CLI::error('test');
Expand Down
63 changes: 63 additions & 0 deletions tests/system/CLI/NullInputOutputTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?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 CodeIgniter\CLI;

use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\StreamFilterTrait;
use PHPUnit\Framework\Attributes\Group;

/**
* @internal
*/
#[Group('Others')]
final class NullInputOutputTest extends CIUnitTestCase
{
use StreamFilterTrait;

public function testFwriteDiscardsOutput(): void
{
$io = new NullInputOutput();
$io->fwrite(STDOUT, 'should not appear');
$io->fwrite(STDERR, 'should not appear either');

$this->assertSame('', $this->getStreamFilterBuffer());
}

public function testInputReturnsEmptyStringWithoutEchoingPrefix(): void
{
$io = new NullInputOutput();

$this->assertSame('', $io->input());
$this->assertSame('', $io->input('any prefix > '));
$this->assertSame('', $this->getStreamFilterBuffer());
}

public function testCanBeSwappedIntoCliToSilenceWrites(): void
{
$prior = CLI::getInputOutput();
CLI::setInputOutput(new NullInputOutput());

try {
CLI::write('this should be discarded');
CLI::error('this too');
$this->assertSame('', $this->getStreamFilterBuffer());
} finally {
if ($prior instanceof InputOutput) {
CLI::setInputOutput($prior);
} else {
CLI::resetInputOutput();
}
}
}
}
2 changes: 2 additions & 0 deletions user_guide_src/source/changelogs/v4.8.0.rst
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,8 @@ Commands
When used with ``CLI::getOption()``, an array option will return its last value (for example, in this case, ``value2``). To retrieve all values for an array option, use ``CLI::getRawOption()``.
- Likewise, the ``command()`` function now also supports the above enhancements for command-line option parsing when using the function to run commands from code.
- Added ``make:request`` generator command to scaffold :ref:`Form Request <form-requests>` classes.
- Added ``AbstractCommand::callSilently()`` to invoke another command with its output discarded, restoring the prior IO afterwards. See :ref:`modern-commands-call-silently`.
- Added :php:class:`NullInputOutput <CodeIgniter\\CLI\\NullInputOutput>`, an :php:class:`InputOutput <CodeIgniter\\CLI\\InputOutput>` sink that discards all writes and returns an empty string from ``input()``.

Testing
=======
Expand Down
25 changes: 25 additions & 0 deletions user_guide_src/source/cli/cli_modern_commands.rst
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,21 @@ To forward the caller's own input through to the target command, pass

.. literalinclude:: cli_modern_commands/008.php

.. _modern-commands-call-silently:

Calling Silently
================

When a command delegates a step to another command but wants to emit its own
consolidated message instead of letting the sub-command's output leak through,
use ``$this->callSilently()``:

.. literalinclude:: cli_modern_commands/012.php

The sub-command's output is suppressed and ``$noInteractionOverride`` defaults
to ``true``, since a silenced sub-command cannot meaningfully prompt. Pass an
explicit value to override.

**************
Usage Examples
**************
Expand Down Expand Up @@ -546,6 +561,16 @@ covered in the sections above and are not listed here.
Invokes another modern command. The arguments and options go through
bind and validate on the target command, just like a user invocation.

.. php:method:: callSilently(string $command[, array $arguments = [], array $options = [], ?bool $noInteractionOverride = true]): int

:param string $command: The name of the modern command to call.
:param array $arguments: Positional arguments to forward.
:param array $options: Options to forward, keyed by long name, shortcut, or negation.
:param bool|null $noInteractionOverride: See :php:meth:`call`. Defaults to ``true``.
:returns: The exit code returned by the called command.

Like :php:meth:`call`, but suppresses the sub-command's output.

.. php:method:: getUnboundArguments(): array

Returns the raw, parsed positional arguments as passed to the
Expand Down
12 changes: 12 additions & 0 deletions user_guide_src/source/cli/cli_modern_commands/012.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

use CodeIgniter\CLI\CLI;

// Inside execute():

// Run `cache:clear` without leaking its own output; emit our own message instead.
$exitCode = $this->callSilently('cache:clear');

if ($exitCode === EXIT_SUCCESS) {
CLI::write('Cache cleared as part of deploy step.', 'green');
}
Loading