Skip to content

Commit 31643c1

Browse files
authored
feat: add AbstractCommand::callSilently() (#10177)
1 parent bfe0edb commit 31643c1

11 files changed

Lines changed: 306 additions & 4 deletions

File tree

system/CLI/AbstractCommand.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,30 @@ protected function call(string $command, array $arguments = [], array $options =
504504
return $this->commands->runCommand($command, $arguments, $this->resolveChildInteractiveState($options, $noInteractionOverride));
505505
}
506506

507+
/**
508+
* Like `call()`, but suppresses the sub-command's output.
509+
*
510+
* @param list<string> $arguments Parsed arguments from command line.
511+
* @param array<string, list<string>|string|null> $options Parsed options from command line.
512+
* @param bool|null $noInteractionOverride See `call()` for the semantics.
513+
*/
514+
protected function callSilently(string $command, array $arguments = [], array $options = [], ?bool $noInteractionOverride = true): int
515+
{
516+
$priorInputOutput = CLI::getInputOutput();
517+
$priorLastWrite = CLI::getLastWrite();
518+
519+
CLI::setInputOutput(new NullInputOutput());
520+
521+
try {
522+
return $this->call($command, $arguments, $options, $noInteractionOverride);
523+
} finally {
524+
$priorInputOutput instanceof InputOutput
525+
? CLI::setInputOutput($priorInputOutput)
526+
: CLI::resetInputOutput();
527+
CLI::setLastWrite($priorLastWrite);
528+
}
529+
}
530+
507531
/**
508532
* Gets the unbound arguments that can be passed to other commands when called via the `call()` method.
509533
*

system/CLI/CLI.php

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1166,8 +1166,30 @@ public static function resetLastWrite(): void
11661166
}
11671167

11681168
/**
1169-
* Testing purpose only
1170-
*
1169+
* @internal
1170+
*/
1171+
public static function getLastWrite(): ?string
1172+
{
1173+
return static::$lastWrite;
1174+
}
1175+
1176+
/**
1177+
* @internal
1178+
*/
1179+
public static function setLastWrite(?string $value): void
1180+
{
1181+
static::$lastWrite = $value;
1182+
}
1183+
1184+
/**
1185+
* @internal
1186+
*/
1187+
public static function getInputOutput(): ?InputOutput
1188+
{
1189+
return static::$io;
1190+
}
1191+
1192+
/**
11711193
* @internal
11721194
*/
11731195
public static function setInputOutput(InputOutput $io): void
@@ -1176,8 +1198,6 @@ public static function setInputOutput(InputOutput $io): void
11761198
}
11771199

11781200
/**
1179-
* Testing purpose only
1180-
*
11811201
* @internal
11821202
*/
11831203
public static function resetInputOutput(): void

system/CLI/NullInputOutput.php

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
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 CodeIgniter\CLI;
15+
16+
/**
17+
* An InputOutput sink that discards all output and never reads input.
18+
*/
19+
final class NullInputOutput extends InputOutput
20+
{
21+
public function fwrite($handle, string $string): void
22+
{
23+
}
24+
25+
public function input(?string $prefix = null): string
26+
{
27+
return '';
28+
}
29+
}

tests/_support/Commands/Modern/AppAboutCommand.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,16 @@ public function helpMe(): int
6262
return $this->call('help');
6363
}
6464

65+
public function helpMeSilently(): int
66+
{
67+
return $this->callSilently('help');
68+
}
69+
70+
public function callUnknownSilently(): int
71+
{
72+
return $this->callSilently('does:not:exist');
73+
}
74+
6575
/**
6676
* @param array<string, list<string|null>|string|null>|null $options
6777
*/

tests/_support/Commands/Modern/ParentCallsInteractFixtureCommand.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,14 @@ final class ParentCallsInteractFixtureCommand extends AbstractCommand
3333
*/
3434
public array $childOptions = [];
3535

36+
public bool $useCallSilently = false;
37+
3638
protected function execute(array $arguments, array $options): int
3739
{
40+
if ($this->useCallSilently) {
41+
return $this->callSilently('test:probe', options: $this->childOptions, noInteractionOverride: $this->childNoInteractionOverride);
42+
}
43+
3844
return $this->call('test:probe', options: $this->childOptions, noInteractionOverride: $this->childNoInteractionOverride);
3945
}
4046
}

