diff --git a/src/Cli/BrefSpinner.php b/src/Cli/BrefSpinner.php index f2ba1f2..1871732 100644 --- a/src/Cli/BrefSpinner.php +++ b/src/Cli/BrefSpinner.php @@ -166,7 +166,9 @@ private function overwrite(string $message): void $this->output->write(Styles::gray('press [?] for verbose logs')); } } else { - $this->output->writeln($message); + // Our colors are raw ANSI escape codes (see `Styles`), which Symfony does not strip on + // its own when the output is not decorated (a pipe, a redirection to a file, CI...). + $this->output->writeln($this->output->isDecorated() ? $message : Styles::strip($message)); } } diff --git a/src/Cli/IO.php b/src/Cli/IO.php index e3b88b1..b156937 100644 --- a/src/Cli/IO.php +++ b/src/Cli/IO.php @@ -178,8 +178,7 @@ private static function writeToLogsFile(string|array $messages): void if (! self::$logsFileResource) return; $message = is_array($messages) ? implode(PHP_EOL, $messages) : $messages; - // Strip ANSI - $message = (string) preg_replace('/\x1b\[[0-9;]*m/', '', $message); + $message = Styles::strip($message); foreach (explode(PHP_EOL, $message) as $line) { if (empty(trim($line))) continue; fwrite(self::$logsFileResource, $line . PHP_EOL); @@ -216,6 +215,13 @@ private static function safeWrite(string|array $messages): void $messages = explode(PHP_EOL, $messages); } + // Our own colors are raw ANSI escape codes (see `Styles`), not Symfony formatter tags, so + // Symfony does not strip them on its own when the output is not decorated (NO_COLOR, a pipe, + // a redirection to a file...). Strip them here so piped/redirected output stays clean. + if (! self::$output->isDecorated()) { + $messages = array_map(Styles::strip(...), $messages); + } + // Symfony `StreamOutput` is not compatible with non-blocking mode // (in that case `fwrite` returns the number of bytes written, which Symfony ignores) // Amphp sets STDOUT and STDERR to non-blocking mode, so we need to set them back to blocking mode here. diff --git a/src/Cli/Styles.php b/src/Cli/Styles.php index 1b7c46e..c6b7c19 100644 --- a/src/Cli/Styles.php +++ b/src/Cli/Styles.php @@ -119,4 +119,13 @@ public static function gray(string $text): string { return "\e[90m{$text}\e[39m"; } + + /** + * Removes ANSI escape codes, for when the text ends up somewhere that does not render them + * (a piped/redirected output, a log file...). + */ + public static function strip(string $text): string + { + return (string) preg_replace('/\x1b\[[0-9;]*m/', '', $text); + } } \ No newline at end of file diff --git a/tests/Cli/IOTest.php b/tests/Cli/IOTest.php new file mode 100644 index 0000000..83463ca --- /dev/null +++ b/tests/Cli/IOTest.php @@ -0,0 +1,106 @@ +fetch(); + + $this->assertStringNotContainsString("\e[", $display); + $this->assertStringContainsString('Something to pay attention to', $display); + $this->assertStringContainsString('The command failed', $display); + } + + public function test_warnings_and_errors_keep_colors_when_decorated(): void + { + $output = new BufferedOutput(decorated: true); + IO::init(new ArrayInput([]), $output); + + IO::warning('Something to pay attention to'); + + $this->assertStringContainsString("\e[", $output->fetch()); + } + + public function test_the_spinner_has_no_escape_codes_when_not_decorated(): void + { + $output = new BufferedOutput(decorated: false); + IO::init(new ArrayInput([]), $output); + + IO::spin('starting command'); + IO::spin('running'); + IO::spinClear(); + + $display = $output->fetch(); + + $this->assertStringNotContainsString("\e[", $display); + $this->assertStringContainsString('starting command', $display); + $this->assertStringContainsString('running', $display); + } + + public function test_the_spinner_keeps_colors_when_decorated(): void + { + $output = new BufferedOutput(decorated: true); + IO::init(new ArrayInput([]), $output); + + IO::spin('starting command'); + IO::spinClear(); + + $this->assertStringContainsString("\e[", $output->fetch()); + } + + /** + * Verbose mode is forced on for non-interactive environments (CI, a piped output...), so its + * lines are exactly the kind of decoration that must stay clean when not decorated. + */ + public function test_verbose_logs_have_no_escape_codes_when_not_decorated(): void + { + $output = new BufferedOutput(decorated: false); + IO::init(new ArrayInput([]), $output); + + IO::verbose('Diagnostic detail'); + + $display = $output->fetch(); + + $this->assertStringNotContainsString("\e[", $display); + $this->assertStringContainsString('Diagnostic detail', $display); + } + + /** + * The command's own output (e.g. what `bref command` prints from the deployed application) + * is never styled by the CLI, and must stay exactly as it is regardless of decoration. + */ + public function test_plain_writeln_is_untouched(): void + { + $output = new BufferedOutput(decorated: false); + IO::init(new ArrayInput([]), $output); + + IO::writeln('Hello from the deployed application'); + + $this->assertSame("Hello from the deployed application\n", $output->fetch()); + } +} diff --git a/tests/Cli/StylesTest.php b/tests/Cli/StylesTest.php new file mode 100644 index 0000000..960d1e1 --- /dev/null +++ b/tests/Cli/StylesTest.php @@ -0,0 +1,22 @@ +assertSame( + 'bold red gray plain', + Styles::strip(Styles::bold('bold') . ' ' . Styles::red('red') . ' ' . Styles::gray('gray') . ' plain'), + ); + } + + public function test_strip_leaves_text_without_escape_codes_untouched(): void + { + $this->assertSame('plain text', Styles::strip('plain text')); + } +}