From f7df2c68ca733b138704eeb921963da11e39cf16 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Tue, 13 Oct 2026 15:56:40 +0800 Subject: [PATCH] feat: split the `CLI` output into `Output`, `Terminal`, `ProgressBar`, `Table` and `Color` --- rector.php | 1 + system/CLI/AbstractCommand.php | 93 ++-- system/CLI/CLI.php | 525 +++++------------- system/CLI/Color.php | 114 ++++ system/CLI/Commands.php | 28 +- system/CLI/Console.php | 2 +- system/CLI/InputOutput.php | 2 +- system/CLI/Output.php | 228 ++++++++ system/CLI/ProgressBar.php | 76 +++ system/CLI/Table.php | 117 ++++ system/CLI/Terminal.php | 168 ++++++ tests/_support/CLI/RecordingInputOutput.php | 46 ++ .../Commands/Modern/CallsAboutCommand.php | 26 + .../Commands/Modern/OutputProbeCommand.php | 35 ++ .../Commands/Modern/SilentCallCommand.php | 34 ++ tests/system/CLI/AbstractCommandTest.php | 125 ++++- tests/system/CLI/CLITest.php | 306 ++++++---- tests/system/CLI/ColorTest.php | 101 ++++ tests/system/CLI/CommandsTest.php | 37 ++ tests/system/CLI/NullInputOutputTest.php | 6 +- tests/system/CLI/OutputTest.php | 381 +++++++++++++ tests/system/CLI/ProgressBarTest.php | 126 +++++ tests/system/CLI/TableTest.php | 114 ++++ tests/system/CLI/TerminalTest.php | 171 ++++++ tests/system/Test/TestCaseTest.php | 3 +- user_guide_src/source/changelogs/v4.8.0.rst | 26 +- user_guide_src/source/cli/cli_library.rst | 113 +++- user_guide_src/source/cli/cli_library/024.php | 26 + .../source/cli/cli_modern_commands.rst | 45 +- .../source/cli/cli_modern_commands/018.php | 39 ++ .../source/installation/upgrade_480.rst | 25 + user_guide_src/source/testing/cli.rst | 14 +- user_guide_src/source/testing/cli/002.php | 19 + utils/phpstan-baseline/arguments.count.neon | 7 +- utils/phpstan-baseline/loader.neon | 2 +- 35 files changed, 2597 insertions(+), 584 deletions(-) create mode 100644 system/CLI/Color.php create mode 100644 system/CLI/Output.php create mode 100644 system/CLI/ProgressBar.php create mode 100644 system/CLI/Table.php create mode 100644 system/CLI/Terminal.php create mode 100644 tests/_support/CLI/RecordingInputOutput.php create mode 100644 tests/_support/Commands/Modern/CallsAboutCommand.php create mode 100644 tests/_support/Commands/Modern/OutputProbeCommand.php create mode 100644 tests/_support/Commands/Modern/SilentCallCommand.php create mode 100644 tests/system/CLI/ColorTest.php create mode 100644 tests/system/CLI/OutputTest.php create mode 100644 tests/system/CLI/ProgressBarTest.php create mode 100644 tests/system/CLI/TableTest.php create mode 100644 tests/system/CLI/TerminalTest.php create mode 100644 user_guide_src/source/cli/cli_library/024.php create mode 100644 user_guide_src/source/cli/cli_modern_commands/018.php create mode 100644 user_guide_src/source/testing/cli/002.php diff --git a/rector.php b/rector.php index 2a5e4a18a113..70e336e77127 100644 --- a/rector.php +++ b/rector.php @@ -101,6 +101,7 @@ // Exclude test file because `is_cli()` is mocked and Rector might remove needed parameters. RemoveExtraParametersRector::class => [ __DIR__ . '/tests/system/CLI/CLITest.php', + __DIR__ . '/tests/system/CLI/OutputTest.php', __DIR__ . '/tests/system/Debug/ToolbarTest.php', ], diff --git a/system/CLI/AbstractCommand.php b/system/CLI/AbstractCommand.php index bb03bb27b439..1f7d7991410d 100644 --- a/system/CLI/AbstractCommand.php +++ b/system/CLI/AbstractCommand.php @@ -438,6 +438,7 @@ public function setInteractive(bool $interactive): static * * @param list $arguments Parsed arguments from command line. * @param array|string|null> $options Parsed options from command line. + * @param Output|null $output The output to run against, or `null` for the current one. * * @throws ArgumentCountMismatchException * @throws CommandNotAvailableException @@ -445,36 +446,52 @@ public function setInteractive(bool $interactive): static * @throws OptionValueMismatchException * @throws UnknownOptionException */ - final public function run(array $arguments, array $options): int + final public function run(array $arguments, array $options, ?Output $output = null): int { - if (! $this->isAvailable()) { - throw new CommandNotAvailableException(lang('Commands.notAvailable', [$this->name])); - } + $previous = CLI::getOutput(); + + CLI::setOutput($output ?? $previous); + + try { + if (! $this->isAvailable()) { + throw new CommandNotAvailableException(lang('Commands.notAvailable', [$this->name])); + } + + // Reset per-run interactive state from the current options. + $this->runtimeInteractive = $this->hasUnboundOption('no-interaction', $options) ? false : null; - // Reset per-run interactive state from the current options. - $this->runtimeInteractive = $this->hasUnboundOption('no-interaction', $options) ? false : null; + $this->initialize($arguments, $options); - $this->initialize($arguments, $options); + if ($this->isInteractive()) { + if ($this instanceof PromptsForMissingInputInterface) { + $this->promptForMissingArguments($arguments, $options); + } - if ($this->isInteractive()) { - if ($this instanceof PromptsForMissingInputInterface) { - $this->promptForMissingArguments($arguments, $options); + $this->interact($arguments, $options); } - $this->interact($arguments, $options); - } + $this->unboundArguments = $arguments; + $this->unboundOptions = $options; - $this->unboundArguments = $arguments; - $this->unboundOptions = $options; + [$boundArguments, $boundOptions] = $this->bind($arguments, $options); - [$boundArguments, $boundOptions] = $this->bind($arguments, $options); + $this->validate($boundArguments, $boundOptions); - $this->validate($boundArguments, $boundOptions); + $this->validatedArguments = $boundArguments; + $this->validatedOptions = $boundOptions; - $this->validatedArguments = $boundArguments; - $this->validatedOptions = $boundOptions; + return $this->execute($boundArguments, $boundOptions); + } finally { + CLI::setOutput($previous); + } + } - return $this->execute($boundArguments, $boundOptions); + /** + * Returns the output of the current run. + */ + final protected function getOutput(): Output + { + return CLI::getOutput(); } /** @@ -573,7 +590,7 @@ abstract protected function execute(array $arguments, array $options): int; */ protected function call(string $command, array $arguments = [], array $options = [], ?bool $noInteractionOverride = null): int { - return $this->commands->runCommand($command, $arguments, $this->resolveChildInteractiveState($options, $noInteractionOverride)); + return $this->runChild($command, $arguments, $options, $noInteractionOverride, $this->getOutput()); } /** @@ -585,19 +602,13 @@ protected function call(string $command, array $arguments = [], array $options = */ 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); - } + return $this->runChild( + $command, + $arguments, + $options, + $noInteractionOverride, + new Output(new NullInputOutput(), $this->getOutput()->getTerminal()), + ); } /** @@ -782,7 +793,7 @@ protected function provideDefaultOptions(): void private function promptForMissingArguments(array &$arguments, array &$options): void { // A null reader cannot satisfy a required prompt, so leave the missing arguments to validation. - if (CLI::getInputOutput() instanceof NullInputOutput) { + if ($this->getOutput()->getInputOutput() instanceof NullInputOutput) { return; } @@ -1182,4 +1193,18 @@ private function createDefaultUsage(): void array_unshift($this->usages, implode(' ', $usage)); } + + /** + * @param list $arguments + * @param array|string|null> $options + */ + private function runChild(string $command, array $arguments, array $options, ?bool $noInteractionOverride, Output $output): int + { + return $this->commands->runCommand( + $command, + $arguments, + $this->resolveChildInteractiveState($options, $noInteractionOverride), + $output, + ); + } } diff --git a/system/CLI/CLI.php b/system/CLI/CLI.php index 3d0b5d785816..4b7e7d486c3e 100644 --- a/system/CLI/CLI.php +++ b/system/CLI/CLI.php @@ -13,20 +13,15 @@ namespace CodeIgniter\CLI; -use CodeIgniter\CLI\Exceptions\CLIException; use CodeIgniter\Exceptions\InvalidArgumentException; -use Throwable; +use WeakMap; /** - * Set of static methods useful for CLI request handling. + * Static facade for console output, prompts and the parsed command line. * * Portions of this code were initially from the FuelPHP Framework, * version 1.7.x, and used here under the MIT license they were * originally made available under. Reference: http://fuelphp.com - * - * Some of the code in this class is Windows-specific, and not - * possible to test using travis-ci. It has been phpunit-annotated - * to prevent messing up code coverage. */ class CLI { @@ -47,7 +42,7 @@ class CLI * * @var array * - * @TODO Fix to camelCase in the next major version. + * @deprecated 4.8.0 Unused. The codes live in `Color`. */ protected static $foreground_colors = [ 'black' => '0;30', @@ -74,7 +69,7 @@ class CLI * * @var array * - * @TODO Fix to camelCase in the next major version. + * @deprecated 4.8.0 Unused. The codes live in `Color`. */ protected static $background_colors = [ 'black' => '40', @@ -105,6 +100,8 @@ class CLI * keep the output clean and as expected. * * @var string|null + * + * @deprecated 4.8.0 Unused. The cursor state lives in `Output`. */ protected static $lastWrite; @@ -112,6 +109,8 @@ class CLI * Height of the CLI window * * @var int|null + * + * @deprecated 4.8.0 Unused. The dimensions live in `Terminal`. */ protected static $height; @@ -119,6 +118,8 @@ class CLI * Width of the CLI window * * @var int|null + * + * @deprecated 4.8.0 Unused. The dimensions live in `Terminal`. */ protected static $width; @@ -126,16 +127,32 @@ class CLI * Whether the current stream supports colored output. * * @var bool + * + * @deprecated 4.8.0 Unused. Colour support is detected per stream by `Output`. */ protected static $isColored = false; /** * Input and Output for CLI. + * + * @deprecated 4.8.0 Unused. The transport is held by `Output`. */ protected static ?InputOutput $io = null; + /** + * @deprecated 4.8.0 Unused. The target stream lives in `Output`. + */ protected static bool $stdoutToStderr = false; + private static ?Output $output = null; + + /** + * The progress bar of each output, so a bar in progress survives a temporary swap of the output. + * + * @var WeakMap|null + */ + private static ?WeakMap $progressBars = null; + /** * Static "constructor". * @@ -144,13 +161,6 @@ class CLI public static function init() { if (is_cli()) { - // clear segments & options to keep testing clean - static::$segments = []; - static::$options = []; - - // Check our stream resource for color support - static::$isColored = static::hasColorSupport(STDOUT); - $parser = new CommandLineParser(service('superglobals')->server('argv', [])); static::$segments = $parser->getArguments(); @@ -163,7 +173,33 @@ public static function init() define('STDOUT', 'php://output'); // @codeCoverageIgnore } - static::resetInputOutput(); + self::resetOutput(); + } + + /** + * Returns the terminal of the current output. + */ + public static function getTerminal(): Terminal + { + return static::getOutput()->getTerminal(); + } + + /** + * Returns the writer behind every output method of this facade. + */ + public static function getOutput(): Output + { + self::$output ??= new Output(); + + return self::$output; + } + + /** + * Replaces the writer behind every output method of this facade. + */ + public static function setOutput(Output $output): void + { + self::$output = $output; } /** @@ -176,7 +212,7 @@ public static function init() */ public static function input(?string $prefix = null): string { - return static::$io->input($prefix); + return static::getOutput()->getInputOutput()->input($prefix); } /** @@ -236,11 +272,12 @@ public static function prompt(string $field, $options = null, $validation = null $default = $options[0]; } - static::$lastWrite = 'write'; + $output = static::getOutput(); + $output->setCursorAtLineStart(true); // The reader renders the prompt itself, so readline redraws repaint it instead of erasing it. $prompt = sprintf('%s%s%s: ', $field, trim($field) !== '' ? ' ' : '', $extraOutput); - $input = trim(static::$io->input($prompt)); + $input = trim($output->getInputOutput()->input($prompt)); $input = $input === '' ? (string) $default : $input; if ($validation !== []) { @@ -270,13 +307,13 @@ public static function promptByKey($text, array $options, $validation = null): s throw new InvalidArgumentException('$text can only be of type string|array'); } - CLI::isZeroOptions($options); + self::isZeroOptions($options); if (($line = array_shift($text)) !== null) { - CLI::write($line); + static::write($line); } - CLI::printKeysAndValues($options); + self::printKeysAndValues($options); return static::prompt(PHP_EOL . array_shift($text), array_keys($options), $validation); } @@ -292,7 +329,7 @@ public static function promptByKey($text, array $options, $validation = null): s */ public static function promptByMultipleKeys(string $text, array $options): array { - CLI::isZeroOptions($options); + self::isZeroOptions($options); $extraOutputDefault = static::color('0', 'green'); $opts = $options; @@ -306,9 +343,9 @@ public static function promptByMultipleKeys(string $text, array $options): array $extraOutput = 'You can specify multiple values separated by commas.' . PHP_EOL . $extraOutput; } - CLI::write($text); - CLI::printKeysAndValues($options); - CLI::newLine(); + static::write($text); + self::printKeysAndValues($options); + static::newLine(); $input = static::prompt($extraOutput); $input = ($input === '') ? '0' : $input; // 0 is default @@ -329,7 +366,7 @@ public static function promptByMultipleKeys(string $text, array $options): array // it means user tried to access null value in $options if ($pattern < 1 || $maxOptions < $maxInput) { static::error('Please select correctly.'); - CLI::newLine(); + static::newLine(); $input = static::prompt($extraOutput); $input = ($input === '') ? '0' : $input; @@ -351,10 +388,6 @@ public static function promptByMultipleKeys(string $text, array $options): array return $input; } - // -------------------------------------------------------------------- - // Utility for promptBy... - // -------------------------------------------------------------------- - /** * Validation for $options in promptByKey() and promptByMultipleKeys(). Return an error if $options is an empty array. * @@ -379,14 +412,10 @@ private static function printKeysAndValues(array $options): void foreach ($options as $key => $description) { $name = str_pad(' [' . $key . '] ', $keyMaxLength + 4, ' '); - CLI::write(CLI::color($name, 'green') . CLI::wrap($description, 125, $keyMaxLength + 4)); + static::write(static::color($name, 'green') . static::wrap($description, 125, $keyMaxLength + 4)); } } - // -------------------------------------------------------------------- - // End Utility for promptBy... - // -------------------------------------------------------------------- - /** * Validate one prompt "field" at a time * @@ -424,13 +453,7 @@ protected static function validate(string $field, string $value, $rules): bool */ public static function print(string $text = '', ?string $foreground = null, ?string $background = null) { - if ((string) $foreground !== '' || (string) $background !== '') { - $text = static::color($text, $foreground, $background); - } - - static::$lastWrite = null; - - static::fwrite(STDOUT, $text); + static::getOutput()->print($text, $foreground ?? '', $background ?? ''); } /** @@ -440,17 +463,7 @@ public static function print(string $text = '', ?string $foreground = null, ?str */ public static function write(string $text = '', ?string $foreground = null, ?string $background = null) { - if ((string) $foreground !== '' || (string) $background !== '') { - $text = static::color($text, $foreground, $background); - } - - if (static::$lastWrite !== 'write') { - $text = PHP_EOL . $text; - - static::$lastWrite = 'write'; - } - - static::fwrite(STDOUT, $text . PHP_EOL); + static::getOutput()->write($text, $foreground ?? '', $background ?? ''); } /** @@ -460,25 +473,7 @@ public static function write(string $text = '', ?string $foreground = null, ?str */ public static function error(string $text, string $foreground = 'light_red', ?string $background = null) { - // Check color support for STDERR - $stdout = static::$isColored; - - static::$isColored = is_cli() && static::hasColorSupport(STDERR); - - if ($foreground !== '' || (string) $background !== '') { - $text = static::color($text, $foreground, $background); - } - - if (static::$lastWrite !== 'write') { - $text = PHP_EOL . $text; - - static::$lastWrite = 'write'; - } - - static::fwrite(is_cli() ? STDERR : STDOUT, $text . PHP_EOL); - - // return STDOUT color support - static::$isColored = $stdout; + static::getOutput()->error($text, $foreground, $background ?? ''); } /** @@ -490,7 +485,7 @@ public static function error(string $text, string $foreground = 'light_red', ?st */ public static function beep(int $num = 1) { - echo str_repeat("\x07", $num); + static::getOutput()->beep($num); } /** @@ -518,7 +513,7 @@ public static function wait(int $seconds, bool $countdown = false) sleep($seconds); } else { static::write(static::$waitMsg); - static::$io->input(); + static::getOutput()->getInputOutput()->input(); } } @@ -529,7 +524,6 @@ public static function wait(int $seconds, bool $countdown = false) */ public static function newLine(int $num = 1) { - // Do it once or more, write with empty string gives us a new line for ($i = 0; $i < $num; $i++) { static::write(); } @@ -542,11 +536,13 @@ public static function newLine(int $num = 1) */ public static function clearScreen() { - // Unix systems, and Windows with VT100 Terminal support (i.e. Win10) - // can handle CSI sequences. For lower than Win10 we just shove in 40 new lines. - is_windows() && is_cli() && ! static::streamSupports('sapi_windows_vt100_support', STDOUT) - ? static::newLine(40) - : static::fwrite(STDOUT, "\033[H\033[2J"); + if (is_windows() && is_cli() && ! static::streamSupports('sapi_windows_vt100_support', STDOUT)) { + static::newLine(40); + + return; + } + + static::fwrite(STDOUT, "\033[H\033[2J"); } /** @@ -562,71 +558,7 @@ public static function clearScreen() */ public static function color(string $text, string $foreground, ?string $background = null, ?string $format = null): string { - if (! static::$isColored || $text === '') { - return $text; - } - - if (! array_key_exists($foreground, static::$foreground_colors)) { - throw CLIException::forInvalidColor('foreground', $foreground); - } - - if ((string) $background !== '' && ! array_key_exists($background, static::$background_colors)) { - throw CLIException::forInvalidColor('background', $background); - } - - $newText = ''; - - // Detect if color method was already in use with this text - if (str_contains($text, "\033[0m")) { - $pattern = '/\\033\\[0;.+?\\033\\[0m/u'; - - preg_match_all($pattern, $text, $matches); - $coloredStrings = $matches[0]; - - // No colored string found. Invalid strings with no `\033[0;??`. - if ($coloredStrings === []) { - return $newText . self::getColoredText($text, $foreground, $background, $format); - } - - $nonColoredText = preg_replace( - $pattern, - '<<__colored_string__>>', - $text, - ); - $nonColoredChunks = preg_split( - '/<<__colored_string__>>/u', - $nonColoredText, - ); - - foreach ($nonColoredChunks as $i => $chunk) { - if ($chunk !== '') { - $newText .= self::getColoredText($chunk, $foreground, $background, $format); - } - - if (isset($coloredStrings[$i])) { - $newText .= $coloredStrings[$i]; - } - } - } else { - $newText .= self::getColoredText($text, $foreground, $background, $format); - } - - return $newText; - } - - private static function getColoredText(string $text, string $foreground, ?string $background, ?string $format): string - { - $string = "\033[" . static::$foreground_colors[$foreground] . 'm'; - - if ((string) $background !== '') { - $string .= "\033[" . static::$background_colors[$background] . 'm'; - } - - if ($format === 'underline') { - $string .= "\033[4m"; - } - - return $string . $text . "\033[0m"; + return static::getOutput()->color($text, $foreground, $background ?? '', $format ?? ''); } /** @@ -635,21 +567,7 @@ private static function getColoredText(string $text, string $foreground, ?string */ public static function strlen(?string $string): int { - if ((string) $string === '') { - return 0; - } - - foreach (static::$foreground_colors as $color) { - $string = strtr($string, ["\033[" . $color . 'm' => '']); - } - - foreach (static::$background_colors as $color) { - $string = strtr($string, ["\033[" . $color . 'm' => '']); - } - - $string = strtr($string, ["\033[4m" => '', "\033[0m" => '']); - - return mb_strwidth($string); + return Color::width((string) $string); } /** @@ -660,48 +578,17 @@ public static function strlen(?string $string): int */ public static function streamSupports(string $function, $resource): bool { - if (service('environment')->isTesting()) { - // In the current setup of the tests we cannot fully check - // if the stream supports the function since we are using - // filtered streams. - return function_exists($function); - } - - return function_exists($function) && @$function($resource); // @codeCoverageIgnore + return static::getTerminal()->streamSupports($function, $resource); } /** * Returns true if the stream resource supports colors. * - * This is tricky on Windows, because Cygwin, Msys2 etc. emulate pseudo - * terminals via named pipes, so we can only check the environment. - * - * Reference: https://github.com/composer/xdebug-handler/blob/master/src/Process.php - * * @param resource $resource */ public static function hasColorSupport($resource): bool { - // Follow https://no-color.org/ - if (isset($_SERVER['NO_COLOR']) || getenv('NO_COLOR') !== false) { // @phpstan-ignore codeigniter.superglobalsOffsetAccess (reads live $_SERVER, not the snapshot service) - return false; - } - - if (getenv('TERM_PROGRAM') === 'Hyper') { - return true; - } - - if (is_windows()) { - // @codeCoverageIgnoreStart - return static::streamSupports('sapi_windows_vt100_support', $resource) - || isset($_SERVER['ANSICON']) // @phpstan-ignore codeigniter.superglobalsOffsetAccess (reads live $_SERVER, not the snapshot service) - || getenv('ANSICON') !== false - || getenv('ConEmuANSI') === 'ON' - || getenv('TERM') === 'xterm'; - // @codeCoverageIgnoreEnd - } - - return static::streamSupports('stream_isatty', $resource); + return static::getTerminal()->hasColorSupport($resource); } /** @@ -709,11 +596,7 @@ public static function hasColorSupport($resource): bool */ public static function getWidth(int $default = 80): int { - if (static::$width === null) { - static::generateDimensions(); - } - - return (static::$width === null || static::$width === 0) ? $default : static::$width; + return static::getTerminal()->getWidth($default); } /** @@ -721,11 +604,7 @@ public static function getWidth(int $default = 80): int */ public static function getHeight(int $default = 32): int { - if (static::$height === null) { - static::generateDimensions(); - } - - return (static::$height === null || static::$height === 0) ? $default : static::$height; + return static::getTerminal()->getHeight($default); } /** @@ -735,74 +614,28 @@ public static function getHeight(int $default = 32): int */ public static function generateDimensions() { - try { - if (is_windows()) { - // Shells such as `Cygwin` and `Git bash` returns incorrect values - // when executing `mode CON`, so we use `tput` instead - if (getenv('TERM') || (($shell = getenv('SHELL')) && preg_match('/(?:bash|zsh)(?:\.exe)?$/', $shell))) { - static::$height = (int) exec('tput lines'); - static::$width = (int) exec('tput cols'); - } else { - $return = -1; - $output = []; - exec('mode CON', $output, $return); - - // Look for the next lines ending in ": " - // Searching for "Columns:" or "Lines:" will fail on non-English locales - if ($return === 0 && $output !== [] && preg_match('/:\s*(\d+)\n[^:]+:\s*(\d+)\n/', implode("\n", $output), $matches)) { - static::$height = (int) $matches[1]; - static::$width = (int) $matches[2]; - } - } - } elseif (($size = exec('stty size 2>/dev/null')) && preg_match('/(\d+)\s+(\d+)/', $size, $matches)) { - static::$height = (int) $matches[1]; - static::$width = (int) $matches[2]; - } else { - static::$height = (int) exec('tput lines 2>/dev/null'); - static::$width = (int) exec('tput cols 2>/dev/null'); - } - } catch (Throwable $e) { - // Reset the dimensions so that the default values will be returned later. - // Then let the developer know of the error. - static::$height = null; - static::$width = null; - log_message('error', (string) $e); - } + static::getTerminal()->generateDimensions(); } /** * Displays a progress bar on the CLI. You must call it repeatedly * to update it. Set $thisStep = false to erase the progress bar. * - * @param bool|int $thisStep + * @param false|float|int $thisStep * * @return void */ public static function showProgress($thisStep = 1, int $totalSteps = 10) { - static $inProgress = false; + $progressBar = self::getProgressBar(static::getOutput()); - // restore cursor position when progress is continuing. - if ($inProgress !== false && $inProgress <= $thisStep) { - static::fwrite(STDOUT, "\033[1A"); - } - $inProgress = $thisStep; - - if ($thisStep !== false) { - // Don't allow div by zero or negative numbers.... - $thisStep = abs($thisStep); - $totalSteps = max(1, $totalSteps); - - $percent = (int) (($thisStep / $totalSteps) * 100); - $step = (int) round($percent / 10); + if ($thisStep === false) { + $progressBar->clear(); - // Write the progress bar - static::fwrite(STDOUT, "[\033[32m" . str_repeat('#', $step) . str_repeat('.', 10 - $step) . "\033[0m]"); - // Textual representation... - static::fwrite(STDOUT, sprintf(' %3d%% Complete', $percent) . PHP_EOL); - } else { - static::fwrite(STDOUT, "\007"); + return; } + + $progressBar->advanceTo(abs($thisStep), max(1, $totalSteps)); } /** @@ -820,15 +653,8 @@ public static function wrap(?string $string = null, int $max = 0, int $padLeft = return ''; } - if ($max === 0) { - $max = self::getWidth(); - } - - if (self::getWidth() < $max) { - $max = self::getWidth(); - } - - $max -= $padLeft; + $width = static::getWidth(); + $max = ($max === 0 ? $width : min($max, $width)) - $padLeft; $lines = wordwrap($string, $max, PHP_EOL); @@ -1038,115 +864,29 @@ public static function getOptionString(bool $useLongOpts = false, bool $trim = f */ public static function table(array $tbody, array $thead = []) { - // All the rows in the table will be here until the end - $tableRows = []; - - // We need only indexes and not keys - if ($thead !== []) { - $tableRows[] = array_values($thead); - } - - foreach ($tbody as $tr) { - $tableRows[] = array_values($tr); - } - - // Yes, it really is necessary to know this count - $totalRows = count($tableRows); - - // Store all columns lengths - // $all_cols_lengths[row][column] = length - $allColsLengths = []; - - // Store maximum lengths by column - // $max_cols_lengths[column] = length - $maxColsLengths = []; - - // Read row by row and define the longest columns - for ($row = 0; $row < $totalRows; $row++) { - $column = 0; // Current column index - - foreach ($tableRows[$row] as $col) { - // Sets the size of this column in the current row - $allColsLengths[$row][$column] = static::strlen((string) $col); - - // If the current column does not have a value among the larger ones - // or the value of this is greater than the existing one - // then, now, this assumes the maximum length - if (! isset($maxColsLengths[$column]) || $allColsLengths[$row][$column] > $maxColsLengths[$column]) { - $maxColsLengths[$column] = $allColsLengths[$row][$column]; - } - - // We can go check the size of the next column... - $column++; - } - } - - // Read row by row and add spaces at the end of the columns - // to match the exact column length - for ($row = 0; $row < $totalRows; $row++) { - $column = 0; - - foreach ($tableRows[$row] as $col) { - $diff = $maxColsLengths[$column] - static::strlen((string) $col); - - if ($diff !== 0) { - $tableRows[$row][$column] .= str_repeat(' ', $diff); - } - - $column++; - } - } - - $table = ''; - $cols = ''; - - // Joins columns and append the well formatted rows to the table - for ($row = 0; $row < $totalRows; $row++) { - // Set the table border-top - if ($row === 0) { - $cols = '+'; - - foreach ($tableRows[$row] as $col) { - $cols .= str_repeat('-', static::strlen((string) $col) + 2) . '+'; - } - $table .= $cols . PHP_EOL; - } - - // Set the columns borders - $table .= '| ' . implode(' | ', $tableRows[$row]) . ' |' . PHP_EOL; - - // Set the thead and table borders-bottom - if (($row === 0 && $thead !== []) || ($row + 1 === $totalRows)) { - $table .= $cols . PHP_EOL; - } - } - - static::write($table); + static::write((new Table($tbody, $thead))->render()); } /** - * While the library is intended for use on CLI commands, - * commands can be called from controllers and elsewhere - * so we need a way to allow them to still work. - * - * For now, just echo the content, but look into a better - * solution down the road. + * Writes without touching the cursor state. * - * @param resource $handle + * @param resource|string $handle A stream, or the `STDOUT` placeholder string outside the CLI. * * @return void */ protected static function fwrite($handle, string $string) { - if (static::$stdoutToStderr && $handle === STDOUT) { - $handle = STDERR; + if ($handle === STDOUT) { + static::getOutput()->writeRaw($string); + + return; } - static::$io->fwrite($handle, $string); + static::getOutput()->getInputOutput()->fwrite($handle, $string); } /** - * Runs the callback with its STDOUT writes sent to STDERR. + * Runs the callback with the output swapped for one targeting STDERR, so the callback must not swap it again. * * @param callable(): void $callback * @@ -1160,19 +900,14 @@ public static function redirectToStderr(callable $callback): void return; } - $stdoutToStderr = static::$stdoutToStderr; - $isColored = static::$isColored; - $lastWrite = static::$lastWrite; + $previous = static::getOutput(); - static::$stdoutToStderr = true; - static::$isColored = static::hasColorSupport(STDERR); + static::setOutput($previous->forStderr()); try { $callback(); } finally { - static::$stdoutToStderr = $stdoutToStderr; - static::$isColored = $isColored; - static::$lastWrite = $lastWrite; + static::setOutput($previous); } } @@ -1183,16 +918,11 @@ public static function redirectToStderr(callable $callback): void */ public static function reset(): void { - static::$initialized = false; - static::$segments = []; - static::$options = []; - static::$lastWrite = null; - static::$height = null; - static::$width = null; - static::$isColored = static::hasColorSupport(STDOUT); - static::$stdoutToStderr = false; - - static::resetInputOutput(); + static::$initialized = false; + static::$segments = []; + static::$options = []; + + self::resetOutput(); } /** @@ -1202,47 +932,46 @@ public static function reset(): void */ public static function resetLastWrite(): void { - static::$lastWrite = null; + static::getOutput()->setCursorAtLineStart(false); } /** * @internal */ - public static function getLastWrite(): ?string + public static function getInputOutput(): InputOutput { - return static::$lastWrite; + return static::getOutput()->getInputOutput(); } /** * @internal */ - public static function setLastWrite(?string $value): void + public static function setInputOutput(InputOutput $io): void { - static::$lastWrite = $value; + static::getOutput()->setInputOutput($io); } /** * @internal */ - public static function getInputOutput(): ?InputOutput + public static function resetInputOutput(): void { - return static::$io; + static::setInputOutput(new InputOutput()); } - /** - * @internal - */ - public static function setInputOutput(InputOutput $io): void + private static function resetOutput(): void { - static::$io = $io; + self::$output = new Output(); + self::$progressBars = new WeakMap(); } - /** - * @internal - */ - public static function resetInputOutput(): void + private static function getProgressBar(Output $output): ProgressBar { - static::$io = new InputOutput(); + self::$progressBars ??= new WeakMap(); + + self::$progressBars[$output] ??= new ProgressBar($output); + + return self::$progressBars[$output]; } } diff --git a/system/CLI/Color.php b/system/CLI/Color.php new file mode 100644 index 000000000000..bb021e06b1eb --- /dev/null +++ b/system/CLI/Color.php @@ -0,0 +1,114 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI; + +use CodeIgniter\CLI\Exceptions\CLIException; + +/** + * ANSI colour codes for terminal text. + */ +final class Color +{ + private const FOREGROUND = [ + 'black' => '0;30', + 'dark_gray' => '1;30', + 'blue' => '0;34', + 'dark_blue' => '0;34', + 'light_blue' => '1;34', + 'green' => '0;32', + 'light_green' => '1;32', + 'cyan' => '0;36', + 'light_cyan' => '1;36', + 'red' => '0;31', + 'light_red' => '1;31', + 'purple' => '0;35', + 'light_purple' => '1;35', + 'yellow' => '0;33', + 'light_yellow' => '1;33', + 'light_gray' => '0;37', + 'white' => '1;37', + ]; + private const BACKGROUND = [ + 'black' => '40', + 'red' => '41', + 'green' => '42', + 'yellow' => '43', + 'blue' => '44', + 'magenta' => '45', + 'cyan' => '46', + 'light_gray' => '47', + ]; + private const RESET = "\033[0m"; + private const UNDERLINE = "\033[4m"; + + /** + * Text already coloured by an earlier `apply()`, which keeps its own codes. + */ + private const COLORED_SEGMENT = '/(\033\[[0-9;]*m.+?\033\[0m)/su'; + + /** + * Wraps the text in the codes for the given colours, leaving already coloured segments as they are. + * + * @param string $foreground A colour name, or `''` for no foreground code. + * @param string $format Only `underline` is understood. + * + * @throws CLIException + */ + public static function apply(string $text, string $foreground, string $background = '', string $format = ''): string + { + if ($text === '') { + return ''; + } + + if ($foreground !== '' && ! isset(self::FOREGROUND[$foreground])) { + throw CLIException::forInvalidColor('foreground', $foreground); + } + + if ($background !== '' && ! isset(self::BACKGROUND[$background])) { + throw CLIException::forInvalidColor('background', $background); + } + + $codes = sprintf( + '%s%s%s', + $foreground !== '' ? sprintf("\033[%sm", self::FOREGROUND[$foreground]) : '', + $background !== '' ? sprintf("\033[%sm", self::BACKGROUND[$background]) : '', + $format === 'underline' ? self::UNDERLINE : '', + ); + + if ($codes === '') { + return $text; + } + + $pieces = preg_split(self::COLORED_SEGMENT, $text, -1, PREG_SPLIT_DELIM_CAPTURE); + $output = ''; + + foreach ($pieces === false ? [$text] : $pieces as $index => $piece) { + if ($piece === '') { + continue; + } + + $output .= $index % 2 === 1 ? $piece : $codes . $piece . self::RESET; + } + + return $output; + } + + /** + * Returns the display width of the text without its colour codes. + */ + public static function width(string $string): int + { + return mb_strwidth((string) preg_replace('/\033\[[0-9;]*m/', '', $string)); + } +} diff --git a/system/CLI/Commands.php b/system/CLI/Commands.php index 70a8381e0d6e..0819246a2eff 100644 --- a/system/CLI/Commands.php +++ b/system/CLI/Commands.php @@ -133,20 +133,31 @@ public function runLegacy(string $command, array $params): int * * @param list $arguments * @param array|string|null> $options + * @param Output|null $output The output the command runs against, or `null` for `CLI::getOutput()`. */ - public function runCommand(string $command, array $arguments, array $options): int + public function runCommand(string $command, array $arguments, array $options, ?Output $output = null): int { - if (! $this->verifyCommand($command, legacy: false)) { + $output ??= CLI::getOutput(); + + if (! $this->verifyCommand($command, legacy: false, output: $output)) { return EXIT_ERROR; } - Events::trigger('pre_command'); + $previous = CLI::getOutput(); - $exitCode = $this->getCommand($command, legacy: false)->run($arguments, $options); + CLI::setOutput($output); - Events::trigger('post_command'); + try { + Events::trigger('pre_command'); - return $exitCode; + $exitCode = $this->getCommand($command, legacy: false)->run($arguments, $options, $output); + + Events::trigger('post_command'); + + return $exitCode; + } finally { + CLI::setOutput($previous); + } } /** @@ -358,8 +369,9 @@ private function registerAliases(): void * Verifies if the command being sought is found in the commands list. * * @param legacy_commands $commands (no longer used) + * @param Output|null $output The output the "not found" error is written to, or `null` for `CLI::getOutput()`. */ - public function verifyCommand(string $command, array $commands = [], bool $legacy = true): bool + public function verifyCommand(string $command, array $commands = [], bool $legacy = true, ?Output $output = null): bool { if ($commands !== []) { @trigger_error(sprintf('Since v4.8.0, the $commands parameter of %s() is no longer used.', __METHOD__), E_USER_DEPRECATED); @@ -386,7 +398,7 @@ public function verifyCommand(string $command, array $commands = [], bool $legac ); } - CLI::error($message); + ($output ?? CLI::getOutput())->error($message); return false; } diff --git a/system/CLI/Console.php b/system/CLI/Console.php index e913fef48840..7a020e0ad03d 100644 --- a/system/CLI/Console.php +++ b/system/CLI/Console.php @@ -92,7 +92,7 @@ public function run(array $tokens = []) return $commands->runLegacy($this->command, array_merge($arguments, $legacyOptions)); } - return $commands->runCommand($this->command, $arguments, $this->options); + return $commands->runCommand($this->command, $arguments, $this->options, CLI::getOutput()); } public function initialize(): static diff --git a/system/CLI/InputOutput.php b/system/CLI/InputOutput.php index 1155c78574af..98fe70256173 100644 --- a/system/CLI/InputOutput.php +++ b/system/CLI/InputOutput.php @@ -77,7 +77,7 @@ public function input(?string $prefix = null): string * For now, just echo the content, but look into a better * solution down the road. * - * @param resource $handle + * @param resource|string $handle A stream, or the `STDOUT` placeholder string outside the CLI. */ public function fwrite($handle, string $string): void { diff --git a/system/CLI/Output.php b/system/CLI/Output.php new file mode 100644 index 000000000000..3842abe9f1a8 --- /dev/null +++ b/system/CLI/Output.php @@ -0,0 +1,228 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI; + +/** + * Writer for the console, targeting STDOUT by default and STDERR for errors. + */ +class Output +{ + /** + * Whether the last text ended in a line break, so the next `write()` needs none before it. + */ + private bool $cursorAtLineStart = false; + + /** + * Whether the STDOUT writes go to STDERR instead, as in the copy made by `forStderr()`. + */ + private bool $toStderr = false; + + /** + * Colour support per stream id, detected on first use. + * + * @var array + */ + private array $colorSupport = []; + + public function __construct( + private InputOutput $io = new InputOutput(), + private readonly Terminal $terminal = new Terminal(), + ) { + // @codeCoverageIgnoreStart + if (! defined('STDOUT')) { + // STDOUT is undefined outside the CLI SAPI. + define('STDOUT', 'php://output'); + } + // @codeCoverageIgnoreEnd + } + + /** + * Writes the text as it is, leaving the cursor where the text ends. + */ + public function print(string $text = '', string $foreground = '', string $background = ''): void + { + $this->io->fwrite($this->stream(), $this->colorize($this->stream(), $text, $foreground, $background)); + + // A line feed ends both LF and CRLF, so this holds on every OS. + $this->cursorAtLineStart = str_ends_with($text, "\n"); + } + + /** + * Writes the text on its own line. + */ + public function write(string $text = '', string $foreground = '', string $background = ''): void + { + $this->io->fwrite($this->stream(), $this->line($this->colorize($this->stream(), $text, $foreground, $background))); + } + + /** + * Writes the text on its own line to STDERR, or to STDOUT outside the CLI. + */ + public function error(string $text, string $foreground = 'light_red', string $background = ''): void + { + $stream = is_cli() ? STDERR : STDOUT; + + $this->io->fwrite($stream, $this->line($this->colorize($stream, $text, $foreground, $background))); + } + + /** + * @param positive-int $num The number of blank lines. + */ + public function newLine(int $num = 1): void + { + for ($i = 0; $i < $num; $i++) { + $this->write(); + } + } + + /** + * @param positive-int $num The number of bells. + */ + public function beep(int $num = 1): void + { + $this->writeRaw(str_repeat("\x07", $num)); + } + + /** + * Clears the screen with a CSI sequence, or with blank lines on a Windows console without VT100 support. + */ + public function clearScreen(): void + { + if (is_windows() && is_cli() && ! $this->terminal->streamSupports('sapi_windows_vt100_support', $this->stream())) { + $this->newLine(40); + + return; + } + + $this->writeRaw("\033[H\033[2J"); + } + + /** + * Writes the text to the target stream without colouring it or touching the cursor state. + * + * @internal + */ + public function writeRaw(string $text): void + { + $this->io->fwrite($this->stream(), $text); + } + + /** + * Returns the text coloured for the target stream, or unchanged when the stream does not support colours. + * + * @param string $format Only `underline` is understood. + */ + public function color(string $text, string $foreground, string $background = '', string $format = ''): string + { + return $this->isDecorated() ? Color::apply($text, $foreground, $background, $format) : $text; + } + + /** + * Whether the target stream supports colours. + */ + public function isDecorated(): bool + { + return $this->supportsColor($this->stream()); + } + + /** + * Returns a copy whose STDOUT writes target STDERR. + */ + public function forStderr(): static + { + $stderrOutput = clone $this; + + $stderrOutput->toStderr = true; + + return $stderrOutput; + } + + public function getTerminal(): Terminal + { + return $this->terminal; + } + + /** + * @internal + */ + public function getInputOutput(): InputOutput + { + return $this->io; + } + + /** + * @internal + */ + public function setInputOutput(InputOutput $io): void + { + $this->io = $io; + } + + /** + * @internal + */ + public function setCursorAtLineStart(bool $cursorAtLineStart): void + { + $this->cursorAtLineStart = $cursorAtLineStart; + } + + /** + * Returns the text on a fresh line and marks the cursor as being at the start of the next one. + */ + private function line(string $text): string + { + $prefix = $this->cursorAtLineStart ? '' : PHP_EOL; + + $this->cursorAtLineStart = true; + + return $prefix . $text . PHP_EOL; + } + + /** + * @param resource $stream + */ + private function colorize($stream, string $text, string $foreground, string $background): string + { + if (($foreground === '' && $background === '') || ! $this->supportsColor($stream)) { + return $text; + } + + return Color::apply($text, $foreground, $background); + } + + /** + * @param resource $stream + */ + private function supportsColor($stream): bool + { + if (! is_cli()) { + return false; + } + + $id = get_resource_id($stream); + + $this->colorSupport[$id] ??= $this->terminal->hasColorSupport($stream); + + return $this->colorSupport[$id]; + } + + /** + * @return resource + */ + private function stream() + { + // Outside the CLI, STDERR is undefined and the transport echoes whatever the handle is. + return $this->toStderr && is_cli() ? STDERR : STDOUT; + } +} diff --git a/system/CLI/ProgressBar.php b/system/CLI/ProgressBar.php new file mode 100644 index 000000000000..7f23abcbeecc --- /dev/null +++ b/system/CLI/ProgressBar.php @@ -0,0 +1,76 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI; + +use CodeIgniter\Exceptions\InvalidArgumentException; + +/** + * Ten-cell progress bar redrawn in place on the console. + */ +final class ProgressBar +{ + private const CELLS = 10; + + /** + * The step drawn last, or `null` when no bar is on screen. + */ + private float|int|null $step = null; + + public function __construct(private readonly Output $output) + { + } + + /** + * Draws the bar for the step, capped at 100%, over the previous bar when the step did not go backwards. + * + * @param float|int<0, max> $step + * + * @throws InvalidArgumentException + */ + public function advanceTo(float|int $step, int $totalSteps = 10): void + { + if ($step < 0) { + throw new InvalidArgumentException(sprintf('The step must be zero or more, %s given.', $step)); + } + + if ($totalSteps < 1) { + throw new InvalidArgumentException(sprintf('The total steps must be one or more, %d given.', $totalSteps)); + } + + $redraw = $this->step !== null && $this->step <= $step; + $this->step = $step; + + $percent = min(100, (int) ($step / $totalSteps * 100)); + $filled = (int) round($percent / 10); + + $this->output->print(sprintf( + "%s[\033[32m%s%s\033[0m] %3d%% Complete%s", + $redraw ? "\033[1A" : '', + str_repeat('#', $filled), + str_repeat('.', self::CELLS - $filled), + $percent, + PHP_EOL, + )); + } + + /** + * Ends the bar with a bell, so the next `advanceTo()` draws a new one. + */ + public function clear(): void + { + $this->output->beep(); + + $this->step = null; + } +} diff --git a/system/CLI/Table.php b/system/CLI/Table.php new file mode 100644 index 000000000000..27b46ff31104 --- /dev/null +++ b/system/CLI/Table.php @@ -0,0 +1,117 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI; + +/** + * Text table with `+---+` borders and `|` separated cells. + */ +final readonly class Table +{ + /** + * @param list> $tbody List of rows + * @param list $thead List of columns + */ + public function __construct( + private array $tbody, + private array $thead = [], + ) { + } + + /** + * Returns the table as lines ending in `PHP_EOL`, or an empty string for no rows. + */ + public function render(): string + { + $rows = $this->rows(); + + if ($rows === []) { + return ''; + } + + $widths = $this->columnWidths($rows); + $border = $this->border($widths); + $output = $border; + $last = array_key_last($rows); + + foreach ($rows as $index => $row) { + $output .= $this->row($row, $widths); + + if (($index === 0 && $this->thead !== []) || $index === $last) { + $output .= $border; + } + } + + return $output; + } + + /** + * @return list> + */ + private function rows(): array + { + $rows = $this->thead === [] ? [] : [$this->thead]; + + foreach ($this->tbody as $row) { + $rows[] = $row; + } + + return array_map( + static fn (array $row): array => array_map(static fn (mixed $cell): string => (string) $cell, array_values($row)), + $rows, + ); + } + + /** + * @param list> $rows + * + * @return list + */ + private function columnWidths(array $rows): array + { + $widths = []; + + foreach ($rows as $row) { + foreach ($row as $column => $cell) { + $widths[$column] = max($widths[$column] ?? 0, Color::width($cell)); + } + } + + return $widths; + } + + /** + * @param list $widths + */ + private function border(array $widths): string + { + $segments = array_map(static fn (int $width): string => str_repeat('-', $width + 2) . '+', $widths); + + return '+' . implode('', $segments) . PHP_EOL; + } + + /** + * @param list $row + * @param list $widths + */ + private function row(array $row, array $widths): string + { + $cells = []; + + foreach ($row as $column => $cell) { + $cells[] = $cell . str_repeat(' ', $widths[$column] - Color::width($cell)); + } + + return sprintf('| %s |%s', implode(' | ', $cells), PHP_EOL); + } +} diff --git a/system/CLI/Terminal.php b/system/CLI/Terminal.php new file mode 100644 index 000000000000..fe57d4d6129c --- /dev/null +++ b/system/CLI/Terminal.php @@ -0,0 +1,168 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI; + +use Throwable; + +/** + * The capabilities and dimensions of the terminal behind the standard streams. + */ +class Terminal +{ + /** + * Rows reported by the terminal, or `0` when it reports none. + * + * @var non-negative-int + */ + private int $height = 0; + + /** + * Columns reported by the terminal, or `0` when it reports none. + * + * @var non-negative-int + */ + private int $width = 0; + + private bool $detected = false; + + /** + * Returns the number of columns, or the default when the terminal does not report them. + */ + public function getWidth(int $default = 80): int + { + if (! $this->detected) { + $this->generateDimensions(); + } + + return $this->width === 0 ? $default : $this->width; + } + + /** + * Returns the number of rows, or the default when the terminal does not report them. + */ + public function getHeight(int $default = 32): int + { + if (! $this->detected) { + $this->generateDimensions(); + } + + return $this->height === 0 ? $default : $this->height; + } + + /** + * Reads the dimensions from the terminal, logging the error and keeping the defaults when that fails. + */ + public function generateDimensions(): void + { + $this->detected = true; + + try { + [$this->height, $this->width] = is_windows() ? $this->windowsDimensions() : $this->unixDimensions(); + } catch (Throwable $e) { + $this->height = 0; + $this->width = 0; + + log_message('error', (string) $e); + } + } + + /** + * Whether the stream supports the given capability function, such as `stream_isatty`. + * + * @param resource $stream + */ + public function streamSupports(string $function, $stream): bool + { + if (service('environment')->isTesting()) { + return function_exists($function); + } + + return function_exists($function) && @$function($stream); // @codeCoverageIgnore + } + + /** + * Whether the stream supports colours, following https://no-color.org/ and the Windows console emulators. + * + * @param resource $stream + */ + public function hasColorSupport($stream): bool + { + if ($this->hasEnv('NO_COLOR')) { + return false; + } + + if (getenv('TERM_PROGRAM') === 'Hyper') { + return true; + } + + if (is_windows()) { + return $this->streamSupports('sapi_windows_vt100_support', $stream) + || $this->hasEnv('ANSICON') + || getenv('ConEmuANSI') === 'ON' + || getenv('TERM') === 'xterm'; + } + + return $this->streamSupports('stream_isatty', $stream); + } + + /** + * Whether the variable is set in the live `$_SERVER` or the process environment. + */ + private function hasEnv(string $name): bool + { + return isset($_SERVER[$name]) || getenv($name) !== false; // @phpstan-ignore codeigniter.superglobalsOffsetAccess (reads live $_SERVER, not the snapshot service) + } + + /** + * @return array{non-negative-int, non-negative-int} + */ + private function unixDimensions(): array + { + $size = exec('stty size 2>/dev/null'); + + if ($size !== false && preg_match('/(\d+)\s+(\d+)/', $size, $matches) === 1) { + return [(int) $matches[1], (int) $matches[2]]; + } + + return [(int) exec('tput lines 2>/dev/null'), (int) exec('tput cols 2>/dev/null')]; + } + + /** + * Shells such as Cygwin and Git Bash answer `mode CON` wrongly, so a shell variable or `TERM` selects `tput`. + * + * @return array{non-negative-int, non-negative-int} + * + * @codeCoverageIgnore + */ + private function windowsDimensions(): array + { + $term = getenv('TERM'); + $shell = getenv('SHELL'); + + if (($term !== false && $term !== '') || ($shell !== false && preg_match('/(?:bash|zsh)(?:\.exe)?$/', $shell) === 1)) { + return [(int) exec('tput lines'), (int) exec('tput cols')]; + } + + $output = []; + $status = -1; + + exec('mode CON', $output, $status); + + if ($status === 0 && $output !== [] && preg_match('/:\s*(\d+)\n[^:]+:\s*(\d+)\n/', implode("\n", $output), $matches) === 1) { + return [(int) $matches[1], (int) $matches[2]]; + } + + return [0, 0]; + } +} diff --git a/tests/_support/CLI/RecordingInputOutput.php b/tests/_support/CLI/RecordingInputOutput.php new file mode 100644 index 000000000000..876df53d1b39 --- /dev/null +++ b/tests/_support/CLI/RecordingInputOutput.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\CLI; + +use CodeIgniter\CLI\InputOutput; + +/** + * Transport that records each write with its stream and answers every prompt with a fixed value. + */ +final class RecordingInputOutput extends InputOutput +{ + /** + * @var list + */ + public array $writes = []; + + public ?string $receivedPrefix = null; + + public function __construct(private readonly string $answer = '') + { + parent::__construct(); + } + + public function input(?string $prefix = null): string + { + $this->receivedPrefix = $prefix; + + return $this->answer; + } + + public function fwrite($handle, string $string): void + { + $this->writes[] = [$handle === STDERR ? 'STDERR' : 'STDOUT', $string]; + } +} diff --git a/tests/_support/Commands/Modern/CallsAboutCommand.php b/tests/_support/Commands/Modern/CallsAboutCommand.php new file mode 100644 index 000000000000..cbae681ea13f --- /dev/null +++ b/tests/_support/Commands/Modern/CallsAboutCommand.php @@ -0,0 +1,26 @@ + + * + * 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; + +#[Command(name: 'test:calls-about', description: 'Fixture that calls app:about.', group: 'Fixtures')] +final class CallsAboutCommand extends AbstractCommand +{ + protected function execute(array $arguments, array $options): int + { + return $this->call('app:about', ['a']); + } +} diff --git a/tests/_support/Commands/Modern/OutputProbeCommand.php b/tests/_support/Commands/Modern/OutputProbeCommand.php new file mode 100644 index 000000000000..dcf311314be7 --- /dev/null +++ b/tests/_support/Commands/Modern/OutputProbeCommand.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Commands\Modern; + +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; +use CodeIgniter\CLI\CLI; +use CodeIgniter\CLI\Output; + +#[Command(name: 'test:output-probe', description: 'Fixture that writes through the output and the facade.', group: 'Fixtures')] +final class OutputProbeCommand extends AbstractCommand +{ + public ?Output $seen = null; + + protected function execute(array $arguments, array $options): int + { + $this->seen = $this->getOutput(); + + $this->getOutput()->write('from output'); + CLI::write('from facade'); + + return EXIT_SUCCESS; + } +} diff --git a/tests/_support/Commands/Modern/SilentCallCommand.php b/tests/_support/Commands/Modern/SilentCallCommand.php new file mode 100644 index 000000000000..b51a55bacced --- /dev/null +++ b/tests/_support/Commands/Modern/SilentCallCommand.php @@ -0,0 +1,34 @@ + + * + * 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\Output; + +#[Command(name: 'test:silent-call', description: 'Fixture that calls help silently, then writes.', group: 'Fixtures')] +final class SilentCallCommand extends AbstractCommand +{ + public ?Output $afterSilentCall = null; + + protected function execute(array $arguments, array $options): int + { + $this->callSilently('help'); + + $this->afterSilentCall = $this->getOutput(); + $this->getOutput()->write('after'); + + return EXIT_SUCCESS; + } +} diff --git a/tests/system/CLI/AbstractCommandTest.php b/tests/system/CLI/AbstractCommandTest.php index 853e67a81e4f..cd338702633e 100644 --- a/tests/system/CLI/AbstractCommandTest.php +++ b/tests/system/CLI/AbstractCommandTest.php @@ -37,14 +37,16 @@ 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\CallsAboutCommand; use Tests\Support\Commands\Modern\HeaderlessCommand; use Tests\Support\Commands\Modern\HiddenCommand; use Tests\Support\Commands\Modern\InteractFixtureCommand; use Tests\Support\Commands\Modern\InteractiveStateProbeCommand; +use Tests\Support\Commands\Modern\OutputProbeCommand; use Tests\Support\Commands\Modern\ParentCallsInteractFixtureCommand; use Tests\Support\Commands\Modern\PromptingFixtureCommand; +use Tests\Support\Commands\Modern\SilentCallCommand; use Tests\Support\Commands\Modern\TestFixtureCommand; use Tests\Support\Commands\Modern\UnavailableFixtureCommand; use Throwable; @@ -300,17 +302,15 @@ public function testCallSilentlyRestoresPriorIo(): void $this->assertSame($custom, CLI::getInputOutput()); } - public function testCallSilentlyResetsToFreshInputOutputWhenPriorWasNull(): void + public function testCallSilentlyRestoresPriorOutput(): void { - $property = new ReflectionProperty(CLI::class, 'io'); - $property->setValue(null, null); + $prior = CLI::getOutput(); $command = new AppAboutCommand(new Commands()); $command->helpMeSilently(); - $current = CLI::getInputOutput(); - $this->assertInstanceOf(InputOutput::class, $current); - $this->assertNotInstanceOf(NullInputOutput::class, $current); + $this->assertSame($prior, CLI::getOutput()); + $this->assertNotInstanceOf(NullInputOutput::class, CLI::getInputOutput()); } public function testCallSilentlyPropagatesSubCommandNonZeroExitCode(): void @@ -321,17 +321,93 @@ public function testCallSilentlyPropagatesSubCommandNonZeroExitCode(): void $this->assertSame('', $this->getStreamFilterBuffer()); } - public function testCallSilentlyRestoresPriorLastWriteState(): void + public function testCallSilentlyLeavesTheParentCursorStateAlone(): void { - CLI::setLastWrite(null); + CLI::write('before'); $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.', - ); + CLI::write('after'); + + $this->assertSame("\nbefore\nafter\n", $this->getUndecoratedBuffer()); + } + + public function testRunWithOutputWritesToItAndRestoresTheFacade(): void + { + $io = new MockInputOutput(); + $output = new Output($io); + $previous = CLI::getOutput(); + $command = new OutputProbeCommand(new Commands()); + + $this->assertSame(EXIT_SUCCESS, $command->run([], [], $output)); + $this->assertSame($output, $command->seen); + $this->assertSame($previous, CLI::getOutput()); + $this->assertSame("\nfrom output\nfrom facade\n", $io->getOutput()); + $this->assertSame('', $this->getStreamFilterBuffer()); + } + + public function testRunWithoutOutputUsesTheFacadeOutput(): void + { + $command = new OutputProbeCommand(new Commands()); + + $command->run([], []); + + $this->assertSame(CLI::getOutput(), $command->seen); + $this->assertSame("\nfrom output\nfrom facade\n", $this->getUndecoratedBuffer()); + } + + public function testRunWithOutputRestoresTheFacadeWhenTheCommandIsUnavailable(): void + { + $previous = CLI::getOutput(); + $command = new UnavailableFixtureCommand(new Commands()); + + UnavailableFixtureCommand::$available = false; + + try { + $command->run([], [], new Output(new MockInputOutput())); + $this->fail('CommandNotAvailableException was not thrown.'); + } catch (CommandNotAvailableException) { + $this->assertSame($previous, CLI::getOutput()); + } + } + + public function testNestedCallInheritsThePassedOutput(): void + { + $io = new MockInputOutput(); + $command = new CallsAboutCommand(new Commands()); + + $this->assertSame(EXIT_SUCCESS, $command->run([], [], new Output($io))); + $this->assertStringContainsString('CodeIgniter Version:', $io->getOutput()); + $this->assertSame('', $this->getStreamFilterBuffer()); + } + + public function testCallSilentlyInsideRunRestoresThePassedOutput(): void + { + $io = new MockInputOutput(); + $output = new Output($io); + $command = new SilentCallCommand(new Commands()); + + $command->run([], [], $output); + + $this->assertSame($output, $command->afterSilentCall); + $this->assertSame("\nafter\n", $io->getOutput()); + $this->assertSame('', $this->getStreamFilterBuffer()); + } + + public function testGetOutputFallsBackToTheFacadeBetweenRuns(): void + { + $output = new Output(new MockInputOutput()); + $command = new OutputProbeCommand(new Commands()); + $getOutput = $this->getPrivateMethodInvoker($command, 'getOutput'); + + $this->assertSame(CLI::getOutput(), $getOutput()); + + $command->run([], [], $output); + + $this->assertSame($output, $command->seen); + $this->assertSame(CLI::getOutput(), $getOutput()); + $this->assertNotSame($output, $getOutput()); } public function testCallSilentlyForwardsNoInteractionOverrideFalseToChild(): void @@ -1254,6 +1330,29 @@ public function testPromptingIsSkippedWithNullInputOutput(): void $command->run([], []); } + public function testPromptingIsSkippedWithAPassedNullOutput(): void + { + $command = new PromptingFixtureCommand(new Commands()); + $command->setInteractive(true); + + $this->expectException(ArgumentCountMismatchException::class); + + $command->run([], [], new Output(new NullInputOutput())); + } + + public function testPromptsReadFromThePassedOutput(): void + { + $io = new MockInputOutput(); + $io->setInputs(['alpha', 'beta']); + + $command = new PromptingFixtureCommand(new Commands()); + $command->setInteractive(true); + + $this->assertSame(EXIT_SUCCESS, $command->run([], [], new Output($io))); + $this->assertSame(['first' => 'alpha', 'second' => 'beta'], PromptingFixtureCommand::$receivedArguments); + $this->assertStringContainsString('What is the second value?', $io->getOutput()); + } + public function testNonImplementingCommandIsNotPrompted(): void { $command = new TestFixtureCommand(new Commands()); diff --git a/tests/system/CLI/CLITest.php b/tests/system/CLI/CLITest.php index 233724114c3f..4473654ca0ee 100644 --- a/tests/system/CLI/CLITest.php +++ b/tests/system/CLI/CLITest.php @@ -23,7 +23,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\RequiresOperatingSystem; -use ReflectionProperty; +use Tests\Support\CLI\RecordingInputOutput; /** * @internal @@ -59,16 +59,161 @@ public function testNew(): void public function testBeep(): void { - $this->expectOutputString("\x07"); - CLI::beep(); + + $this->assertSame("\x07", $this->getStreamFilterBuffer()); } public function testBeep4(): void { - $this->expectOutputString("\x07\x07\x07\x07"); - CLI::beep(4); + + $this->assertSame("\x07\x07\x07\x07", $this->getStreamFilterBuffer()); + } + + public function testBeepIsSilencedByANullInputOutput(): void + { + CLI::setInputOutput(new NullInputOutput()); + + CLI::beep(); + + $this->assertSame('', $this->getStreamFilterBuffer()); + } + + public function testGetOutputReturnsTheSameInstanceUntilReset(): void + { + $output = CLI::getOutput(); + + $this->assertSame($output, CLI::getOutput()); + $this->assertSame(CLI::getTerminal(), $output->getTerminal()); + + CLI::reset(); + + $this->assertNotSame($output, CLI::getOutput()); + $this->assertNotSame($output->getTerminal(), CLI::getTerminal()); + } + + public function testInitRebuildsTheOutput(): void + { + $output = CLI::getOutput(); + + CLI::init(); + + $this->assertNotSame($output, CLI::getOutput()); + } + + public function testSetOutputRebindsTheFacadeAndTheProgressBar(): void + { + CLI::showProgress(1, 20); + + $io = new MockInputOutput(); + CLI::setOutput(new Output($io, CLI::getTerminal())); + + CLI::write('after'); + CLI::showProgress(10, 20); + + $this->assertSame( + PHP_EOL . 'after' . PHP_EOL . "[\033[32m#####.....\033[0m] 50% Complete" . PHP_EOL, + $io->getOutput(), + ); + $this->assertSame("[\033[32m#.........\033[0m] 5% Complete" . PHP_EOL, $this->getStreamFilterBuffer()); + } + + public function testProgressBarSurvivesATemporaryOutputSwap(): void + { + CLI::showProgress(1, 20); + CLI::redirectToStderr(static function (): void { + CLI::write('aside'); + }); + CLI::showProgress(10, 20); + + $this->assertSame( + "[\033[32m#.........\033[0m] 5% Complete" . PHP_EOL + . 'aside' . PHP_EOL + . "\033[1A[\033[32m#####.....\033[0m] 50% Complete" . PHP_EOL, + $this->getStreamFilterBuffer(), + ); + } + + public function testRawFacadeWritesToStderrReachTheTransportUnchanged(): void + { + $io = new RecordingInputOutput(); + CLI::setInputOutput($io); + + $fwrite = $this->getPrivateMethodInvoker(CLI::class, 'fwrite'); + + CLI::write('before'); + $fwrite(STDERR, 'raw'); + CLI::write('after'); + + $this->assertSame([ + ['STDOUT', "\nbefore\n"], + ['STDERR', 'raw'], + ['STDOUT', "after\n"], + ], $io->writes); + } + + public function testSetOutputWithTheSameInstanceKeepsTheProgressBar(): void + { + CLI::showProgress(1, 20); + CLI::setOutput(CLI::getOutput()); + CLI::showProgress(10, 20); + + $this->assertSame( + "[\033[32m#.........\033[0m] 5% Complete" . PHP_EOL . + "\033[1A[\033[32m#####.....\033[0m] 50% Complete" . PHP_EOL, + $this->getStreamFilterBuffer(), + ); + } + + public function testSetOutputSwitchesTheTerminalWithIt(): void + { + $terminal = $this->createStub(Terminal::class); + $terminal->method('getWidth')->willReturn(12); + + CLI::setOutput(new Output(new MockInputOutput(), $terminal)); + + $this->assertSame($terminal, CLI::getTerminal()); + $this->assertSame(12, CLI::getWidth()); + $this->assertSame('1234 5678 90' . PHP_EOL . 'abc', CLI::wrap('1234 5678 90 abc')); + } + + public function testRedirectToStderrAppliesToRawFacadeWrites(): void + { + $io = new RecordingInputOutput(); + CLI::setInputOutput($io); + + $fwrite = $this->getPrivateMethodInvoker(CLI::class, 'fwrite'); + + $fwrite(STDOUT, 'before'); + CLI::redirectToStderr(static function () use ($fwrite): void { + $fwrite(STDOUT, 'inside'); + CLI::beep(); + }); + $fwrite(STDOUT, 'after'); + + $this->assertSame([ + ['STDOUT', 'before'], + ['STDERR', 'inside'], + ['STDERR', "\x07"], + ['STDOUT', 'after'], + ], $io->writes); + } + + public function testSetInputOutputChangesTheTransportOfTheCurrentOutput(): void + { + $output = CLI::getOutput(); + $io = new MockInputOutput(); + + CLI::setInputOutput($io); + + $this->assertSame($output, CLI::getOutput()); + $this->assertSame($io, CLI::getInputOutput()); + $this->assertSame($io, $output->getInputOutput()); + + CLI::resetInputOutput(); + + $this->assertNotSame($io, $output->getInputOutput()); } /** @@ -146,16 +291,7 @@ public function testPromptInputZero(): void public function testPromptPassesPromptTextToInputReader(): void { - $io = new class () extends InputOutput { - public ?string $receivedPrefix = null; - - public function input(?string $prefix = null): string - { - $this->receivedPrefix = $prefix; - - return 'red'; - } - }; + $io = new RecordingInputOutput('red'); CLI::setInputOutput($io); $output = CLI::prompt('What is your favorite color?'); @@ -168,16 +304,7 @@ public function input(?string $prefix = null): string public function testPromptPassesDefaultOptionInPromptText(): void { - $io = new class () extends InputOutput { - public ?string $receivedPrefix = null; - - public function input(?string $prefix = null): string - { - $this->receivedPrefix = $prefix; - - return ''; - } - }; + $io = new RecordingInputOutput(); CLI::setInputOutput($io); $output = CLI::prompt('What is your favorite color?', 'red'); @@ -193,16 +320,7 @@ public function input(?string $prefix = null): string public function testPromptByKeyPassesPromptTextToInputReader(): void { - $io = new class () extends InputOutput { - public ?string $receivedPrefix = null; - - public function input(?string $prefix = null): string - { - $this->receivedPrefix = $prefix; - - return '1'; - } - }; + $io = new RecordingInputOutput('1'); CLI::setInputOutput($io); $output = CLI::promptByKey('Select your hobbies:', ['Playing game', 'Sleep', 'Badminton']); @@ -218,16 +336,7 @@ public function input(?string $prefix = null): string public function testPromptByMultipleKeysPassesPromptTextToInputReader(): void { - $io = new class () extends InputOutput { - public ?string $receivedPrefix = null; - - public function input(?string $prefix = null): string - { - $this->receivedPrefix = $prefix; - - return '0,1'; - } - }; + $io = new RecordingInputOutput('0,1'); CLI::setInputOutput($io); $output = CLI::promptByMultipleKeys('Select your hobbies:', ['Playing game', 'Sleep', 'Badminton']); @@ -555,46 +664,31 @@ public function testWriteBackground(): void $this->assertSame($expected, $this->getStreamFilterBuffer()); } - public function testGetLastWriteReturnsNullAfterReset(): void + public function testResetLastWriteMakesTheNextWriteStartOnAFreshLine(): void { - CLI::resetLastWrite(); - - $this->assertNull(CLI::getLastWrite()); - } - - public function testGetLastWriteReflectsPriorWrite(): void - { - CLI::resetLastWrite(); CLI::write('hello'); + CLI::resetLastWrite(); + CLI::write('world'); - $this->assertSame('write', CLI::getLastWrite()); + $this->assertSame(PHP_EOL . 'hello' . PHP_EOL . PHP_EOL . 'world' . PHP_EOL, $this->getStreamFilterBuffer()); } - public function testGetLastWriteReflectsPriorPrint(): void + public function testWriteAfterPrintStartsOnAFreshLine(): void { - CLI::resetLastWrite(); CLI::write('hello'); CLI::print('world'); + CLI::write('again'); - $this->assertNull(CLI::getLastWrite()); + $this->assertSame(PHP_EOL . 'hello' . PHP_EOL . 'world' . PHP_EOL . 'again' . PHP_EOL, $this->getStreamFilterBuffer()); } - public function testSetLastWriteRoundTrips(): void + public function testWriteAfterPrintEndingInANewlineAddsNoBlankLine(): 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'); + CLI::print('world' . PHP_EOL); + CLI::write('again'); - $this->assertSame('hello' . PHP_EOL, $this->getStreamFilterBuffer()); + $this->assertSame(PHP_EOL . 'hello' . PHP_EOL . 'world' . PHP_EOL . 'again' . PHP_EOL, $this->getStreamFilterBuffer()); } public function testError(): void @@ -607,17 +701,7 @@ public function testError(): void public function testErrorOutsideCliWritesToStdoutWithoutColor(): void { - $io = new class () extends InputOutput { - /** - * @var list - */ - public array $writes = []; - - public function fwrite($handle, string $string): void - { - $this->writes[] = [$handle === STDERR ? 'STDERR' : 'STDOUT', $string]; - } - }; + $io = new RecordingInputOutput(); CLI::setInputOutput($io); is_cli(false); @@ -651,17 +735,7 @@ public function testMixedWriteError(): void public function testRedirectToStderrRoutesStdoutWritesOnlyWhileTheCallbackRuns(): void { - $io = new class () extends InputOutput { - /** - * @var list - */ - public array $writes = []; - - public function fwrite($handle, string $string): void - { - $this->writes[] = [$handle === STDERR ? 'STDERR' : 'STDOUT', $string]; - } - }; + $io = new RecordingInputOutput(); CLI::setInputOutput($io); CLI::print('before'); @@ -688,17 +762,7 @@ public function fwrite($handle, string $string): void public function testRedirectToStderrDoesNotRedirectOutsideCli(): void { - $io = new class () extends InputOutput { - /** - * @var list - */ - public array $writes = []; - - public function fwrite($handle, string $string): void - { - $this->writes[] = [$handle === STDERR ? 'STDERR' : 'STDOUT', $string]; - } - }; + $io = new RecordingInputOutput(); CLI::setInputOutput($io); is_cli(false); @@ -719,7 +783,7 @@ public function testRedirectToStderrUsesTheColorSupportOfStderr(): void $superglobals = service('superglobals'); $noColor = $superglobals->server('NO_COLOR'); - (new ReflectionProperty(CLI::class, 'isColored'))->setValue(null, true); + $this->assertTrue(CLI::getOutput()->isDecorated()); $superglobals->setServer('NO_COLOR', '1'); try { @@ -739,6 +803,20 @@ public function testRedirectToStderrUsesTheColorSupportOfStderr(): void $this->assertSame("\ninside\n\n\033[0;31mafter\033[0m\n", $this->getStreamFilterBuffer()); } + public function testRedirectToStderrRestoresThePreviousOutput(): void + { + $previous = CLI::getOutput(); + $inside = null; + + CLI::redirectToStderr(static function () use (&$inside): void { + $inside = CLI::getOutput(); + }); + + $this->assertInstanceOf(Output::class, $inside); + $this->assertNotSame($previous, $inside); + $this->assertSame($previous, CLI::getOutput()); + } + public function testErrorForeground(): void { CLI::error('test', 'purple'); @@ -781,6 +859,16 @@ public function testShowProgress(): void $this->assertSame($expected, $this->getStreamFilterBuffer()); } + public function testShowProgressToleratesNegativeAndZeroValues(): void + { + CLI::showProgress(-5, 10); + CLI::showProgress(1, 0); + + $expected = "[\033[32m#####.....\033[0m] 50% Complete" . PHP_EOL . + "[\033[32m##########\033[0m] 100% Complete" . PHP_EOL; + $this->assertSame($expected, $this->getStreamFilterBuffer()); + } + public function testShowProgressWithoutBar(): void { CLI::write('first.'); @@ -989,15 +1077,9 @@ public static function provideGetOptionString(): iterable public function testWindow(): void { - $height = new ReflectionProperty(CLI::class, 'height'); - $height->setValue(null, null); - $this->assertGreaterThan(0, CLI::getHeight()); - - $width = new ReflectionProperty(CLI::class, 'width'); - $width->setValue(null, null); - $this->assertGreaterThan(0, CLI::getWidth()); + $this->assertSame(CLI::getTerminal()->getWidth(), CLI::getWidth()); } #[RequiresOperatingSystem('Darwin|Linux')] diff --git a/tests/system/CLI/ColorTest.php b/tests/system/CLI/ColorTest.php new file mode 100644 index 000000000000..e220b3250e41 --- /dev/null +++ b/tests/system/CLI/ColorTest.php @@ -0,0 +1,101 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI; + +use CodeIgniter\CLI\Exceptions\CLIException; +use CodeIgniter\Test\CIUnitTestCase; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[CoversClass(Color::class)] +#[Group('Others')] +final class ColorTest extends CIUnitTestCase +{ + public function testApplyWrapsTheTextInTheCodes(): void + { + $this->assertSame("\033[0;31mtest\033[0m", Color::apply('test', 'red')); + $this->assertSame("\033[0;31m\033[42mtest\033[0m", Color::apply('test', 'red', 'green')); + $this->assertSame("\033[1;37m\033[42m\033[4mtest\033[0m", Color::apply('test', 'white', 'green', 'underline')); + $this->assertSame("\033[1;37m\033[4mtest\033[0m", Color::apply('test', 'white', '', 'underline')); + } + + public function testApplyWithoutAForegroundEmitsOnlyTheOtherCodes(): void + { + $this->assertSame("\033[42mtest\033[0m", Color::apply('test', '', 'green')); + $this->assertSame("\033[4mtest\033[0m", Color::apply('test', '', '', 'underline')); + $this->assertSame('test', Color::apply('test', '')); + } + + public function testApplyLeavesEmptyTextEmpty(): void + { + $this->assertSame('', Color::apply('', 'white', 'green', 'underline')); + } + + #[DataProvider('provideApplyKeepsColoredSegments')] + public function testApplyKeepsColoredSegments(string $text, string $expected): void + { + $this->assertSame($expected, Color::apply($text, 'red')); + } + + /** + * @return iterable + */ + public static function provideApplyKeepsColoredSegments(): iterable + { + $green = "\033[0;32mgreen\033[0m"; + + yield 'colored segment first' => [$green . ' red', $green . "\033[0;31m red\033[0m"]; + + yield 'colored segment last' => ['red ' . $green, "\033[0;31mred \033[0m" . $green]; + + yield 'colored segments around' => [$green . ' red ' . $green, $green . "\033[0;31m red \033[0m" . $green]; + + yield 'only colored segments' => [$green . $green, $green . $green]; + + yield 'bright segment' => ["\033[1;32mok\033[0m failed", "\033[1;32mok\033[0m\033[0;31m failed\033[0m"]; + + yield 'background-only segment' => ["\033[42mok\033[0m failed", "\033[42mok\033[0m\033[0;31m failed\033[0m"]; + + yield 'segment spanning lines' => ["\033[0;32mline\nbreak\033[0m end", "\033[0;32mline\nbreak\033[0m\033[0;31m end\033[0m"]; + + yield 'reset without a matching segment' => ["bold\033[0m", "\033[0;31mbold\033[0m\033[0m"]; + } + + public function testApplyRejectsAnUnknownForeground(): void + { + $this->expectException(CLIException::class); + $this->expectExceptionMessage('Invalid "foreground" color: "Foreground"'); + + Color::apply('test', 'Foreground'); + } + + public function testApplyRejectsAnUnknownBackground(): void + { + $this->expectException(CLIException::class); + $this->expectExceptionMessage('Invalid "background" color: "Background"'); + + Color::apply('test', 'white', 'Background'); + } + + public function testWidthIgnoresTheCodes(): void + { + $this->assertSame(0, Color::width('')); + $this->assertSame(7, Color::width(Color::apply('success', 'green', 'red', 'underline'))); + $this->assertSame(4, Color::width('ほげ')); + } +} diff --git a/tests/system/CLI/CommandsTest.php b/tests/system/CLI/CommandsTest.php index 873fbf0c1ece..9a52cc8c9005 100644 --- a/tests/system/CLI/CommandsTest.php +++ b/tests/system/CLI/CommandsTest.php @@ -19,10 +19,12 @@ use CodeIgniter\Autoloader\FileLocatorInterface; use CodeIgniter\CLI\Exceptions\CommandNotFoundException; use CodeIgniter\CodeIgniter; +use CodeIgniter\Events\Events; use CodeIgniter\Exceptions\LogicException; use CodeIgniter\Log\Logger; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Filters\CITestStreamFilter; +use CodeIgniter\Test\Mock\MockInputOutput; use CodeIgniter\Test\ReflectionHelper; use CodeIgniter\Test\StreamFilterTrait; use Config\Services; @@ -101,6 +103,41 @@ public function testRunOnUnknownCommand(): void $this->assertSame("\nCommand \"app:unknown\" not found.\n", $this->getUndecoratedBuffer()); } + public function testRunCommandRoutesTheCommandEventsThroughThePassedOutput(): void + { + $commands = new Commands(); + $io = new MockInputOutput(); + $listener = static function (): void { + CLI::write('hook'); + }; + + Events::on('pre_command', $listener); + Events::on('post_command', $listener); + + try { + $this->assertSame(EXIT_SUCCESS, $commands->runCommand('app:about', ['a'], [], new Output($io))); + } finally { + Events::removeListener('pre_command', $listener); + Events::removeListener('post_command', $listener); + } + + $this->assertSame(2, substr_count($io->getOutput(), 'hook')); + $this->assertSame('', $this->getStreamFilterBuffer()); + } + + public function testRunCommandWritesThroughThePassedOutput(): void + { + $commands = new Commands(); + $io = new MockInputOutput(); + + $this->assertSame(EXIT_ERROR, $commands->runCommand('app:unknown', [], [], new Output($io))); + $this->assertSame(EXIT_SUCCESS, $commands->runCommand('app:about', ['a'], [], new Output($io))); + + $this->assertStringContainsString('Command "app:unknown" not found.', $io->getOutput()); + $this->assertStringContainsString('CodeIgniter Version:', $io->getOutput()); + $this->assertSame('', $this->getStreamFilterBuffer()); + } + public function testRunOnUnknownLegacyCommandButWithOneAlternative(): void { $commands = new Commands(); diff --git a/tests/system/CLI/NullInputOutputTest.php b/tests/system/CLI/NullInputOutputTest.php index 4008bc7bf6ff..182a218ca0e7 100644 --- a/tests/system/CLI/NullInputOutputTest.php +++ b/tests/system/CLI/NullInputOutputTest.php @@ -53,11 +53,7 @@ public function testCanBeSwappedIntoCliToSilenceWrites(): void CLI::error('this too'); $this->assertSame('', $this->getStreamFilterBuffer()); } finally { - if ($prior instanceof InputOutput) { - CLI::setInputOutput($prior); - } else { - CLI::resetInputOutput(); - } + CLI::setInputOutput($prior); } } } diff --git a/tests/system/CLI/OutputTest.php b/tests/system/CLI/OutputTest.php new file mode 100644 index 000000000000..5f3d4a8fdec6 --- /dev/null +++ b/tests/system/CLI/OutputTest.php @@ -0,0 +1,381 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI; + +use CodeIgniter\CLI\Exceptions\CLIException; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\Mock\MockInputOutput; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Group; +use Tests\Support\CLI\RecordingInputOutput; + +/** + * @internal + */ +#[CoversClass(Output::class)] +#[Group('Others')] +final class OutputTest extends CIUnitTestCase +{ + public function testWriteStartsOnAFreshLineOnceAndThenContinues(): void + { + $io = new RecordingInputOutput(); + $output = new Output($io); + + $output->write('first'); + $output->write('second'); + + $this->assertSame([['STDOUT', "\nfirst\n"], ['STDOUT', "second\n"]], $io->writes); + } + + public function testPrintLeavesTheCursorAfterTheText(): void + { + $io = new RecordingInputOutput(); + $output = new Output($io); + + $output->write('first'); + $output->print('second'); + $output->write('third'); + + $this->assertSame([['STDOUT', "\nfirst\n"], ['STDOUT', 'second'], ['STDOUT', "\nthird\n"]], $io->writes); + } + + public function testPrintEndingInANewlineLeavesTheCursorAtTheLineStart(): void + { + $io = new RecordingInputOutput(); + $output = new Output($io); + + $output->print("first\n"); + $output->write('second'); + $output->print("third\r\n"); + $output->write('fourth'); + + $this->assertSame([ + ['STDOUT', "first\n"], + ['STDOUT', "second\n"], + ['STDOUT', "third\r\n"], + ['STDOUT', "fourth\n"], + ], $io->writes); + } + + public function testErrorWritesToStderrAndSharesTheCursorState(): void + { + $io = new RecordingInputOutput(); + $output = new Output($io, $this->createUncoloredTerminal()); + + $output->write('first'); + $output->error('second'); + $output->write('third'); + + $this->assertSame([['STDOUT', "\nfirst\n"], ['STDERR', "second\n"], ['STDOUT', "third\n"]], $io->writes); + } + + public function testErrorOutsideTheCliWritesToStdoutWithoutColor(): void + { + $io = new RecordingInputOutput(); + $output = new Output($io); + + is_cli(false); + + try { + $output->error('failed'); + } finally { + is_cli(true); + } + + $this->assertSame([['STDOUT', "\nfailed\n"]], $io->writes); + } + + public function testColorFollowsTheSupportOfTheTargetStream(): void + { + $io = new RecordingInputOutput(); + $terminal = $this->createStub(Terminal::class); + $terminal->method('hasColorSupport')->willReturnCallback(static fn ($stream): bool => $stream === STDERR); + $output = new Output($io, $terminal); + + $this->assertFalse($output->isDecorated()); + $this->assertSame('plain', $output->color('plain', 'red')); + $this->assertTrue($output->forStderr()->isDecorated()); + $this->assertSame("\033[0;31mred\033[0m", $output->forStderr()->color('red', 'red')); + + $output->write('out', 'red'); + $output->error('err'); + + $this->assertSame([['STDOUT', "\nout\n"], ['STDERR', "\033[1;31merr\033[0m\n"]], $io->writes); + } + + public function testColorIsOffOutsideTheCli(): void + { + $output = new Output(new RecordingInputOutput()); + + is_cli(false); + + try { + $this->assertFalse($output->isDecorated()); + $this->assertSame('plain', $output->color('plain', 'red')); + } finally { + is_cli(true); + } + } + + public function testPrintColorsTheText(): void + { + $io = new RecordingInputOutput(); + $output = new Output($io); + + $output->print('test', 'red'); + $output->print('test', 'red', 'green'); + + $this->assertSame([ + ['STDOUT', "\033[0;31mtest\033[0m"], + ['STDOUT', "\033[0;31m\033[42mtest\033[0m"], + ], $io->writes); + } + + public function testWriteColorsTheText(): void + { + $io = new RecordingInputOutput(); + $output = new Output($io); + + $output->write('test', 'red'); + $output->write('test', 'red', 'green'); + + $this->assertSame([ + ['STDOUT', "\n\033[0;31mtest\033[0m\n"], + ['STDOUT', "\033[0;31m\033[42mtest\033[0m\n"], + ], $io->writes); + } + + public function testWriteKeepsAlreadyColoredSegments(): void + { + $io = new RecordingInputOutput(); + $output = new Output($io); + $green = $output->color('green', 'green'); + + $output->write($green . ' red', 'red'); + $output->write('red ' . $green, 'red'); + $output->write($green . ' red ' . $green, 'red'); + + $this->assertSame([ + ['STDOUT', "\n\033[0;32mgreen\033[0m\033[0;31m red\033[0m\n"], + ['STDOUT', "\033[0;31mred \033[0m\033[0;32mgreen\033[0m\n"], + ['STDOUT', "\033[0;32mgreen\033[0m\033[0;31m red \033[0m\033[0;32mgreen\033[0m\n"], + ], $io->writes); + } + + public function testErrorColorsTheTextForStderr(): void + { + $io = new RecordingInputOutput(); + $output = new Output($io); + + $output->error('test'); + $output->error('test', 'purple'); + $output->error('test', 'purple', 'green'); + + $this->assertSame([ + ['STDERR', "\n\033[1;31mtest\033[0m\n"], + ['STDERR', "\033[0;35mtest\033[0m\n"], + ['STDERR', "\033[0;35m\033[42mtest\033[0m\n"], + ], $io->writes); + } + + public function testColorAppliesBackgroundAndUnderline(): void + { + $output = new Output(new RecordingInputOutput()); + + $this->assertSame("\033[1;37m\033[42m\033[4mtest\033[0m", $output->color('test', 'white', 'green', 'underline')); + $this->assertSame('', $output->color('', 'white', 'green', 'underline')); + } + + public function testColorRejectsAnUnknownBackground(): void + { + $output = new Output(new RecordingInputOutput()); + + $this->expectException(CLIException::class); + $this->expectExceptionMessage('Invalid "background" color: "Background"'); + + $output->color('test', 'white', 'Background'); + } + + public function testBeepRepeats(): void + { + $io = new RecordingInputOutput(); + $output = new Output($io); + + $output->beep(); + $output->beep(4); + + $this->assertSame([['STDOUT', "\x07"], ['STDOUT', "\x07\x07\x07\x07"]], $io->writes); + } + + public function testBackgroundWithoutForegroundIsApplied(): void + { + $io = new RecordingInputOutput(); + $output = new Output($io); + + $output->write('test', '', 'green'); + $output->print('test', background: 'green'); + + $this->assertSame([ + ['STDOUT', "\n\033[42mtest\033[0m\n"], + ['STDOUT', "\033[42mtest\033[0m"], + ], $io->writes); + } + + public function testColorSupportIsNotCachedAcrossTheCliCheck(): void + { + $output = new Output(new RecordingInputOutput()); + + $this->assertTrue($output->isDecorated()); + + is_cli(false); + + try { + $this->assertFalse($output->isDecorated()); + $this->assertSame('plain', $output->color('plain', 'red')); + } finally { + is_cli(true); + } + + $this->assertTrue($output->isDecorated()); + } + + public function testWriteRawLeavesTheTextAndTheCursorAlone(): void + { + $io = new RecordingInputOutput(); + $output = new Output($io); + + $output->write('first'); + $output->writeRaw('3... '); + $output->write('second'); + + $this->assertSame([['STDOUT', "\nfirst\n"], ['STDOUT', '3... '], ['STDOUT', "second\n"]], $io->writes); + } + + public function testColorRejectsAnUnknownColor(): void + { + $output = new Output(new RecordingInputOutput()); + + $this->expectException(CLIException::class); + $this->expectExceptionMessage('Invalid "foreground" color: "Foreground"'); + + $output->color('test', 'Foreground'); + } + + public function testEmptyColorNamesLeaveTheTextAlone(): void + { + $io = new RecordingInputOutput(); + $output = new Output($io); + + $output->write('plain', '', ''); + $output->print('plain', '', ''); + + $this->assertSame([['STDOUT', "\nplain\n"], ['STDOUT', 'plain']], $io->writes); + } + + public function testForStderrTargetsStderrAndKeepsTheCursorState(): void + { + $io = new RecordingInputOutput(); + $output = new Output($io, $this->createUncoloredTerminal()); + + $output->write('first'); + + $stderr = $output->forStderr(); + $stderr->write('second'); + $stderr->print('third'); + $stderr->beep(); + + $output->write('fourth'); + + $this->assertSame([ + ['STDOUT', "\nfirst\n"], + ['STDERR', "second\n"], + ['STDERR', 'third'], + ['STDERR', "\x07"], + ['STDOUT', "fourth\n"], + ], $io->writes); + } + + public function testBeepAndClearScreenLeaveTheCursorStateAlone(): void + { + $io = new RecordingInputOutput(); + $output = new Output($io); + + $output->write('first'); + $output->beep(2); + $output->clearScreen(); + $output->write('second'); + + $this->assertSame([ + ['STDOUT', "\nfirst\n"], + ['STDOUT', "\x07\x07"], + ['STDOUT', "\033[H\033[2J"], + ['STDOUT', "second\n"], + ], $io->writes); + } + + public function testNewLineWritesEmptyLines(): void + { + $io = new RecordingInputOutput(); + $output = new Output($io); + + $output->newLine(2); + + $this->assertSame([['STDOUT', "\n\n"], ['STDOUT', "\n"]], $io->writes); + } + + public function testSetCursorAtLineStartControlsTheNextWrite(): void + { + $io = new RecordingInputOutput(); + $output = new Output($io); + + $output->setCursorAtLineStart(true); + $output->write('first'); + $output->setCursorAtLineStart(false); + $output->write('second'); + + $this->assertSame([['STDOUT', "first\n"], ['STDOUT', "\nsecond\n"]], $io->writes); + } + + public function testSetInputOutputReplacesTheTransport(): void + { + $first = new RecordingInputOutput(); + $second = new MockInputOutput(); + $output = new Output($first); + + $this->assertSame($first, $output->getInputOutput()); + + $output->setInputOutput($second); + $output->write('hello'); + + $this->assertSame($second, $output->getInputOutput()); + $this->assertSame([], $first->writes); + $this->assertSame("\nhello\n", $second->getOutput()); + } + + public function testDefaultsToAFreshTransportAndTerminal(): void + { + $output = new Output(); + + $this->assertNotInstanceOf(MockInputOutput::class, $output->getInputOutput()); + $this->assertNotSame($output->getTerminal(), (new Output())->getTerminal()); + } + + private function createUncoloredTerminal(): Terminal + { + $terminal = $this->createStub(Terminal::class); + $terminal->method('hasColorSupport')->willReturn(false); + + return $terminal; + } +} diff --git a/tests/system/CLI/ProgressBarTest.php b/tests/system/CLI/ProgressBarTest.php new file mode 100644 index 000000000000..84eb49250849 --- /dev/null +++ b/tests/system/CLI/ProgressBarTest.php @@ -0,0 +1,126 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI; + +use CodeIgniter\Exceptions\InvalidArgumentException; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\Mock\MockInputOutput; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[CoversClass(ProgressBar::class)] +#[Group('Others')] +final class ProgressBarTest extends CIUnitTestCase +{ + private MockInputOutput $io; + private ProgressBar $bar; + + protected function setUp(): void + { + parent::setUp(); + + $this->io = new MockInputOutput(); + $this->bar = new ProgressBar(new Output($this->io)); + } + + public function testRedrawsInPlaceWhileAdvancing(): void + { + $this->bar->advanceTo(1, 20); + $this->bar->advanceTo(10, 20); + $this->bar->advanceTo(20, 20); + + $this->assertSame( + "[\033[32m#.........\033[0m] 5% Complete" . PHP_EOL + . "\033[1A[\033[32m#####.....\033[0m] 50% Complete" . PHP_EOL + . "\033[1A[\033[32m##########\033[0m] 100% Complete" . PHP_EOL, + $this->io->getOutput(), + ); + } + + public function testGoingBackwardsStartsANewBar(): void + { + $this->bar->advanceTo(10, 20); + $this->bar->advanceTo(1, 20); + + $this->assertSame( + "[\033[32m#####.....\033[0m] 50% Complete" . PHP_EOL + . "[\033[32m#.........\033[0m] 5% Complete" . PHP_EOL, + $this->io->getOutput(), + ); + } + + public function testClearRingsTheBellAndStartsANewBar(): void + { + $this->bar->advanceTo(1, 20); + $this->bar->clear(); + $this->bar->clear(); + $this->bar->advanceTo(1, 20); + + $this->assertSame( + "[\033[32m#.........\033[0m] 5% Complete" . PHP_EOL + . "\x07\x07" + . "[\033[32m#.........\033[0m] 5% Complete" . PHP_EOL, + $this->io->getOutput(), + ); + } + + public function testAStepBeyondTheTotalIsCappedAtTheFullBar(): void + { + $this->bar->advanceTo(30, 20); + + $this->assertSame("[\033[32m##########\033[0m] 100% Complete" . PHP_EOL, $this->io->getOutput()); + } + + public function testFractionalStepsAreAccepted(): void + { + $this->bar->advanceTo(0, 10); + $this->bar->advanceTo(2.5, 10); + + $this->assertSame( + "[\033[32m..........\033[0m] 0% Complete" . PHP_EOL + . "\033[1A[\033[32m###.......\033[0m] 25% Complete" . PHP_EOL, + $this->io->getOutput(), + ); + } + + public function testANegativeStepIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The step must be zero or more, -1 given.'); + + $this->bar->advanceTo(-1, 10); + } + + public function testATotalBelowOneIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The total steps must be one or more, 0 given.'); + + $this->bar->advanceTo(1, 0); + } + + public function testTheBarLeavesTheCursorAtTheLineStart(): void + { + $output = new Output($this->io); + $bar = new ProgressBar($output); + + $bar->advanceTo(1, 20); + $output->write('done'); + + $this->assertSame("[\033[32m#.........\033[0m] 5% Complete" . PHP_EOL . 'done' . PHP_EOL, $this->io->getOutput()); + } +} diff --git a/tests/system/CLI/TableTest.php b/tests/system/CLI/TableTest.php new file mode 100644 index 000000000000..d19eab979110 --- /dev/null +++ b/tests/system/CLI/TableTest.php @@ -0,0 +1,114 @@ + + * + * 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 PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[CoversClass(Table::class)] +#[Group('Others')] +final class TableTest extends CIUnitTestCase +{ + /** + * @param list> $tbody + * @param list $thead + */ + #[DataProvider('provideRender')] + public function testRender(array $tbody, array $thead, string $expected): void + { + $this->assertSame($expected, (new Table($tbody, $thead))->render()); + } + + /** + * @return iterable>, list, string}> + */ + public static function provideRender(): iterable + { + $head = ['ID', 'Title']; + $oneRow = [['id' => 1, 'foo' => 'bar']]; + $manyRows = [ + ['id' => 1, 'foo' => 'bar'], + ['id' => 2, 'foo' => 'bar * 2'], + ['id' => 3, 'foo' => 'bar + bar + bar'], + ]; + + yield 'no rows' => [[], [], '']; + + yield 'empty row' => [[[]], [], '+' . PHP_EOL . '| |' . PHP_EOL . '+' . PHP_EOL]; + + yield 'one row without head' => [ + $oneRow, + [], + '+---+-----+' . PHP_EOL + . '| 1 | bar |' . PHP_EOL + . '+---+-----+' . PHP_EOL, + ]; + + yield 'one row with head' => [ + $oneRow, + $head, + '+----+-------+' . PHP_EOL + . '| ID | Title |' . PHP_EOL + . '+----+-------+' . PHP_EOL + . '| 1 | bar |' . PHP_EOL + . '+----+-------+' . PHP_EOL, + ]; + + yield 'many rows without head' => [ + $manyRows, + [], + '+---+-----------------+' . PHP_EOL + . '| 1 | bar |' . PHP_EOL + . '| 2 | bar * 2 |' . PHP_EOL + . '| 3 | bar + bar + bar |' . PHP_EOL + . '+---+-----------------+' . PHP_EOL, + ]; + + yield 'many rows with head' => [ + $manyRows, + $head, + '+----+-----------------+' . PHP_EOL + . '| ID | Title |' . PHP_EOL + . '+----+-----------------+' . PHP_EOL + . '| 1 | bar |' . PHP_EOL + . '| 2 | bar * 2 |' . PHP_EOL + . '| 3 | bar + bar + bar |' . PHP_EOL + . '+----+-----------------+' . PHP_EOL, + ]; + + yield 'multibyte letters' => [ + [['id' => 'ほげ', 'foo' => 'bar']], + ['ID', 'タイトル'], + '+------+----------+' . PHP_EOL + . '| ID | タイトル |' . PHP_EOL + . '+------+----------+' . PHP_EOL + . '| ほげ | bar |' . PHP_EOL + . '+------+----------+' . PHP_EOL, + ]; + + yield 'colored cells pad by visible width' => [ + [['status' => Color::apply('ok', 'green'), 'name' => 'alpha'], ['status' => 'failed', 'name' => 'b']], + [], + '+--------+-------+' . PHP_EOL + . "| \033[0;32mok\033[0m | alpha |" . PHP_EOL + . '| failed | b |' . PHP_EOL + . '+--------+-------+' . PHP_EOL, + ]; + } +} diff --git a/tests/system/CLI/TerminalTest.php b/tests/system/CLI/TerminalTest.php new file mode 100644 index 000000000000..cb1e9dd35515 --- /dev/null +++ b/tests/system/CLI/TerminalTest.php @@ -0,0 +1,171 @@ + + * + * 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 PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[CoversClass(Terminal::class)] +#[Group('Others')] +final class TerminalTest extends CIUnitTestCase +{ + public function testDimensionsArePositive(): void + { + $terminal = new Terminal(); + + $this->assertGreaterThan(0, $terminal->getWidth()); + $this->assertGreaterThan(0, $terminal->getHeight()); + } + + public function testDefaultsReplaceUnreportedDimensions(): void + { + $terminal = new Terminal(); + + $this->setPrivateProperty($terminal, 'detected', true); + $this->setPrivateProperty($terminal, 'width', 0); + $this->setPrivateProperty($terminal, 'height', 0); + + $this->assertSame(120, $terminal->getWidth(120)); + $this->assertSame(40, $terminal->getHeight(40)); + } + + public function testDimensionsAreDetectedOnceAndCached(): void + { + $terminal = new Terminal(); + + $this->setPrivateProperty($terminal, 'detected', true); + $this->setPrivateProperty($terminal, 'width', 9999); + $this->setPrivateProperty($terminal, 'height', 9998); + + $this->assertSame(9999, $terminal->getWidth()); + $this->assertSame(9998, $terminal->getHeight()); + + $terminal->generateDimensions(); + + $this->assertNotSame(9999, $this->getPrivateProperty($terminal, 'width')); + $this->assertNotSame(9998, $this->getPrivateProperty($terminal, 'height')); + } + + public function testStreamSupportsChecksTheFunctionWhenTesting(): void + { + $terminal = new Terminal(); + + $this->assertTrue($terminal->streamSupports('stream_isatty', STDOUT)); + $this->assertSame( + function_exists('sapi_windows_vt100_support'), + $terminal->streamSupports('sapi_windows_vt100_support', STDOUT), + ); + } + + public function testHasColorSupportFollowsNoColorInTheEnvironment(): void + { + $noColor = getenv('NO_COLOR'); + putenv('NO_COLOR=1'); + + try { + $this->assertFalse((new Terminal())->hasColorSupport(STDOUT)); + } finally { + putenv($noColor === false ? 'NO_COLOR' : "NO_COLOR={$noColor}"); + } + } + + public function testHasColorSupportFollowsNoColorInServer(): void + { + $superglobals = service('superglobals'); + + $noColor = $superglobals->server('NO_COLOR'); + $superglobals->setServer('NO_COLOR', '1'); + + try { + $this->assertFalse((new Terminal())->hasColorSupport(STDOUT)); + } finally { + if ($noColor === null) { + $superglobals->unsetServer('NO_COLOR'); + } else { + $superglobals->setServer('NO_COLOR', $noColor); + } + } + } + + public function testHasColorSupportOnHyperTerminals(): void + { + $termProgram = getenv('TERM_PROGRAM'); + putenv('TERM_PROGRAM=Hyper'); + + try { + $this->assertTrue((new Terminal())->hasColorSupport(STDOUT)); + } finally { + putenv($termProgram === false ? 'TERM_PROGRAM' : "TERM_PROGRAM={$termProgram}"); + } + } + + public function testHasColorSupportFallsBackToTheStreamCheck(): void + { + $this->assertTrue((new Terminal())->hasColorSupport(STDOUT)); + } + + /** + * @param array $env + */ + #[DataProvider('provideHasColorSupportOnWindowsFollowsTheConsoleHints')] + public function testHasColorSupportOnWindowsFollowsTheConsoleHints(array $env, bool $expected): void + { + $saved = []; + + foreach (['ANSICON', 'ConEmuANSI', 'TERM'] as $name) { + $saved[$name] = getenv($name); + putenv($name); + } + + foreach ($env as $name => $value) { + putenv("{$name}={$value}"); + } + + is_windows(true); + + try { + $this->assertSame( + $expected || function_exists('sapi_windows_vt100_support'), + (new Terminal())->hasColorSupport(STDOUT), + ); + } finally { + foreach ($saved as $name => $value) { + putenv($value === false ? $name : "{$name}={$value}"); + } + } + } + + /** + * @return iterable, bool}> + */ + public static function provideHasColorSupportOnWindowsFollowsTheConsoleHints(): iterable + { + yield 'no hints' => [[], false]; + + yield 'ANSICON' => [['ANSICON' => '1'], true]; + + yield 'ConEmuANSI on' => [['ConEmuANSI' => 'ON'], true]; + + yield 'ConEmuANSI off' => [['ConEmuANSI' => 'OFF'], false]; + + yield 'TERM xterm' => [['TERM' => 'xterm'], true]; + + yield 'TERM other' => [['TERM' => 'xterm-256color'], false]; + } +} diff --git a/tests/system/Test/TestCaseTest.php b/tests/system/Test/TestCaseTest.php index 53e2623c5562..647b657aab40 100644 --- a/tests/system/Test/TestCaseTest.php +++ b/tests/system/Test/TestCaseTest.php @@ -30,8 +30,7 @@ protected function setUp(): void { parent::setUp(); - // Reset CLI::$lastWrite - CLI::print(); + CLI::resetLastWrite(); } public function testGetPrivatePropertyWithObject(): void diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index d64ceeb4bbe6..d25c118a2a0a 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -28,6 +28,19 @@ Behavior Changes - **CLI:** Uncaught exceptions in ``spark``, and errors shown with ``BaseCommand::showError()`` or ``AbstractCommand::renderThrowable()`` (as ``migrate`` and ``db:seed`` do), are now printed to STDERR instead of STDOUT. The exit code is unchanged. See :ref:`upgrade-480-cli-error-output`. +- **CLI:** ``CLI::beep()`` now writes through the ``InputOutput`` transport instead of ``echo``, so it is discarded by ``callSilently()``, captured + by ``MockInputOutput``, and no longer seen by PHP output buffering on the command line. +- **CLI:** ``CLI::print()`` of text ending in a line feed, and a progress bar drawn with ``CLI::showProgress()``, no longer cause a blank line + before the next ``CLI::write()``, since the cursor already sits at the start of a line. ``CLI::init()`` now also resets that cursor state, + the progress bar, and the cached terminal dimensions. +- **CLI:** ``CLI::showProgress()`` now draws a full bar at 100% for a step beyond the total, instead of throwing a ``ValueError``. +- **CLI:** ``CLI::error()`` no longer leaves colour support disabled for STDOUT when it is given an invalid colour name, and + ``CLI::write()``, ``CLI::print()``, and ``CLI::color()`` now accept an empty foreground with a background instead of throwing. + Colouring text that already contains a coloured segment now keeps that segment whatever its colour, where previously only the + dim colours (``\033[0;...m``) were kept and a bright or background-only segment lost the colour of the text after it. +- **CLI:** The output state moved out of the static ``CLI`` properties. A subclass of ``CLI`` that overrides ``fwrite()`` or ``color()``, or reads + ``$lastWrite``, ``$isColored``, ``$io``, ``$height``, ``$width``, or the colour tables, no longer affects or observes the output. + See :ref:`upgrade-480-cli-output-classes`. - **Commands:** The ``-h`` option to ``routes`` command which was mapped previously to the ``--sort-by-handler`` option is now removed. Use ``--sort-by-handler`` instead to sort the routes by handler when running the ``routes`` command. - **Commands:** The ``filter:check`` command now requires the HTTP method argument to be uppercase (e.g., ``spark filter:check GET /`` instead of ``spark filter:check get /``). @@ -244,7 +257,15 @@ Commands - 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 ``key:rotate`` command to demote the current ``encryption.key`` to ``encryption.previousKeys`` in **.env** and generate a new key. See :ref:`spark-key-rotate`. -- Added ``AbstractCommand::callSilently()`` to invoke another command with its output discarded, restoring the prior IO afterwards. See :ref:`modern-commands-call-silently`. +- Added ``AbstractCommand::callSilently()`` to invoke another command with its output discarded, restoring the prior output afterwards. See :ref:`modern-commands-call-silently`. +- The output side of the ``CLI`` class now lives in instance classes, with ``CLI`` delegating to them: :php:class:`Output ` + (``print()``, ``write()``, ``error()``, ``newLine()``, ``beep()``, ``clearScreen()``, ``color()``), :php:class:`Terminal ` + (dimensions and colour support), :php:class:`ProgressBar `, :php:class:`Table `, and + :php:class:`Color ` (ANSI codes and ``Color::width()``). ``CLI::getOutput()`` and ``CLI::getTerminal()`` return the shared + instances. See :ref:`cli-library-classes`. +- 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 :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`. @@ -441,6 +462,9 @@ Deprecations - **CLI:** ``BaseCommand`` is now deprecated in favor of ``AbstractCommand``. Running a command that extends it triggers a deprecation notice. See :doc:`../cli/cli_modern_commands`. - **CLI:** ``GeneratorTrait`` is now deprecated in favor of ``AbstractGeneratorCommand``. See :doc:`../cli/cli_modern_generators`. - **CLI:** The ``CLI.generator.viewName.cell`` language string is deprecated. It was never displayed, since ``make:cell`` only prompts for the class name. +- **CLI:** The ``CLI::$foreground_colors``, ``CLI::$background_colors``, ``CLI::$lastWrite``, ``CLI::$height``, ``CLI::$width``, ``CLI::$isColored``, + ``CLI::$io``, and ``CLI::$stdoutToStderr`` properties are deprecated and no longer used. Their state lives in ``Color``, ``Output``, and + ``Terminal``. See :ref:`upgrade-480-cli-output-classes`. - **HTTP:** The ``CLIRequest::parseCommand()`` method is now deprecated and will be removed in a future release. The ``CLIRequest`` class now uses the new ``CommandLineParser`` class to handle command-line argument parsing. - **HTTP:** ``URI::setSilent()`` is now hard deprecated. This method was only previously marked as deprecated. It will now trigger a deprecation notice when used. diff --git a/user_guide_src/source/cli/cli_library.rst b/user_guide_src/source/cli/cli_library.rst index d53d9bcdd6cc..0bc8e54e7fd2 100644 --- a/user_guide_src/source/cli/cli_library.rst +++ b/user_guide_src/source/cli/cli_library.rst @@ -17,13 +17,16 @@ CodeIgniter's CLI library makes creating interactive command-line scripts simple Initializing the Class ********************** -You do not need to create an instance of the CLI library, since all of it's methods are static. Instead, you simply +You do not need to create an instance of the CLI library, since all of its methods are static. Instead, you simply need to ensure your controller can locate it via a ``use`` statement above your class: .. literalinclude:: cli_library/001.php The class is automatically initialized when the file is loaded the first time. +Since v4.8.0 the static methods delegate to instance classes, which you can also use directly. +See :ref:`cli-library-classes`. + Getting Input from the User *************************** @@ -147,6 +150,9 @@ print "Done" on the same line: .. literalinclude:: cli_library/012.php +The next ``write()`` starts on a fresh line. Since v4.8.0, printed text that already ends in a line feed leaves the +cursor at the start of a line, so that ``write()`` adds no blank line first. + .. _cli-library-color: color() @@ -261,3 +267,108 @@ Waits a certain number of seconds, optionally showing a wait message and waiting for a key press. .. literalinclude:: cli_library/022.php + +.. _cli-library-classes: + +The Classes Behind the Facade +***************************** + +.. versionadded:: 4.8.0 + +The static output methods delegate to instance classes, each with one job. +``CLI::getOutput()`` and ``CLI::getTerminal()`` return the shared instances, and +modern commands reach the output through ``$this->getOutput()`` +(see :ref:`modern-commands-writing-output`): + +.. literalinclude:: cli_library/024.php + +Mixing the static methods with the instance is safe, because both write to the same +``Output`` and share its line breaks. The prompt methods remain static on ``CLI``. + +.. php:namespace:: CodeIgniter\CLI + +.. php:class:: Output + + The writer. It targets STDOUT, sends ``error()`` to STDERR, and remembers whether the + cursor sits mid-line so that ``write()`` starts on a fresh line. + + .. php:method:: __construct([InputOutput $io = new InputOutput()[, Terminal $terminal = new Terminal()]]) + + :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:: 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. + + .. php:method:: color(string $text, string $foreground[, string $background = ''[, string $format = '']]): string + + Colours the text when the target stream supports colours, and returns it unchanged otherwise. + + .. php:method:: isDecorated(): bool + + Whether the target stream supports colours. + + .. php:method:: forStderr(): static + + Returns a copy whose STDOUT writes target STDERR, which is what the framework swaps in + while rendering an exception. + + .. php:method:: getTerminal(): Terminal + +.. php:class:: Terminal + + The dimensions and capabilities of the terminal. The dimensions are read once and cached. + + .. php:method:: getWidth(int $default = 80): int + .. php:method:: getHeight(int $default = 32): int + .. php:method:: generateDimensions(): void + .. php:method:: hasColorSupport($stream): bool + .. php:method:: streamSupports(string $function, $stream): bool + + The same as the static methods of the same names. + +.. php:class:: ProgressBar + + A ten-cell progress bar redrawn in place. + + .. php:method:: __construct(Output $output) + + .. php:method:: advanceTo(float|int $step[, int $totalSteps = 10]): void + + Draws the bar for the step, capped at 100%, over the previous bar unless the step + went backwards. A negative step or a total below one throws ``InvalidArgumentException``, + where ``CLI::showProgress()`` keeps tolerating them. + + .. php:method:: clear(): void + + Rings the bell and starts a new bar on the next ``advanceTo()``. This is what + ``CLI::showProgress(false)`` does. + +.. php:class:: Table + + .. php:method:: __construct(array $tbody[, array $thead = []]) + + :param array $tbody: List of rows. + :param array $thead: List of column headings. + + .. php:method:: render(): string + + Returns the table as lines ending in ``PHP_EOL``, ready for ``Output::write()``. + +.. php:class:: Color + + The ANSI colour codes, independent of any stream. + + .. php:staticmethod:: apply(string $text, string $foreground[, string $background = ''[, string $format = '']]): string + + Wraps the text in the colour codes, always. Throws ``CLIException`` for an unknown colour. + + .. php:staticmethod:: width(string $string): int + + Returns the display width of the text without its colour codes, as ``CLI::strlen()`` does. diff --git a/user_guide_src/source/cli/cli_library/024.php b/user_guide_src/source/cli/cli_library/024.php new file mode 100644 index 000000000000..cd709c3a802c --- /dev/null +++ b/user_guide_src/source/cli/cli_library/024.php @@ -0,0 +1,26 @@ +write('Hello', 'green'); +$output->error('Something went wrong.'); + +// Tables and progress bars are built on the output. +$output->write((new Table([[1, 'bar']], ['ID', 'Title']))->render()); + +$bar = new ProgressBar($output); +$bar->advanceTo(5, 10); +$bar->advanceTo(10, 10); + +// An output of your own, here capturing everything written to it. +$captured = new Output($io = new MockInputOutput()); +$captured->write('captured'); + +echo $io->getOutput(); diff --git a/user_guide_src/source/cli/cli_modern_commands.rst b/user_guide_src/source/cli/cli_modern_commands.rst index 42484f61dbce..8fe0617f5076 100644 --- a/user_guide_src/source/cli/cli_modern_commands.rst +++ b/user_guide_src/source/cli/cli_modern_commands.rst @@ -365,6 +365,26 @@ flag was actually passed rather than whether it resolved to a default value. Accessors that take a name throw ``LogicException`` when the name is not declared on the command. +.. _modern-commands-writing-output: + +************** +Writing Output +************** + +``$this->getOutput()`` returns the :php:class:`Output ` of the +current run. It is the same instance written to by the static ``CLI`` methods, so the two +can be mixed and the line breaks stay consistent. ``Output`` carries the writing +methods: ``write()``, ``print()``, ``error()``, ``newLine()``, ``beep()``, +``clearScreen()``, and ``color()``. Tables and progress bars are rendered by +:php:class:`Table ` and :php:class:`ProgressBar `: + +.. literalinclude:: cli_modern_commands/018.php + +By default the run writes to the output shared through ``CLI::getOutput()``. Pass an +``Output`` as the third argument of ``run()`` to write somewhere else, for instance +to a ``MockInputOutput`` in a test. See :doc:`../cli/cli_library` for the classes and +:ref:`testing-cli-output` for testing. + *********************** Calling Another Command *********************** @@ -400,6 +420,11 @@ 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. +While the sub-command runs, ``CLI::getOutput()`` and ``$this->getOutput()`` return an +output backed by ``NullInputOutput``, so everything written through either, including +the sub-command's own ``callSilently()`` or ``call()`` sub-commands, is discarded. The +previous output is restored afterwards. + ************** Usage Examples ************** @@ -665,17 +690,29 @@ covered in the sections above and are not listed here. detection for this command instance. Typically called from ``initialize()`` or by an outer caller. - .. php:method:: run(array $arguments, array $options): int + .. php:method:: run(array $arguments, array $options[, ?Output $output = null]): int - :param array $arguments: The raw positional arguments parsed from the command line. - :param array $options: The raw option map parsed from the command line. - :returns: The exit code returned by ``execute()``. + :param array $arguments: The raw positional arguments parsed from the command line. + :param array $options: The raw option map parsed from the command line. + :param Output|null $output: The output to run against, or ``null`` for ``CLI::getOutput()``. + :returns: The exit code returned by ``execute()``. **Final.** Walks the command through ``initialize()``, ``interact()``, bind, validate, and finally ``execute()``. The framework calls this on your behalf — you rarely invoke it directly, but you can when driving a command manually (for instance, from a test). + A passed ``$output`` replaces ``CLI::getOutput()`` for the duration of the + run, so sub-commands called with :php:meth:`call` inherit it. The previous + output is restored afterwards, also when the run throws, so an uncaught + exception is rendered on the previous output. + + .. php:method:: getOutput(): Output + + Returns the :php:class:`Output ` of the current run, + which is the same instance written to by the static ``CLI`` methods. See + :ref:`modern-commands-writing-output`. + .. php:method:: call(string $command[, array $arguments = [], array $options = [], ?bool $noInteractionOverride = null]): int :param string $command: The name of the modern command to call. diff --git a/user_guide_src/source/cli/cli_modern_commands/018.php b/user_guide_src/source/cli/cli_modern_commands/018.php new file mode 100644 index 000000000000..cd8fe023b183 --- /dev/null +++ b/user_guide_src/source/cli/cli_modern_commands/018.php @@ -0,0 +1,39 @@ +getOutput(); + + $output->write('Generating the report...', 'yellow'); + + $rows = $this->collectRows(); + + if ($rows === []) { + $output->error('Nothing to report.'); + + return EXIT_ERROR; + } + + $output->write((new Table($rows, ['ID', 'Title']))->render()); + $output->write(sprintf('%d rows.', count($rows)), 'green'); + + return EXIT_SUCCESS; + } + + /** + * @return list + */ + private function collectRows(): array + { + return [[1, 'First'], [2, 'Second']]; + } +} diff --git a/user_guide_src/source/installation/upgrade_480.rst b/user_guide_src/source/installation/upgrade_480.rst index 0a4bcd9ecbfe..76382fb4bb1b 100644 --- a/user_guide_src/source/installation/upgrade_480.rst +++ b/user_guide_src/source/installation/upgrade_480.rst @@ -49,6 +49,31 @@ reach that file. Redirect STDERR as well to keep them (``php spark my:command > A custom CLI error view that writes with ``echo`` instead of the ``CLI`` class still prints to STDOUT. +.. _upgrade-480-cli-output-classes: + +CLI Output Classes +================== + +The static ``CLI`` class now delegates its output methods to instance classes: +``Output``, ``Terminal``, ``ProgressBar``, ``Table``, and ``Color``. Calling the static +methods keeps working, and the output is byte-identical, except that ``CLI::beep()`` now +goes through the ``InputOutput`` transport and that ``CLI::print()`` of text ending in +a line feed, or a progress bar drawn with ``CLI::showProgress()``, no longer adds a blank +line before the next ``CLI::write()``. + +If you extend ``CLI`` and override ``color()``, or read the ``$lastWrite``, ``$isColored``, +``$io``, ``$height``, ``$width``, ``$foreground_colors``, or ``$background_colors`` +properties, those overrides and reads no longer take effect. The properties are still +declared but unused. An override of ``write()`` is still reached by ``newLine()``, +``table()``, and ``clearScreen()``, and an override of ``fwrite()`` by ``wait()`` and +``clearScreen()``, but no longer by ``print()``, ``error()``, ``beep()``, or +``showProgress()``. Replace a transport override with ``CLI::setInputOutput()``, and a +colour or terminal override with an ``Output`` or ``Terminal`` subclass passed to +``CLI::setOutput()``. + +Tests that reached those properties through reflection should use the instances +returned by ``CLI::getOutput()`` and ``CLI::getTerminal()`` instead. + Uploaded File Move Return Type ============================== diff --git a/user_guide_src/source/testing/cli.rst b/user_guide_src/source/testing/cli.rst index ace1f12419e8..835ff4411191 100644 --- a/user_guide_src/source/testing/cli.rst +++ b/user_guide_src/source/testing/cli.rst @@ -37,14 +37,14 @@ Gets the output. - If you call it like ``$io->getOutput()``, it returns the whole output string. - If you specify ``0`` or a positive number, it returns the output array item. - Each item has the output of a ``CLI::fwrite()`` call. + Each item has the output of one write, such as a ``CLI::write()`` or ``CLI::beep()`` call. - If you specify a negative number ``-n``, it returns the last ``n``-th item of the output array. getOutputs(): array ^^^^^^^^^^^^^^^^^^^ -Returns the output array. Each item has the output of a ``CLI::fwrite()`` call. +Returns the output array. Each item has the output of one write. How to Use ========== @@ -58,6 +58,16 @@ The following test code is to test the command ``spark db:table``: .. literalinclude:: cli/001.php +Running a Modern Command Against an Output +------------------------------------------ + +.. versionadded:: 4.8.0 + +A modern command can also be run directly against an ``Output`` built on the mock, +which leaves the ``CLI`` class untouched once ``run()`` returns: + +.. literalinclude:: cli/002.php + *********************** Without MockInputOutput *********************** diff --git a/user_guide_src/source/testing/cli/002.php b/user_guide_src/source/testing/cli/002.php new file mode 100644 index 000000000000..457f95fd7100 --- /dev/null +++ b/user_guide_src/source/testing/cli/002.php @@ -0,0 +1,19 @@ +run([], [], new Output($io)); + + $this->assertSame(EXIT_SUCCESS, $exitCode); + $this->assertStringContainsString('2 rows.', $io->getOutput()); + } +} diff --git a/utils/phpstan-baseline/arguments.count.neon b/utils/phpstan-baseline/arguments.count.neon index 254e39c2f397..6ff430dc407e 100644 --- a/utils/phpstan-baseline/arguments.count.neon +++ b/utils/phpstan-baseline/arguments.count.neon @@ -1,4 +1,4 @@ -# total 6 errors +# total 12 errors parameters: ignoreErrors: @@ -7,6 +7,11 @@ parameters: count: 4 path: ../../tests/system/CLI/CLITest.php + - + message: '#^Function is_cli invoked with 1 parameter, 0 required\.$#' + count: 6 + path: ../../tests/system/CLI/OutputTest.php + - message: '#^Function is_cli invoked with 1 parameter, 0 required\.$#' count: 2 diff --git a/utils/phpstan-baseline/loader.neon b/utils/phpstan-baseline/loader.neon index 20de678702e1..9a9653cc3c2e 100644 --- a/utils/phpstan-baseline/loader.neon +++ b/utils/phpstan-baseline/loader.neon @@ -1,4 +1,4 @@ -# total 32 errors +# total 38 errors includes: - arguments.count.neon