tests/system/CLI/AbstractCommandTest.php

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
use PHPUnit\Framework\Attributes\DataProvider;
3535
use PHPUnit\Framework\Attributes\Group;
3636
use ReflectionClass;
37+
use ReflectionProperty;
3738
use Tests\Support\Commands\Modern\AppAboutCommand;
3839
use Tests\Support\Commands\Modern\InteractFixtureCommand;
3940
use Tests\Support\Commands\Modern\InteractiveStateProbeCommand;
@@ -257,6 +258,74 @@ public function testCommandCanCallAnotherCommand(): void
257258
$this->assertStringContainsString('help [options] [--] [<command_name>]', $this->getStreamFilterBuffer());
258259
}
259260

261+
public function testCallSilentlySuppressesSubCommandOutputAndReturnsExitCode(): void
262+
{
263+
$command = new AppAboutCommand(new Commands());
264+
265+
$this->assertSame(EXIT_SUCCESS, $command->helpMeSilently());
266+
$this->assertSame('', $this->getStreamFilterBuffer());
267+
}
268+
269+
public function testCallSilentlyRestoresPriorIo(): void
270+
{
271+
$custom = new InputOutput();
272+
CLI::setInputOutput($custom);
273+
274+
$command = new AppAboutCommand(new Commands());
275+
$command->helpMeSilently();
276+
277+
$this->assertSame($custom, CLI::getInputOutput());
278+
}
279+
280+
public function testCallSilentlyResetsToFreshInputOutputWhenPriorWasNull(): void
281+
{
282+
$property = new ReflectionProperty(CLI::class, 'io');
283+
$property->setValue(null, null);
284+
285+
$command = new AppAboutCommand(new Commands());
286+
$command->helpMeSilently();
287+
288+
$current = CLI::getInputOutput();
289+
$this->assertInstanceOf(InputOutput::class, $current);
290+
$this->assertNotInstanceOf(NullInputOutput::class, $current);
291+
}
292+
293+
public function testCallSilentlyPropagatesSubCommandNonZeroExitCode(): void
294+
{
295+
$command = new AppAboutCommand(new Commands());
296+
297+
$this->assertSame(EXIT_ERROR, $command->callUnknownSilently());
298+
$this->assertSame('', $this->getStreamFilterBuffer());
299+
}
300+
301+
public function testCallSilentlyRestoresPriorLastWriteState(): void
302+
{
303+
CLI::setLastWrite(null);
304+
305+
$command = new AppAboutCommand(new Commands());
306+
$command->helpMeSilently();
307+
308+
$this->assertNull(
309+
CLI::getLastWrite(),
310+
'callSilently() must not leak the silenced sub-command\'s $lastWrite mutation back to the parent.',
311+
);
312+
}
313+
314+
public function testCallSilentlyForwardsNoInteractionOverrideFalseToChild(): void
315+
{
316+
$command = new ParentCallsInteractFixtureCommand(new Commands());
317+
$command->setInteractive(false);
318+
$command->useCallSilently = true;
319+
$command->childNoInteractionOverride = false;
320+
321+
$exitCode = $command->run([], []);
322+
323+
$this->assertSame(EXIT_SUCCESS, $exitCode);
324+
$this->assertFalse($command->isInteractive());
325+
$this->assertTrue(InteractiveStateProbeCommand::$interactCalled);
326+
$this->assertTrue(InteractiveStateProbeCommand::$observedInteractive);
327+
}
328+
260329
public function testRunCommand(): void
261330
{
262331
command('app:about a');

tests/system/CLI/CLITest.php

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,48 @@ public function testWriteBackground(): void
391391
$this->assertSame($expected, $this->getStreamFilterBuffer());
392392
}
393393

