From 2335454f7b90998d16f196535647425760556359 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Mon, 19 Oct 2026 20:44:25 +0800 Subject: [PATCH] feat: add `--silent`, `--quiet` and `--verbose` to `spark` --- app/Views/errors/cli/error_exception.php | 2 +- system/CLI/AbstractCommand.php | 11 +- system/CLI/Console.php | 188 ++++++++++-- system/CLI/Output.php | 86 +++++- system/CLI/Verbosity.php | 50 ++++ .../Translation/LocalizationFinder.php | 32 +- .../Commands/Modern/VerbosityProbeCommand.php | 66 +++++ tests/system/CLI/AbstractCommandTest.php | 26 +- .../CLI/AbstractGeneratorCommandTest.php | 6 +- tests/system/CLI/ConsoleTest.php | 279 ++++++++++++++++++ tests/system/CLI/OutputTest.php | 115 ++++++++ tests/system/Commands/HelpCommandTest.php | 36 +++ .../Translation/LocalizationFinderTest.php | 42 +++ user_guide_src/source/changelogs/v4.8.0.rst | 2 + user_guide_src/source/cli/cli_library.rst | 29 +- .../source/cli/cli_modern_commands.rst | 30 +- .../source/cli/cli_modern_commands/003.php | 6 +- .../source/cli/cli_modern_commands/019.php | 27 ++ user_guide_src/source/cli/spark_commands.rst | 32 ++ .../source/installation/upgrade_480.rst | 2 + 20 files changed, 998 insertions(+), 69 deletions(-) create mode 100644 system/CLI/Verbosity.php create mode 100644 tests/_support/Commands/Modern/VerbosityProbeCommand.php create mode 100644 user_guide_src/source/cli/cli_modern_commands/019.php diff --git a/app/Views/errors/cli/error_exception.php b/app/Views/errors/cli/error_exception.php index 624f3b5b0600..5621cb186185 100644 --- a/app/Views/errors/cli/error_exception.php +++ b/app/Views/errors/cli/error_exception.php @@ -21,7 +21,7 @@ } // The backtrace -if (defined('SHOW_DEBUG_BACKTRACE') && SHOW_DEBUG_BACKTRACE) { +if ((defined('SHOW_DEBUG_BACKTRACE') && SHOW_DEBUG_BACKTRACE) || CLI::getOutput()->isVerbose()) { $backtraces = $last->getTrace(); if ($backtraces) { diff --git a/system/CLI/AbstractCommand.php b/system/CLI/AbstractCommand.php index 1f7d7991410d..c5c8ed3e6e39 100644 --- a/system/CLI/AbstractCommand.php +++ b/system/CLI/AbstractCommand.php @@ -389,7 +389,7 @@ public function hasNegation(string $name): bool * Resolution order: * 1. An explicit `setInteractive()` call wins. * 2. Otherwise, the `--no-interaction` / `-N` flag from the current `run()` - * forces non-interactive. + * or a quiet output forces non-interactive. * 3. Otherwise, the command is interactive when STDIN is a TTY. * * Non-CLI contexts (e.g., a controller invoking `command()`) don't expose @@ -458,7 +458,7 @@ final public function run(array $arguments, array $options, ?Output $output = nu } // Reset per-run interactive state from the current options. - $this->runtimeInteractive = $this->hasUnboundOption('no-interaction', $options) ? false : null; + $this->runtimeInteractive = $this->hasUnboundOption('no-interaction', $options) || $this->getOutput()->isQuiet() ? false : null; $this->initialize($arguments, $options); @@ -771,6 +771,8 @@ protected function getValidatedOption(string $name): array|bool|string|null * - `--no-header`: `Console` strips it before rendering the banner. * - `--no-interaction` / `-N`: `run()` folds it into the interactive state * and `resolveChildInteractiveState()` reads it to drive the `call()` cascade. + * - `--silent`, `--quiet` / `-q` and `--verbose` / `-v`: `Console` reads them into the + * verbosity of the output. * * Subclasses that override this hook should re-register these options or * accept that the corresponding framework features will be broken for @@ -781,7 +783,10 @@ protected function provideDefaultOptions(): void $this ->addOption(new Option(name: 'help', shortcut: 'h', description: 'Display help for the given command.')) ->addOption(new Option(name: 'no-header', description: 'Do not display the banner when running the command.')) - ->addOption(new Option(name: 'no-interaction', shortcut: 'N', description: 'Do not ask any interactive questions.')); + ->addOption(new Option(name: 'no-interaction', shortcut: 'N', description: 'Do not ask any interactive questions.')) + ->addOption(new Option(name: 'silent', description: 'Do not output any message, not even errors.')) + ->addOption(new Option(name: 'quiet', shortcut: 'q', description: 'Do not output any message except errors.')) + ->addOption(new Option(name: 'verbose', shortcut: 'v', description: 'Increase the verbosity of messages (-v, -vv, or -vvv).')); } /** diff --git a/system/CLI/Console.php b/system/CLI/Console.php index 7a020e0ad03d..423e41062c93 100644 --- a/system/CLI/Console.php +++ b/system/CLI/Console.php @@ -16,6 +16,7 @@ use CodeIgniter\CodeIgniter; use Config\App; use Config\Services; +use Throwable; class Console { @@ -37,7 +38,9 @@ class Console */ public function run(array $tokens = []) { - if ($tokens === []) { + $fromArgv = $tokens === []; + + if ($fromArgv) { $tokens = service('superglobals')->server('argv', []); } @@ -45,7 +48,75 @@ public function run(array $tokens = []) $arguments = $parser->getArguments(); $this->options = $parser->getOptions(); - $noHeader = $this->hasParameterOption(['no-header']); + + $output = CLI::getOutput(); + $previousVerbosity = $output->getVerbosity(); + $previousShellVerbosity = getenv('SHELL_VERBOSITY'); + $previousEnvShellVerbosity = $_ENV['SHELL_VERBOSITY'] ?? null; + $previousServerShellVerbosity = service('superglobals')->server('SHELL_VERBOSITY'); + + $output->setVerbosity($this->resolveVerbosity()); + $this->setShellVerbosity($output->getVerbosity()->value); + + try { + $exitCode = $this->dispatch($arguments); + } catch (Throwable $e) { + // The spark run keeps the level, so the exception handler renders the uncaught throwable at it. + if (! $fromArgv) { + $output->setVerbosity($previousVerbosity); + } + + throw $e; + } finally { + $this->restoreShellVerbosity($previousShellVerbosity, $previousEnvShellVerbosity, $previousServerShellVerbosity); + } + + $output->setVerbosity($previousVerbosity); + + return $exitCode; + } + + public function initialize(): static + { + Services::createRequest(config(App::class), true); + service('routes')->loadRoutes(); + + return $this; + } + + /** + * Returns the command that is being executed. + */ + public function getCommand(): string + { + return $this->command; + } + + /** + * Displays basic information about the Console. + * + * @return void + */ + public function showHeader(bool $suppress = false) + { + if ($suppress) { + return; + } + + CLI::write(sprintf( + 'CodeIgniter v%s Command Line Tool - Server Time: %s', + CodeIgniter::CI_VERSION, + date('Y-m-d H:i:s \\U\\T\\CP'), + ), 'green'); + CLI::newLine(); + } + + /** + * @param list $arguments + */ + private function dispatch(array $arguments): int + { + $noHeader = $this->hasParameterOption(['no-header']); if ($this->hasParameterOption(['help', 'h'])) { if ($arguments === []) { @@ -92,42 +163,116 @@ public function run(array $tokens = []) return $commands->runLegacy($this->command, array_merge($arguments, $legacyOptions)); } - return $commands->runCommand($this->command, $arguments, $this->options, CLI::getOutput()); + return $commands->runCommand($this->command, $arguments, $this->normalizeVerbosityOptions(), CLI::getOutput()); } - public function initialize(): static + /** + * Resolves the level from `--silent`, `--quiet`, the count of `-v`, or else `SHELL_VERBOSITY`. + */ + private function resolveVerbosity(): Verbosity { - Services::createRequest(config(App::class), true); - service('routes')->loadRoutes(); + $quiet = false; + $count = 0; - return $this; + foreach ($this->options as $name => $value) { + $option = $this->resolveVerbosityOption($name); + + if ($option === 'silent') { + return Verbosity::Silent; + } + + if ($option === 'quiet') { + $quiet = true; + } elseif ($option === 'verbose') { + $count += ($name === 'verbose' ? 1 : strlen($name)) * (is_array($value) ? count($value) : 1); + } + } + + if ($quiet) { + return Verbosity::Quiet; + } + + if ($count > 0) { + return Verbosity::from(min($count, Verbosity::Debug->value)); + } + + return Verbosity::tryFrom((int) env('SHELL_VERBOSITY', 0)) ?? Verbosity::Normal; } /** - * Returns the command that is being executed. + * Collapses repeated `--silent`, `-q` and `-v` tokens into the single `silent`, `quiet` and `verbose` flags of a modern command, keeping the first value so that validation rejects it. + * + * @return array|string|null> */ - public function getCommand(): string + private function normalizeVerbosityOptions(): array { - return $this->command; + $options = []; + $flags = []; + + foreach ($this->options as $name => $value) { + $option = $this->resolveVerbosityOption($name); + + if ($option === null) { + $options[$name] = $value; + + continue; + } + + $flags[$option] ??= null; + + foreach (is_array($value) ? $value : [$value] as $occurrence) { + $flags[$option] ??= $occurrence; + } + } + + return $options + $flags; } /** - * Displays basic information about the Console. + * Returns `null` for an option unrelated to verbosity. * - * @return void + * @return 'quiet'|'silent'|'verbose'|null */ - public function showHeader(bool $suppress = false) + private function resolveVerbosityOption(int|string $name): ?string { - if ($suppress) { - return; + return match (true) { + $name === 'silent' => 'silent', + $name === 'quiet', $name === 'q' => 'quiet', + $name === 'verbose', preg_match('/^v+$/', (string) $name) === 1 => 'verbose', + default => null, + }; + } + + /** + * Mirrors the level to `SHELL_VERBOSITY` for child processes. + */ + private function setShellVerbosity(int $value): void + { + putenv(sprintf('SHELL_VERBOSITY=%d', $value)); + $_ENV['SHELL_VERBOSITY'] = $value; + service('superglobals')->setServer('SHELL_VERBOSITY', $value); + } + + /** + * Puts back each store of `SHELL_VERBOSITY`, removing the variable where it was absent (`false` or `null`). + * + * @param array|float|int|string|null $server + */ + private function restoreShellVerbosity(false|string $process, mixed $env, array|float|int|string|null $server): void + { + putenv($process === false ? 'SHELL_VERBOSITY' : sprintf('SHELL_VERBOSITY=%s', $process)); + + if ($env === null) { + unset($_ENV['SHELL_VERBOSITY']); + } else { + $_ENV['SHELL_VERBOSITY'] = $env; } - CLI::write(sprintf( - 'CodeIgniter v%s Command Line Tool - Server Time: %s', - CodeIgniter::CI_VERSION, - date('Y-m-d H:i:s \\U\\T\\CP'), - ), 'green'); - CLI::newLine(); + if ($server === null) { + service('superglobals')->unsetServer('SHELL_VERBOSITY'); + } else { + service('superglobals')->setServer('SHELL_VERBOSITY', $server); + } } /** @@ -168,6 +313,7 @@ private function hasParameterOption(array $options): bool private function isInteractive(): bool { return ! $this->hasParameterOption(['no-interaction', 'N']) + && ! CLI::getOutput()->isQuiet() && ! CLI::getInputOutput() instanceof NullInputOutput && defined('STDIN') && CLI::streamSupports('stream_isatty', STDIN); diff --git a/system/CLI/Output.php b/system/CLI/Output.php index 558aa421c56e..db23c4bfba79 100644 --- a/system/CLI/Output.php +++ b/system/CLI/Output.php @@ -35,6 +35,8 @@ class Output */ private array $colorSupport = []; + private Verbosity $verbosity = Verbosity::Normal; + public function __construct( private InputOutput $io = new InputOutput(), private readonly Terminal $terminal = new Terminal(), @@ -49,9 +51,15 @@ public function __construct( /** * Writes the text as it is, leaving the cursor where the text ends. + * + * @param Verbosity $verbosity The lowest level that shows the text. */ - public function print(string $text = '', string $foreground = '', string $background = ''): void + public function print(string $text = '', string $foreground = '', string $background = '', Verbosity $verbosity = Verbosity::Normal): void { + if (! $this->accepts($verbosity, $this->toStderr)) { + return; + } + $this->io->fwrite($this->stream(), $this->color($text, $foreground, $background)); // A line feed ends both LF and CRLF, so this holds on every OS. @@ -60,9 +68,15 @@ public function print(string $text = '', string $foreground = '', string $backgr /** * Writes the text on its own line. + * + * @param Verbosity $verbosity The lowest level that shows the text. */ - public function write(string $text = '', string $foreground = '', string $background = ''): void + public function write(string $text = '', string $foreground = '', string $background = '', Verbosity $verbosity = Verbosity::Normal): void { + if (! $this->accepts($verbosity, $this->toStderr)) { + return; + } + $this->io->fwrite($this->stream(), $this->line($this->color($text, $foreground, $background))); } @@ -71,6 +85,10 @@ public function write(string $text = '', string $foreground = '', string $backgr */ public function error(string $text, string $foreground = 'light_red', string $background = ''): void { + if (! $this->accepts(Verbosity::Normal, true)) { + return; + } + $this->io->fwrite( is_cli() ? STDERR : STDOUT, $this->line($this->forStderr()->color($text, $foreground, $background)), @@ -78,12 +96,13 @@ public function error(string $text, string $foreground = 'light_red', string $ba } /** - * @param positive-int $num The number of blank lines. + * @param positive-int $num The number of blank lines. + * @param Verbosity $verbosity The lowest level that shows the lines. */ - public function newLine(int $num = 1): void + public function newLine(int $num = 1, Verbosity $verbosity = Verbosity::Normal): void { for ($i = 0; $i < $num; $i++) { - $this->write(); + $this->write(verbosity: $verbosity); } } @@ -116,6 +135,10 @@ public function clearScreen(): void */ public function writeRaw(string $text): void { + if (! $this->accepts(Verbosity::Normal, $this->toStderr)) { + return; + } + $this->io->fwrite($this->stream(), $text); } @@ -154,6 +177,45 @@ public function getTerminal(): Terminal return $this->terminal; } + public function getVerbosity(): Verbosity + { + return $this->verbosity; + } + + public function setVerbosity(Verbosity $verbosity): void + { + $this->verbosity = $verbosity; + } + + /** + * Whether the level is quiet or silent. + */ + public function isQuiet(): bool + { + return $this->verbosity->value <= Verbosity::Quiet->value; + } + + /** + * Whether the level is verbose or above. + */ + public function isVerbose(): bool + { + return $this->verbosity->value >= Verbosity::Verbose->value; + } + + /** + * Whether the level is very verbose or above. + */ + public function isVeryVerbose(): bool + { + return $this->verbosity->value >= Verbosity::VeryVerbose->value; + } + + public function isDebug(): bool + { + return $this->verbosity === Verbosity::Debug; + } + /** * @internal */ @@ -190,6 +252,20 @@ private function line(string $text): string return $prefix . $text . PHP_EOL; } + /** + * Whether the level shows text written at the given verbosity, where STDERR is never quieter than normal. + */ + private function accepts(Verbosity $verbosity, bool $toStderr): bool + { + if ($this->verbosity === Verbosity::Silent) { + return false; + } + + $level = $toStderr ? max($this->verbosity->value, Verbosity::Normal->value) : $this->verbosity->value; + + return $verbosity->value <= $level; + } + /** * @param resource $stream */ diff --git a/system/CLI/Verbosity.php b/system/CLI/Verbosity.php new file mode 100644 index 000000000000..a46fb17912f6 --- /dev/null +++ b/system/CLI/Verbosity.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI; + +/** + * Verbosity level of the console output, valued as `SHELL_VERBOSITY`. + */ +enum Verbosity: int +{ + /** + * No output at all, not even errors. + */ + case Silent = -2; + + /** + * Errors only. + */ + case Quiet = -1; + + /** + * The default output. + */ + case Normal = 0; + + /** + * Extra detail, as with `-v`. + */ + case Verbose = 1; + + /** + * More detail, as with `-vv`. + */ + case VeryVerbose = 2; + + /** + * Everything, including debug messages, as with `-vvv`. + */ + case Debug = 3; +} diff --git a/system/Commands/Translation/LocalizationFinder.php b/system/Commands/Translation/LocalizationFinder.php index 2bcc2b60c377..ed50881dbee8 100644 --- a/system/Commands/Translation/LocalizationFinder.php +++ b/system/Commands/Translation/LocalizationFinder.php @@ -17,6 +17,7 @@ use CodeIgniter\CLI\Attributes\Command; use CodeIgniter\CLI\CLI; use CodeIgniter\CLI\Input\Option; +use CodeIgniter\CLI\Verbosity; use CodeIgniter\Helpers\Array\ArrayHelper; use Config\App; use Locale; @@ -54,10 +55,6 @@ protected function configure(): void ->addOption(new Option( name: 'show-new', description: 'Show only new translations in table. Does not write to files.', - )) - ->addOption(new Option( - name: 'verbose', - description: 'Output detailed information.', )); } @@ -142,7 +139,7 @@ private function resolvePaths(): array private function process(string $currentDir, string $currentLocale): void { $showNew = $this->getValidatedOption('show-new') === true; - $verbose = $this->getValidatedOption('verbose') === true; + $output = $this->getOutput(); $tableRows = []; $countNewKeys = 0; @@ -182,9 +179,9 @@ private function process(string $currentDir, string $currentLocale): void if ($languageDiff !== []) { if (file_put_contents($languageFilePath, $this->templateFile($newLanguageKeys)) === false) { - $this->writeIsVerbose(sprintf('Lang file %s (error write).', $langFileName), 'red'); + $output->write(sprintf('Lang file %s (error write).', $langFileName), 'red', verbosity: Verbosity::Verbose); } else { - $this->writeIsVerbose(sprintf('Lang file "%s" successful updated!', $langFileName), 'green'); + $output->write(sprintf('Lang file "%s" successful updated!', $langFileName), 'green', verbosity: Verbosity::Verbose); } } } @@ -199,11 +196,11 @@ private function process(string $currentDir, string $currentLocale): void CLI::write('Note: You need to run your linting tool to fix coding standards issues.', 'white', 'red'); } - $this->writeIsVerbose(sprintf('Files found: %d', $countFiles)); - $this->writeIsVerbose(sprintf('New translates found: %d', $countNewKeys)); - $this->writeIsVerbose(sprintf('Bad translates found: %d', count($badLanguageKeys))); + $output->write(sprintf('Files found: %d', $countFiles), verbosity: Verbosity::Verbose); + $output->write(sprintf('New translates found: %d', $countNewKeys), verbosity: Verbosity::Verbose); + $output->write(sprintf('Bad translates found: %d', count($badLanguageKeys)), verbosity: Verbosity::Verbose); - if ($verbose && $badLanguageKeys !== []) { + if ($output->isVerbose() && $badLanguageKeys !== []) { $tableBadRows = []; foreach ($badLanguageKeys as $value) { @@ -416,16 +413,6 @@ private function arrayToTableRows(string $langFileName, array $array): array return $rows; } - /** - * Show details in the console if the flag is set - */ - private function writeIsVerbose(string $text = '', ?string $foreground = null, ?string $background = null): void - { - if ($this->getValidatedOption('verbose') === true) { - CLI::write($text, $foreground, $background); - } - } - private function isSubdirectory(string $directory, string $rootDirectory): bool { return 0 === strncmp($directory, $rootDirectory, strlen($directory)); @@ -441,13 +428,14 @@ private function findLanguageKeysInFiles(array $files): array $foundLanguageKeys = []; $badLanguageKeys = []; $countFiles = 0; + $output = $this->getOutput(); foreach ($files as $file) { if ($this->isIgnoredFile($file)) { continue; } - $this->writeIsVerbose(sprintf('File found: %s', mb_substr($file->getRealPath(), mb_strlen(APPPATH)))); + $output->write(sprintf('File found: %s', mb_substr($file->getRealPath(), mb_strlen(APPPATH))), verbosity: Verbosity::Verbose); $countFiles++; $findInFile = $this->findTranslationsInFile($file); diff --git a/tests/_support/Commands/Modern/VerbosityProbeCommand.php b/tests/_support/Commands/Modern/VerbosityProbeCommand.php new file mode 100644 index 000000000000..07e8a15b6117 --- /dev/null +++ b/tests/_support/Commands/Modern/VerbosityProbeCommand.php @@ -0,0 +1,66 @@ + + * + * 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\Input\Option; +use CodeIgniter\CLI\Verbosity; + +#[Command(name: 'test:verbosity', description: 'Fixture that writes one line per verbosity level.', group: 'Fixtures')] +final class VerbosityProbeCommand extends AbstractCommand +{ + /** + * @var array{silent?: bool, quiet?: bool, verbose?: bool} + */ + public static array $observedOptions = []; + + public static false|string $observedShellVerbosity = false; + public static bool $observedOnce = false; + + public static function reset(): void + { + self::$observedOptions = []; + self::$observedShellVerbosity = false; + self::$observedOnce = false; + } + + protected function configure(): void + { + $this->addOption(new Option(name: 'once', shortcut: '1', description: 'Numeric shortcut.')); + } + + protected function execute(array $arguments, array $options): int + { + self::$observedOnce = $options['once'] === true; + + self::$observedOptions = [ + 'silent' => $options['silent'] === true, + 'quiet' => $options['quiet'] === true, + 'verbose' => $options['verbose'] === true, + ]; + self::$observedShellVerbosity = getenv('SHELL_VERBOSITY'); + + $output = $this->getOutput(); + + $output->write('quiet', verbosity: Verbosity::Quiet); + $output->write('normal'); + $output->write('verbose', verbosity: Verbosity::Verbose); + $output->write('very verbose', verbosity: Verbosity::VeryVerbose); + $output->write('debug', verbosity: Verbosity::Debug); + $output->error('error'); + + return EXIT_SUCCESS; + } +} diff --git a/tests/system/CLI/AbstractCommandTest.php b/tests/system/CLI/AbstractCommandTest.php index cd338702633e..ead18d857f3a 100644 --- a/tests/system/CLI/AbstractCommandTest.php +++ b/tests/system/CLI/AbstractCommandTest.php @@ -121,14 +121,14 @@ public function testCommandCanGetDefinitions(): void $command = new Help(new Commands()); $this->assertCount(1, $command->getArgumentsDefinition()); - $this->assertCount(3, $command->getOptionsDefinition()); - $this->assertCount(2, $command->getShortcuts()); + $this->assertCount(6, $command->getOptionsDefinition()); + $this->assertCount(4, $command->getShortcuts()); $this->assertEmpty($command->getNegations()); } public function testCommandHasDefaultOptions(): void { - $defaultOptions = ['help', 'no-header', 'no-interaction']; + $defaultOptions = ['help', 'no-header', 'no-interaction', 'silent', 'quiet', 'verbose']; $this->assertSame($defaultOptions, array_keys((new Help(new Commands()))->getOptionsDefinition())); } @@ -1238,11 +1238,31 @@ public function testGetValidatedOptionsReflectsDefaultsAfterBinding(): void 'help' => false, 'no-header' => false, 'no-interaction' => false, + 'silent' => false, + 'quiet' => false, + 'verbose' => false, ], $command->callGetValidatedOptions(), ); } + public function testQuietOutputMakesTheRunNonInteractive(): void + { + $output = new Output(new MockInputOutput()); + $output->setVerbosity(Verbosity::Quiet); + + $command = new InteractiveStateProbeCommand(new Commands()); + + InteractiveStateProbeCommand::reset(); + $command->run([], []); + $this->assertTrue(InteractiveStateProbeCommand::$interactCalled); + + InteractiveStateProbeCommand::reset(); + $command->run([], [], $output); + $this->assertFalse(InteractiveStateProbeCommand::$interactCalled); + $this->assertFalse(InteractiveStateProbeCommand::$observedInteractive); + } + public function testGetValidatedOptionByName(): void { $command = new AppAboutCommand(new Commands()); diff --git a/tests/system/CLI/AbstractGeneratorCommandTest.php b/tests/system/CLI/AbstractGeneratorCommandTest.php index fe94ecfd979b..12fa0f2188c3 100644 --- a/tests/system/CLI/AbstractGeneratorCommandTest.php +++ b/tests/system/CLI/AbstractGeneratorCommandTest.php @@ -87,11 +87,11 @@ public function testCommandDeclaresGeneratorDefinition(): void $this->assertSame(['name'], array_keys($arguments)); $this->assertTrue($arguments['name']->required); $this->assertSame( - ['namespace', 'suffix', 'force', 'help', 'no-header', 'no-interaction'], + ['namespace', 'suffix', 'force', 'help', 'no-header', 'no-interaction', 'silent', 'quiet', 'verbose'], array_keys($command->getOptionsDefinition()), ); $this->assertSame( - ['n' => 'namespace', 's' => 'suffix', 'f' => 'force', 'h' => 'help', 'N' => 'no-interaction'], + ['n' => 'namespace', 's' => 'suffix', 'f' => 'force', 'h' => 'help', 'N' => 'no-interaction', 'q' => 'quiet', 'v' => 'verbose'], $command->getShortcuts(), ); $this->assertSame('make:testwidget [options] [--] ', $command->getUsages()[0]); @@ -102,7 +102,7 @@ public function testTrimmedCommandDeclaresReducedDefinition(): void $command = new TrimmedOptionsGeneratorCommand(new Commands()); $this->assertSame( - ['namespace', 'help', 'no-header', 'no-interaction'], + ['namespace', 'help', 'no-header', 'no-interaction', 'silent', 'quiet', 'verbose'], array_keys($command->getOptionsDefinition()), ); } diff --git a/tests/system/CLI/ConsoleTest.php b/tests/system/CLI/ConsoleTest.php index 21386766fcac..e1a58fd49279 100644 --- a/tests/system/CLI/ConsoleTest.php +++ b/tests/system/CLI/ConsoleTest.php @@ -13,6 +13,8 @@ namespace CodeIgniter\CLI; +use CodeIgniter\CLI\Exceptions\CommandNotAvailableException; +use CodeIgniter\CLI\Exceptions\OptionValueMismatchException; use CodeIgniter\CodeIgniter; use CodeIgniter\Config\DotEnv; use CodeIgniter\Config\Services; @@ -26,6 +28,8 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; +use Tests\Support\Commands\Modern\UnavailableFixtureCommand; +use Tests\Support\Commands\Modern\VerbosityProbeCommand; /** * @internal @@ -45,6 +49,7 @@ protected function setUp(): void CLI::init(); (new DotEnv(ROOTPATH))->load(); + $this->clearShellVerbosity(); $this->app = new MockCodeIgniter(new MockCLIConfig()); $this->app->initialize(); @@ -55,6 +60,8 @@ protected function tearDown(): void parent::tearDown(); service('superglobals')->setServer('CODEIGNITER_SCREAM_DEPRECATIONS', '1'); + $this->clearShellVerbosity(); + Services::resetSingle('commands'); CLI::reset(); } @@ -447,6 +454,278 @@ public function testConsoleReturnsTheLastExecutedCommand(): void $this->assertSame('list', $console->getCommand()); } + /** + * @param list $tokens + */ + #[DataProvider('provideVerbosityFlagsSetTheLevel')] + public function testVerbosityFlagsSetTheLevel(array $tokens, string $expected): void + { + $this->initializeConsole('test:verbosity', '--no-header', ...$tokens); + $io = $this->useInputs(); + + (new Console())->run(); + + $this->assertSame($expected, $this->getUndecoratedIoOutput($io)); + } + + /** + * @return iterable, string}> + */ + public static function provideVerbosityFlagsSetTheLevel(): iterable + { + yield 'none' => [[], "\nquiet\nnormal\nerror\n"]; + + yield '--silent' => [['--silent'], '']; + + yield '--silent wins over -q and -v' => [['-q', '-v', '--silent'], '']; + + yield '-q' => [['-q'], "\nquiet\nerror\n"]; + + yield '--quiet' => [['--quiet'], "\nquiet\nerror\n"]; + + yield '-q wins over -v' => [['-q', '-v'], "\nquiet\nerror\n"]; + + yield '-v' => [['-v'], "\nquiet\nnormal\nverbose\nerror\n"]; + + yield '--verbose' => [['--verbose'], "\nquiet\nnormal\nverbose\nerror\n"]; + + yield '-vv' => [['-vv'], "\nquiet\nnormal\nverbose\nvery verbose\nerror\n"]; + + yield '-v -v' => [['-v', '-v'], "\nquiet\nnormal\nverbose\nvery verbose\nerror\n"]; + + yield '-vvv' => [['-vvv'], "\nquiet\nnormal\nverbose\nvery verbose\ndebug\nerror\n"]; + + yield '-vvvv' => [['-vvvv'], "\nquiet\nnormal\nverbose\nvery verbose\ndebug\nerror\n"]; + } + + public function testQuietDropsTheHeader(): void + { + $this->initializeConsole('test:verbosity', '-q'); + $io = $this->useInputs(); + + (new Console())->run(); + + $this->assertSame("\nquiet\nerror\n", $this->getUndecoratedIoOutput($io)); + } + + public function testModernCommandSeesTheCollapsedFlags(): void + { + VerbosityProbeCommand::reset(); + $this->initializeConsole('test:verbosity', '--no-header', '--silent', '--silent', '-q', '-q', '-v', '-vv'); + $this->useInputs(); + + $exitCode = (new Console())->run(); + + $this->assertSame(EXIT_SUCCESS, $exitCode); + $this->assertSame(['silent' => true, 'quiet' => true, 'verbose' => true], VerbosityProbeCommand::$observedOptions); + } + + public function testModernCommandAcceptsANumericShortcut(): void + { + VerbosityProbeCommand::reset(); + $this->initializeConsole('test:verbosity', '--no-header', '-1', '-v'); + $this->useInputs(); + + $exitCode = (new Console())->run(); + + $this->assertSame(EXIT_SUCCESS, $exitCode); + $this->assertTrue(VerbosityProbeCommand::$observedOnce); + $this->assertTrue(VerbosityProbeCommand::$observedOptions['verbose']); + } + + public function testLegacyCommandReceivesTheRawTokens(): void + { + $commands = $this->getMockBuilder(Commands::class)->onlyMethods(['runLegacy'])->getMock(); + $commands->expects($this->once())->method('runLegacy')->with('app:info', ['vv' => null, 'no-interaction' => null])->willReturn(EXIT_SUCCESS); + Services::injectMock('commands', $commands); + + $this->initializeConsole('app:info', '-vv', '--no-interaction'); + + (new Console())->run(); + } + + public function testQuietAppliesToLegacyCommands(): void + { + service('superglobals')->unsetServer('CODEIGNITER_SCREAM_DEPRECATIONS'); + + $this->initializeConsole('app:info', '-q'); + $io = $this->useInputs(); + + $this->assertSame(EXIT_SUCCESS, (new Console())->run()); + $this->assertSame('', $io->getOutput()); + } + + public function testQuietDoesNotPromptForAnAlternative(): void + { + $this->initializeConsole('lst', '--no-header', '-q'); + $io = $this->useInputs('y'); + + $this->assertSame(EXIT_ERROR, (new Console())->run()); + $this->assertSame( + <<<'EOT' + + Command "lst" not found. + + Did you mean this? + list + + EOT, + $this->getUndecoratedIoOutput($io), + ); + } + + /** + * @param list $tokens + */ + #[DataProvider('provideShellVerbosityIsTheFallback')] + public function testShellVerbosityIsTheFallback(string $shellVerbosity, array $tokens, string $expected): void + { + putenv(sprintf('SHELL_VERBOSITY=%s', $shellVerbosity)); + $this->initializeConsole('test:verbosity', '--no-header', ...$tokens); + $io = $this->useInputs(); + + (new Console())->run(); + + $this->assertSame($expected, $this->getUndecoratedIoOutput($io)); + } + + /** + * @return iterable, string}> + */ + public static function provideShellVerbosityIsTheFallback(): iterable + { + yield 'quiet' => ['-1', [], "\nquiet\nerror\n"]; + + yield 'silent' => ['-2', [], '']; + + yield 'verbose' => ['1', [], "\nquiet\nnormal\nverbose\nerror\n"]; + + yield 'flag wins' => ['-1', ['-v'], "\nquiet\nnormal\nverbose\nerror\n"]; + + yield 'invalid' => ['7', [], "\nquiet\nnormal\nerror\n"]; + } + + public function testShellVerbosityIsMirroredDuringTheRunAndRemovedAfter(): void + { + VerbosityProbeCommand::reset(); + $this->initializeConsole('test:verbosity', '--no-header', '-vv'); + $this->useInputs(); + + (new Console())->run(); + + $this->assertSame('2', VerbosityProbeCommand::$observedShellVerbosity); + $this->assertFalse(getenv('SHELL_VERBOSITY')); + $this->assertArrayNotHasKey('SHELL_VERBOSITY', $_ENV); + $this->assertNull(service('superglobals')->server('SHELL_VERBOSITY')); + $this->assertSame(Verbosity::Normal, CLI::getOutput()->getVerbosity()); + } + + public function testUncaughtThrowableKeepsTheLevelForTheExceptionHandler(): void + { + UnavailableFixtureCommand::$available = false; + $this->initializeConsole('test:unavailable', '--no-header', '-v'); + $this->useInputs(); + + try { + (new Console())->run(); + $this->fail('The command should have thrown.'); + } catch (CommandNotAvailableException) { + $this->assertSame(Verbosity::Verbose, CLI::getOutput()->getVerbosity()); + $this->assertFalse(getenv('SHELL_VERBOSITY')); + } finally { + UnavailableFixtureCommand::reset(); + } + } + + public function testInitializeReturnsTheConsole(): void + { + $console = new Console(); + + $this->assertSame($console, $console->initialize()); + } + + public function testUncaughtThrowableRestoresTheLevelForExplicitTokens(): void + { + UnavailableFixtureCommand::$available = false; + $this->useInputs(); + + try { + (new Console())->run(['spark', 'test:unavailable', '--no-header', '-v']); + $this->fail('The command should have thrown.'); + } catch (CommandNotAvailableException) { + $this->assertSame(Verbosity::Normal, CLI::getOutput()->getVerbosity()); + } finally { + UnavailableFixtureCommand::reset(); + } + } + + /** + * @param list $tokens + */ + #[DataProvider('provideVerbosityFlagWithValueIsRejected')] + public function testVerbosityFlagWithValueIsRejected(array $tokens): void + { + $this->initializeConsole('test:verbosity', '--no-header', ...$tokens); + $this->useInputs(); + + $this->expectException(OptionValueMismatchException::class); + + (new Console())->run(); + } + + /** + * @return iterable}> + */ + public static function provideVerbosityFlagWithValueIsRejected(): iterable + { + yield '-q value' => [['-q', 'extra']]; + + yield '-vv value' => [['-vv', 'extra']]; + + yield '-v -v value' => [['-v', '-v', 'extra']]; + + yield '--silent=value' => [['--silent=extra']]; + } + + public function testShellVerbosityIsRestoredAfterTheRun(): void + { + putenv('SHELL_VERBOSITY=1'); + VerbosityProbeCommand::reset(); + $this->initializeConsole('test:verbosity', '--no-header', '-q'); + $this->useInputs(); + + (new Console())->run(); + + $this->assertSame('-1', VerbosityProbeCommand::$observedShellVerbosity); + $this->assertSame('1', getenv('SHELL_VERBOSITY')); + $this->assertArrayNotHasKey('SHELL_VERBOSITY', $_ENV); + $this->assertNull(service('superglobals')->server('SHELL_VERBOSITY')); + } + + public function testShellVerbosityRestoresValuesPresentOnlyInSuperglobals(): void + { + $_ENV['SHELL_VERBOSITY'] = '2'; + service('superglobals')->setServer('SHELL_VERBOSITY', '1'); + VerbosityProbeCommand::reset(); + $this->initializeConsole('test:verbosity', '--no-header'); + $io = $this->useInputs(); + + (new Console())->run(); + + $this->assertStringContainsString('very verbose', $this->getUndecoratedIoOutput($io)); + $this->assertSame('2', VerbosityProbeCommand::$observedShellVerbosity); + $this->assertFalse(getenv('SHELL_VERBOSITY')); + $this->assertSame('2', $_ENV['SHELL_VERBOSITY']); + $this->assertSame('1', service('superglobals')->server('SHELL_VERBOSITY')); + } + + private function clearShellVerbosity(): void + { + putenv('SHELL_VERBOSITY'); + unset($_ENV['SHELL_VERBOSITY']); + service('superglobals')->unsetServer('SHELL_VERBOSITY'); + } + private function initializeConsole(string ...$tokens): void { service('superglobals') diff --git a/tests/system/CLI/OutputTest.php b/tests/system/CLI/OutputTest.php index 9934fc34b4d2..d50f008f0286 100644 --- a/tests/system/CLI/OutputTest.php +++ b/tests/system/CLI/OutputTest.php @@ -17,6 +17,7 @@ use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockInputOutput; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use Tests\Support\CLI\RecordingInputOutput; @@ -393,6 +394,120 @@ public function testDefaultsToAFreshTransportAndTerminal(): void $this->assertNotSame($output->getTerminal(), (new Output())->getTerminal()); } + public function testDefaultsToTheNormalLevel(): void + { + $this->assertSame(Verbosity::Normal, (new Output(new RecordingInputOutput()))->getVerbosity()); + } + + public function testDropsWritesAboveTheLevel(): void + { + $io = new RecordingInputOutput(); + $output = new Output($io); + + $output->write('normal'); + $output->write('verbose', verbosity: Verbosity::Verbose); + $output->print('verbose', verbosity: Verbosity::Verbose); + $output->newLine(verbosity: Verbosity::Verbose); + $output->write('quiet', verbosity: Verbosity::Quiet); + + $output->setVerbosity(Verbosity::VeryVerbose); + $output->write('very verbose', verbosity: Verbosity::VeryVerbose); + $output->write('debug', verbosity: Verbosity::Debug); + + $this->assertSame([ + ['STDOUT', "\nnormal\n"], + ['STDOUT', "quiet\n"], + ['STDOUT', "very verbose\n"], + ], $io->writes); + } + + public function testQuietDropsNormalWritesButKeepsErrors(): void + { + $io = new RecordingInputOutput(); + $output = new Output($io, $this->createUncoloredTerminal()); + + $output->setVerbosity(Verbosity::Quiet); + $output->write('write'); + $output->print('print'); + $output->newLine(); + $output->beep(); + $output->clearScreen(); + $output->writeRaw('raw'); + $output->error('error'); + $output->write('quiet', verbosity: Verbosity::Quiet); + + $this->assertSame([['STDERR', "\nerror\n"], ['STDOUT', "quiet\n"]], $io->writes); + } + + public function testSilentDropsEverything(): void + { + $io = new RecordingInputOutput(); + $output = new Output($io, $this->createUncoloredTerminal()); + + $output->setVerbosity(Verbosity::Silent); + $output->write('silent', verbosity: Verbosity::Silent); + $output->error('error'); + $output->forStderr()->write('stderr'); + + $this->assertSame([], $io->writes); + } + + public function testDroppedWriteLeavesTheCursorAlone(): void + { + $io = new RecordingInputOutput(); + $output = new Output($io); + + $output->write('first'); + $output->print('dropped', verbosity: Verbosity::Verbose); + $output->write('second'); + + $this->assertSame([['STDOUT', "\nfirst\n"], ['STDOUT', "second\n"]], $io->writes); + } + + public function testForStderrKeepsTheLevelAndWritesAtQuiet(): void + { + $io = new RecordingInputOutput(); + $output = new Output($io, $this->createUncoloredTerminal()); + + $output->setVerbosity(Verbosity::Quiet); + $stderrOutput = $output->forStderr(); + $stderrOutput->write('shown'); + $stderrOutput->write('dropped', verbosity: Verbosity::Verbose); + + $this->assertSame(Verbosity::Quiet, $stderrOutput->getVerbosity()); + $this->assertSame([['STDERR', "\nshown\n"]], $io->writes); + } + + /** + * @param array{bool, bool, bool, bool} $expected + */ + #[DataProvider('provideLevelPredicates')] + public function testLevelPredicates(Verbosity $verbosity, array $expected): void + { + $output = new Output(new RecordingInputOutput()); + $output->setVerbosity($verbosity); + + $this->assertSame($expected, [$output->isQuiet(), $output->isVerbose(), $output->isVeryVerbose(), $output->isDebug()]); + } + + /** + * @return iterable + */ + public static function provideLevelPredicates(): iterable + { + yield 'silent' => [Verbosity::Silent, [true, false, false, false]]; + + yield 'quiet' => [Verbosity::Quiet, [true, false, false, false]]; + + yield 'normal' => [Verbosity::Normal, [false, false, false, false]]; + + yield 'verbose' => [Verbosity::Verbose, [false, true, false, false]]; + + yield 'very verbose' => [Verbosity::VeryVerbose, [false, true, true, false]]; + + yield 'debug' => [Verbosity::Debug, [false, true, true, true]]; + } + private function createUncoloredTerminal(): Terminal { $terminal = $this->createStub(Terminal::class); diff --git a/tests/system/Commands/HelpCommandTest.php b/tests/system/Commands/HelpCommandTest.php index 649880c0c925..89d41546dfa7 100644 --- a/tests/system/Commands/HelpCommandTest.php +++ b/tests/system/Commands/HelpCommandTest.php @@ -62,6 +62,9 @@ public function testNoArgumentDescribesItself(): void -h, --help Display help for the given command. --no-header Do not display the banner when running the command. -N, --no-interaction Do not ask any interactive questions. + --silent Do not output any message, not even errors. + -q, --quiet Do not output any message except errors. + -v, --verbose Increase the verbosity of messages (-v, -vv, or -vvv). EOT, $this->getUndecoratedBuffer(), @@ -95,6 +98,9 @@ public function testDescribeCommandNoArguments(): void -h, --help Display help for the given command. --no-header Do not display the banner when running the command. -N, --no-interaction Do not ask any interactive questions. + --silent Do not output any message, not even errors. + -q, --quiet Do not output any message except errors. + -v, --verbose Increase the verbosity of messages (-v, -vv, or -vvv). EOT, $this->getUndecoratedBuffer(), @@ -121,6 +127,9 @@ public function testDescribeSpecificCommand(): void -h, --help Display help for the given command. --no-header Do not display the banner when running the command. -N, --no-interaction Do not ask any interactive questions. + --silent Do not output any message, not even errors. + -q, --quiet Do not output any message except errors. + -v, --verbose Increase the verbosity of messages (-v, -vv, or -vvv). EOT, $this->getUndecoratedBuffer(), @@ -147,6 +156,9 @@ public function testEmptyStringOptionDefaultIsNotDisplayed(): void -h, --help Display help for the given command. --no-header Do not display the banner when running the command. -N, --no-interaction Do not ask any interactive questions. + --silent Do not output any message, not even errors. + -q, --quiet Do not output any message except errors. + -v, --verbose Increase the verbosity of messages (-v, -vv, or -vvv). EOT, $this->getUndecoratedBuffer(), @@ -174,6 +186,9 @@ public function testDescribeCommandWithAliases(): void -h, --help Display help for the given command. --no-header Do not display the banner when running the command. -N, --no-interaction Do not ask any interactive questions. + --silent Do not output any message, not even errors. + -q, --quiet Do not output any message except errors. + -v, --verbose Increase the verbosity of messages (-v, -vv, or -vvv). EOT, $this->getUndecoratedBuffer(), @@ -201,6 +216,9 @@ public function testDescribeHiddenCommand(string $command): void -h, --help Display help for the given command. --no-header Do not display the banner when running the command. -N, --no-interaction Do not ask any interactive questions. + --silent Do not output any message, not even errors. + -q, --quiet Do not output any message except errors. + -v, --verbose Increase the verbosity of messages (-v, -vv, or -vvv). EOT, $this->getUndecoratedBuffer(), @@ -242,6 +260,9 @@ public function testDescribeCommandViaAliasResolvesToCanonical(): void -h, --help Display help for the given command. --no-header Do not display the banner when running the command. -N, --no-interaction Do not ask any interactive questions. + --silent Do not output any message, not even errors. + -q, --quiet Do not output any message except errors. + -v, --verbose Increase the verbosity of messages (-v, -vv, or -vvv). EOT, $this->getUndecoratedBuffer(), @@ -271,6 +292,9 @@ public function testDescribeGeneratorCommand(): void -h, --help Display help for the given command. --no-header Do not display the banner when running the command. -N, --no-interaction Do not ask any interactive questions. + --silent Do not output any message, not even errors. + -q, --quiet Do not output any message except errors. + -v, --verbose Increase the verbosity of messages (-v, -vv, or -vvv). EOT, $this->getUndecoratedBuffer(), @@ -298,6 +322,9 @@ public function testDescribeGeneratorCommandWithTrimmedOptions(): void -h, --help Display help for the given command. --no-header Do not display the banner when running the command. -N, --no-interaction Do not ask any interactive questions. + --silent Do not output any message, not even errors. + -q, --quiet Do not output any message except errors. + -v, --verbose Increase the verbosity of messages (-v, -vv, or -vvv). EOT, $this->getUndecoratedBuffer(), @@ -321,6 +348,9 @@ public function testDescribeUnavailableCommand(): void -h, --help Display help for the given command. --no-header Do not display the banner when running the command. -N, --no-interaction Do not ask any interactive questions. + --silent Do not output any message, not even errors. + -q, --quiet Do not output any message except errors. + -v, --verbose Increase the verbosity of messages (-v, -vv, or -vvv). EOT, $this->getUndecoratedBuffer(), @@ -414,6 +444,9 @@ public function testDescribeUsingHelpOption(): void -h, --help Display help for the given command. --no-header Do not display the banner when running the command. -N, --no-interaction Do not ask any interactive questions. + --silent Do not output any message, not even errors. + -q, --quiet Do not output any message except errors. + -v, --verbose Increase the verbosity of messages (-v, -vv, or -vvv). EOT, $this->getUndecoratedBuffer(), @@ -440,6 +473,9 @@ public function testDescribeUsingHelpShortOption(): void -h, --help Display help for the given command. --no-header Do not display the banner when running the command. -N, --no-interaction Do not ask any interactive questions. + --silent Do not output any message, not even errors. + -q, --quiet Do not output any message except errors. + -v, --verbose Increase the verbosity of messages (-v, -vv, or -vvv). EOT, $this->getUndecoratedBuffer(), diff --git a/tests/system/Commands/Translation/LocalizationFinderTest.php b/tests/system/Commands/Translation/LocalizationFinderTest.php index 30670336f6e0..324c344d0ffe 100644 --- a/tests/system/Commands/Translation/LocalizationFinderTest.php +++ b/tests/system/Commands/Translation/LocalizationFinderTest.php @@ -133,6 +133,48 @@ public function testShowBadTranslation(): void $this->assertStringContainsString($this->getActualTableWithBadKeys(), $this->getStreamFilterBuffer()); } + public function testShowBadTranslationWithVerboseShortcut(): void + { + $this->makeLocaleDirectory(); + + command('lang:find --dir Translation -v'); + + $this->assertStringContainsString('Files found:', $this->getStreamFilterBuffer()); + $this->assertStringContainsString($this->getActualTableWithBadKeys(), $this->getStreamFilterBuffer()); + } + + public function testReportsAFailedWriteWhenVerbose(): void + { + $this->makeLocaleDirectory(); + $localeDirectory = self::$languageTestPath . self::$locale; + chmod($localeDirectory, 0555); + + if (is_writable($localeDirectory)) { + chmod($localeDirectory, 0777); + $this->markTestSkipped('The locale directory stays writable for this user.'); + } + + set_error_handler(static fn (): bool => true, E_WARNING); + + try { + command('lang:find --dir Translation -v'); + } finally { + restore_error_handler(); + chmod($localeDirectory, 0777); + } + + $this->assertStringContainsString('(error write).', $this->getStreamFilterBuffer()); + } + + public function testHidesDetailsWithoutVerbose(): void + { + $this->makeLocaleDirectory(); + + command('lang:find --dir Translation'); + + $this->assertStringNotContainsString('Files found:', $this->getStreamFilterBuffer()); + } + public function testShowNewSkipsKeysAlreadyTranslatedByFramework(): void { $this->makeLocaleDirectory(); diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index 0f85d126d8d1..e3e116d1f996 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -267,6 +267,8 @@ Commands - Modern commands can write through ``$this->getOutput()``, the same ``Output`` used by the static ``CLI`` methods. ``AbstractCommand::run()`` and ``Commands::runCommand()`` accept an ``Output`` to run against, which ``Console`` passes down and sub-commands inherit. See :ref:`modern-commands-writing-output`. +- Added ``--silent``, ``--quiet`` / ``-q``, and ``--verbose`` / ``-v``, ``-vv``, ``-vvv`` to every command, with ``SHELL_VERBOSITY`` as the fallback. Modern + commands can write at a :php:class:`Verbosity ` through ``$this->getOutput()``. ``lang:find`` uses the global ``--verbose`` in place of its own. See :ref:`spark-verbosity` and :ref:`modern-commands-verbosity`. - Added :php:class:`AbstractGeneratorCommand ` and the ``#[GeneratorCommand]`` attribute, the modern counterpart of ``GeneratorTrait`` for commands that generate files from templates. Generator commands built on it accept the ``-n``, ``-s``, and ``-f`` shortcuts for ``--namespace``, ``--suffix``, and ``--force``. See :doc:`../cli/cli_modern_generators`. diff --git a/user_guide_src/source/cli/cli_library.rst b/user_guide_src/source/cli/cli_library.rst index c1738f525ebe..2a93a0166a2b 100644 --- a/user_guide_src/source/cli/cli_library.rst +++ b/user_guide_src/source/cli/cli_library.rst @@ -297,14 +297,29 @@ Mixing the static methods with the instance is safe, because both write to the s :param InputOutput $io: The transport the text is written through. :param Terminal $terminal: The terminal consulted for colour support. - .. php:method:: print(string $text = ''[, string $foreground = ''[, string $background = '']]): void - .. php:method:: write(string $text = ''[, string $foreground = ''[, string $background = '']]): void + .. php:method:: print(string $text = ''[, string $foreground = ''[, string $background = ''[, Verbosity $verbosity = Verbosity::Normal]]]): void + .. php:method:: write(string $text = ''[, string $foreground = ''[, string $background = ''[, Verbosity $verbosity = Verbosity::Normal]]]): void + .. php:method:: newLine([int $num = 1[, Verbosity $verbosity = Verbosity::Normal]]): void + + The same as the static methods of the same names, writing nothing when the + level of the output is below ``$verbosity``. + .. php:method:: error(string $text[, string $foreground = 'light_red'[, string $background = '']]): void - .. php:method:: newLine(int $num = 1): void .. php:method:: beep(int $num = 1): void .. php:method:: clearScreen(): void - The same as the static methods of the same names. + The same as the static methods of the same names. ``error()`` writes at every + level but ``Verbosity::Silent``, and the other two only from ``Verbosity::Normal`` on. + + .. php:method:: getVerbosity(): Verbosity + .. php:method:: setVerbosity(Verbosity $verbosity): void + .. php:method:: isQuiet(): bool + .. php:method:: isVerbose(): bool + .. php:method:: isVeryVerbose(): bool + .. php:method:: isDebug(): bool + + ``isQuiet()`` is true for the quiet and silent levels, and the others for their + level or above. See :ref:`modern-commands-verbosity`. .. php:method:: color(string $text, string $foreground[, string $background = ''[, string $format = '']]): string @@ -323,6 +338,12 @@ Mixing the static methods with the instance is safe, because both write to the s .. php:method:: getTerminal(): Terminal +.. php:class:: Verbosity + + The level of the output, as an ``int``-backed enum valued as ``SHELL_VERBOSITY``: + ``Silent`` (-2), ``Quiet`` (-1), ``Normal`` (0), ``Verbose`` (1), ``VeryVerbose`` (2), + and ``Debug`` (3). + .. php:class:: Terminal The dimensions and capabilities of the terminal. The dimensions are read once and cached. diff --git a/user_guide_src/source/cli/cli_modern_commands.rst b/user_guide_src/source/cli/cli_modern_commands.rst index 8fe0617f5076..b04f1800aa09 100644 --- a/user_guide_src/source/cli/cli_modern_commands.rst +++ b/user_guide_src/source/cli/cli_modern_commands.rst @@ -127,8 +127,8 @@ this order: 1. **Construction.** The ``#[Command]`` attribute is read, then your ``configure(): void`` hook runs so you can register arguments, options, and extra usage examples. A default ``--help``/ ``-h`` flag, ``--no-header`` - flag, and ``--no-interaction``/ ``-N`` flag are added automatically - afterwards. + flag, ``--no-interaction``/ ``-N`` flag, ``--silent`` flag, ``--quiet``/ ``-q`` + flag, and ``--verbose``/ ``-v`` flag are added automatically afterwards. 2. ``isAvailable(): bool`` is called to check whether the command should execute (see :ref:`restricting-execution`). 3. ``initialize(array &$arguments, array &$options): void`` receives the raw @@ -207,6 +207,9 @@ A few quirks are worth knowing: - ``--help`` / ``-h`` - ``--no-header`` - ``--no-interaction`` / ``-N`` + - ``--silent`` + - ``--quiet`` / ``-q`` + - ``--verbose`` / ``-v`` Configuration-time violations raise ``InvalidOptionDefinitionException``. @@ -215,7 +218,8 @@ Configuration-time violations raise ``InvalidOptionDefinitionException``. The command-line parser does **not** understand bundled shortcuts or shortcuts with a glued value: - - ``-abc`` is read as one option named ``abc``, *not* as ``-a -b -c``. + - ``-abc`` is read as one option named ``abc``, *not* as ``-a -b -c``. The only + exception is a run of ``v``, such as ``-vvv``, which sets the verbosity. - ``-fvalue`` is read as one option named ``fvalue``; it is not split into shortcut ``-f`` with value ``value``. @@ -343,7 +347,7 @@ Inside execute() - ``$arguments`` contains every declared argument, bound to the provided value or the declared default. - ``$options`` contains every declared option plus the framework defaults - (``help``, ``no-header``, ``no-interaction``), bound to the provided value + (``help``, ``no-header``, ``no-interaction``, ``silent``, ``quiet``, ``verbose``), bound to the provided value or the declared default. Within ``execute()`` itself, reaching into ``$arguments`` / ``$options`` directly @@ -385,6 +389,24 @@ By default the run writes to the output shared through ``CLI::getOutput()``. Pas to a ``MockInputOutput`` in a test. See :doc:`../cli/cli_library` for the classes and :ref:`testing-cli-output` for testing. +.. _modern-commands-verbosity: + +Writing at a Verbosity +====================== + +**spark** sets the :php:class:`Verbosity ` of the output +from ``--silent``, ``--quiet`` / ``-q``, and ``--verbose`` / ``-v``, ``-vv``, ``-vvv`` (see :ref:`spark-verbosity`). +``print()``, ``write()``, and ``newLine()`` take the lowest level that shows the text as their last +parameter, and ``isVerbose()``, ``isVeryVerbose()``, ``isDebug()``, and ``isQuiet()`` guard larger blocks: + +.. literalinclude:: cli_modern_commands/019.php + +``--quiet`` drops every write at the normal level, so only errors and text written at +``Verbosity::Quiet`` remain, and it makes the run non-interactive. + +A command run through ``call()`` writes at the level of the calling command. Use +``callSilently()`` to hide its output. + *********************** Calling Another Command *********************** diff --git a/user_guide_src/source/cli/cli_modern_commands/003.php b/user_guide_src/source/cli/cli_modern_commands/003.php index 2e3bb3bfcee3..e2c85d4f9fb3 100644 --- a/user_guide_src/source/cli/cli_modern_commands/003.php +++ b/user_guide_src/source/cli/cli_modern_commands/003.php @@ -14,9 +14,9 @@ protected function configure(): void $this // Flag. Presence → true, absence → false. ->addOption(new Option( - name: 'verbose', - shortcut: 'v', - description: 'Enable verbose output.', + name: 'dry-run', + shortcut: 'r', + description: 'Show what would be published without copying.', )) // Value-required. --destination=path or -d path. A string default is mandatory. ->addOption(new Option( diff --git a/user_guide_src/source/cli/cli_modern_commands/019.php b/user_guide_src/source/cli/cli_modern_commands/019.php new file mode 100644 index 000000000000..16b035272039 --- /dev/null +++ b/user_guide_src/source/cli/cli_modern_commands/019.php @@ -0,0 +1,27 @@ +getOutput(); + $rows = [[1, 'First'], [2, 'Second']]; + + $output->write('Connecting to the remote...', verbosity: Verbosity::Verbose); + $output->write(sprintf('%d records synchronized.', count($rows)), 'green'); + + if ($output->isVeryVerbose()) { + $output->write((new Table($rows, ['ID', 'Title']))->render()); + } + + return EXIT_SUCCESS; + } +} diff --git a/user_guide_src/source/cli/spark_commands.rst b/user_guide_src/source/cli/spark_commands.rst index 4355d7ce1a5a..f14fc59812e8 100644 --- a/user_guide_src/source/cli/spark_commands.rst +++ b/user_guide_src/source/cli/spark_commands.rst @@ -112,6 +112,38 @@ You may always pass ``--no-header`` to suppress the header output, helpful for p .. note:: A modern command can also opt out of the header for every run. See :ref:`commands-without-the-header`. +.. _spark-verbosity: + +Verbosity +--------- + +.. versionadded:: 4.8.0 + +``--quiet`` / ``-q`` hides everything but errors, including the header, and makes a +modern command non-interactive. ``--silent`` hides the errors too. ``--verbose`` / ``-v`` +shows more detail, ``-vv`` even more, and ``-vvv`` everything, including debug messages. +A command shows the extra detail only when it writes some, and an uncaught exception +shows its backtrace from ``-v`` on. + +The flags can be combined, and the quietest one wins: ``--silent`` over ``-q``, and ``-q`` +over any ``-v``. + +.. code-block:: console + + php spark lang:find -v + php spark migrate -q + +Without these flags, the ``SHELL_VERBOSITY`` environment variable sets the level: +``-2`` for no output at all, ``-1`` for quiet, ``0`` for normal, and ``1`` to ``3`` +for the three verbose levels. It can also be set in the **.env** file. Processes started +by the command inherit the level. + +.. code-block:: console + + SHELL_VERBOSITY=-1 php spark migrate + +.. note:: Legacy commands follow the level too, and can still read ``-v`` and ``-q`` as their own options. + .. _correcting-a-mistyped-command: Correcting a Mistyped Command diff --git a/user_guide_src/source/installation/upgrade_480.rst b/user_guide_src/source/installation/upgrade_480.rst index 553bf63c4103..c71011e6bc9c 100644 --- a/user_guide_src/source/installation/upgrade_480.rst +++ b/user_guide_src/source/installation/upgrade_480.rst @@ -232,6 +232,8 @@ Config Error Views ----------- +- app/Views/errors/cli/error_exception.php + - Shows the backtrace also when the command runs with ``-v``. - app/Views/errors/html/debug.css - Added styles for the **Copy Details** button. - app/Views/errors/html/debug.js