From f0870742bd6e5d440631160d73697cc3a86f053a Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Sun, 10 May 2026 03:01:33 +0800 Subject: [PATCH] feat: add `AbstractCommand::callSilently()` --- system/CLI/AbstractCommand.php | 24 +++++++ system/CLI/CLI.php | 28 ++++++-- system/CLI/NullInputOutput.php | 29 ++++++++ .../Commands/Modern/AppAboutCommand.php | 10 +++ .../ParentCallsInteractFixtureCommand.php | 6 ++ tests/system/CLI/AbstractCommandTest.php | 69 +++++++++++++++++++ tests/system/CLI/CLITest.php | 42 +++++++++++ tests/system/CLI/NullInputOutputTest.php | 63 +++++++++++++++++ user_guide_src/source/changelogs/v4.8.0.rst | 2 + .../source/cli/cli_modern_commands.rst | 25 +++++++ .../source/cli/cli_modern_commands/012.php | 12 ++++ 11 files changed, 306 insertions(+), 4 deletions(-) create mode 100644 system/CLI/NullInputOutput.php create mode 100644 tests/system/CLI/NullInputOutputTest.php create mode 100644 user_guide_src/source/cli/cli_modern_commands/012.php diff --git a/system/CLI/AbstractCommand.php b/system/CLI/AbstractCommand.php index e92512e45060..46a4d9982786 100644 --- a/system/CLI/AbstractCommand.php +++ b/system/CLI/AbstractCommand.php @@ -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 $arguments Parsed arguments from command line. + * @param array|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); + } + } + /** * Gets the unbound arguments that can be passed to other commands when called via the `call()` method. * diff --git a/system/CLI/CLI.php b/system/CLI/CLI.php index af8b0ce84e94..6b72feabe6af 100644 --- a/system/CLI/CLI.php +++ b/system/CLI/CLI.php @@ -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 @@ -1176,8 +1198,6 @@ public static function setInputOutput(InputOutput $io): void } /** - * Testing purpose only - * * @internal */ public static function resetInputOutput(): void diff --git a/system/CLI/NullInputOutput.php b/system/CLI/NullInputOutput.php new file mode 100644 index 000000000000..1686f564adae --- /dev/null +++ b/system/CLI/NullInputOutput.php @@ -0,0 +1,29 @@ + + * + * 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 ''; + } +} diff --git a/tests/_support/Commands/Modern/AppAboutCommand.php b/tests/_support/Commands/Modern/AppAboutCommand.php index ca53fe9d35c4..7603f367ebc4 100644 --- a/tests/_support/Commands/Modern/AppAboutCommand.php +++ b/tests/_support/Commands/Modern/AppAboutCommand.php @@ -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|null>|null $options */ diff --git a/tests/_support/Commands/Modern/ParentCallsInteractFixtureCommand.php b/tests/_support/Commands/Modern/ParentCallsInteractFixtureCommand.php index cbe7f0285338..ff182300a0e9 100644 --- a/tests/_support/Commands/Modern/ParentCallsInteractFixtureCommand.php +++ b/tests/_support/Commands/Modern/ParentCallsInteractFixtureCommand.php @@ -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); } } diff --git a/tests/system/CLI/AbstractCommandTest.php b/tests/system/CLI/AbstractCommandTest.php index d3afb3558934..97cc90311754 100644 --- a/tests/system/CLI/AbstractCommandTest.php +++ b/tests/system/CLI/AbstractCommandTest.php @@ -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; @@ -257,6 +258,74 @@ public function testCommandCanCallAnotherCommand(): void $this->assertStringContainsString('help [options] [--] []', $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'); diff --git a/tests/system/CLI/CLITest.php b/tests/system/CLI/CLITest.php index 77d1a545ca8c..f5c9f434acdf 100644 --- a/tests/system/CLI/CLITest.php +++ b/tests/system/CLI/CLITest.php @@ -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'); diff --git a/tests/system/CLI/NullInputOutputTest.php b/tests/system/CLI/NullInputOutputTest.php new file mode 100644 index 000000000000..4008bc7bf6ff --- /dev/null +++ b/tests/system/CLI/NullInputOutputTest.php @@ -0,0 +1,63 @@ + + * + * 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(); + } + } + } +} diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index e9c3f388edae..756dc5620860 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -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 ` 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 `, an :php:class:`InputOutput ` sink that discards all writes and returns an empty string from ``input()``. Testing ======= diff --git a/user_guide_src/source/cli/cli_modern_commands.rst b/user_guide_src/source/cli/cli_modern_commands.rst index 7df061cd9969..c9d805642149 100644 --- a/user_guide_src/source/cli/cli_modern_commands.rst +++ b/user_guide_src/source/cli/cli_modern_commands.rst @@ -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 ************** @@ -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 diff --git a/user_guide_src/source/cli/cli_modern_commands/012.php b/user_guide_src/source/cli/cli_modern_commands/012.php new file mode 100644 index 000000000000..0963ccccc08c --- /dev/null +++ b/user_guide_src/source/cli/cli_modern_commands/012.php @@ -0,0 +1,12 @@ +callSilently('cache:clear'); + +if ($exitCode === EXIT_SUCCESS) { + CLI::write('Cache cleared as part of deploy step.', 'green'); +}