394+
public function testGetLastWriteReturnsNullAfterReset(): void
395+
{
396+
CLI::resetLastWrite();
397+
398+
$this->assertNull(CLI::getLastWrite());
399+
}
400+
401+
public function testGetLastWriteReflectsPriorWrite(): void
402+
{
403+
CLI::resetLastWrite();
404+
CLI::write('hello');
405+
406+
$this->assertSame('write', CLI::getLastWrite());
407+
}
408+
409+
public function testGetLastWriteReflectsPriorPrint(): void
410+
{
411+
CLI::resetLastWrite();
412+
CLI::write('hello');
413+
CLI::print('world');
414+
415+
$this->assertNull(CLI::getLastWrite());
416+
}
417+
418+
public function testSetLastWriteRoundTrips(): void
419+
{
420+
CLI::setLastWrite('write');
421+
$this->assertSame('write', CLI::getLastWrite());
422+
423+
CLI::setLastWrite(null);
424+
$this->assertNull(CLI::getLastWrite());
425+
}
426+
427+
public function testSetLastWriteSuppressesLeadingNewlineOnNextWrite(): void
428+
{
429+
CLI::setLastWrite('write');
430+
431+
CLI::write('hello');
432+
433+
$this->assertSame('hello' . PHP_EOL, $this->getStreamFilterBuffer());
434+
}
435+
394436
public function testError(): void
395437
{
396438
CLI::error('test');
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
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 CodeIgniter\CLI;
15+
16+
use CodeIgniter\Test\CIUnitTestCase;
17+
use CodeIgniter\Test\StreamFilterTrait;
18+
use PHPUnit\Framework\Attributes\Group;
19+
20+
/**
21+
* @internal
22+
*/
23+
#[Group('Others')]
24+
final class NullInputOutputTest extends CIUnitTestCase
25+
{
26+
use StreamFilterTrait;
27+
28+
public function testFwriteDiscardsOutput(): void
29+
{
30+
$io = new NullInputOutput();
31+
$io->fwrite(STDOUT, 'should not appear');
32+
$io->fwrite(STDERR, 'should not appear either');
33+
34+
$this->assertSame('', $this->getStreamFilterBuffer());
35+
}
36+
37+
public function testInputReturnsEmptyStringWithoutEchoingPrefix(): void
38+
{
39+
$io = new NullInputOutput();
40+
41+
$this->assertSame('', $io->input());
42+
$this->assertSame('', $io->input('any prefix > '));
43+
$this->assertSame('', $this->getStreamFilterBuffer());
44+
}
45+
46+
public function testCanBeSwappedIntoCliToSilenceWrites(): void
47+
{
48+
$prior = CLI::getInputOutput();
49+
CLI::setInputOutput(new NullInputOutput());
50+
51+
try {
52+
CLI::write('this should be discarded');
53+
CLI::error('this too');
54+
$this->assertSame('', $this->getStreamFilterBuffer());
55+
} finally {
56+
if ($prior instanceof InputOutput) {
57+
CLI::setInputOutput($prior);
58+
} else {
59+
CLI::resetInputOutput();
60+
}
61+
}
62+
}
63+
}

user_guide_src/source/changelogs/v4.8.0.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,8 @@ Commands
196196
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()``.
197197
- Likewise, the ``command()`` function now also supports the above enhancements for command-line option parsing when using the function to run commands from code.
198198
- Added ``make:request`` generator command to scaffold :ref:`Form Request <form-requests>` classes.
199+
- Added ``AbstractCommand::callSilently()`` to invoke another command with its output discarded, restoring the prior IO afterwards. See :ref:`modern-commands-call-silently`.
200+
- 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()``.
199201

200202
Testing
201203
=======

user_guide_src/source/cli/cli_modern_commands.rst

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,21 @@ To forward the caller's own input through to the target command, pass
273273

274274
.. literalinclude:: cli_modern_commands/008.php
275275

276+
.. _modern-commands-call-silently:
277+
278+
Calling Silently
279+
================
280+
281+
When a command delegates a step to another command but wants to emit its own
282+
consolidated message instead of letting the sub-command's output leak through,
283+
use ``$this->callSilently()``:
284+
285+
.. literalinclude:: cli_modern_commands/012.php
286+
287+
The sub-command's output is suppressed and ``$noInteractionOverride`` defaults
288+
to ``true``, since a silenced sub-command cannot meaningfully prompt. Pass an
289+
explicit value to override.
290+
276291
**************
277292
Usage Examples
278293
**************
@@ -546,6 +561,16 @@ covered in the sections above and are not listed here.
546561
Invokes another modern command. The arguments and options go through
547562
bind and validate on the target command, just like a user invocation.
548563

564+
.. php:method:: callSilently(string $command[, array $arguments = [], array $options = [], ?bool $noInteractionOverride = true]): int
565+
566+
:param string $command: The name of the modern command to call.
567+
:param array $arguments: Positional arguments to forward.
568+
:param array $options: Options to forward, keyed by long name, shortcut, or negation.
569+
:param bool|null $noInteractionOverride: See :php:meth:`call`. Defaults to ``true``.
570+
:returns: The exit code returned by the called command.
571+
572+
Like :php:meth:`call`, but suppresses the sub-command's output.
573+
549574
.. php:method:: getUnboundArguments(): array
550575
551576
Returns the raw, parsed positional arguments as passed to the

0 commit comments

Comments
 (0)