From f605e69bbc96342114ec57078bbb9fe3c6fc5b5c Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Fri, 23 Oct 2026 15:26:38 +0800 Subject: [PATCH] feat: modernise colour detection with `FORCE_COLOR`, `COLORTERM` and `TERM=dumb` --- system/CLI/Terminal.php | 49 ++++-- tests/system/CLI/TerminalTest.php | 185 ++++++++++++++------ user_guide_src/source/changelogs/v4.8.0.rst | 5 + 3 files changed, 165 insertions(+), 74 deletions(-) diff --git a/system/CLI/Terminal.php b/system/CLI/Terminal.php index fe57d4d6129c..53adba9e7747 100644 --- a/system/CLI/Terminal.php +++ b/system/CLI/Terminal.php @@ -92,36 +92,53 @@ public function streamSupports(string $function, $stream): bool } /** - * Whether the stream supports colours, following https://no-color.org/ and the Windows console emulators. + * Whether the stream supports colours, following https://no-color.org/, https://force-color.org/ and the terminal hints. * * @param resource $stream */ public function hasColorSupport($stream): bool { - if ($this->hasEnv('NO_COLOR')) { + $noColor = env('NO_COLOR'); + + if ($noColor !== null && $noColor !== '') { + // no-color.org: only an empty value is disregarded. return false; } - if (getenv('TERM_PROGRAM') === 'Hyper') { + $forceColor = env('FORCE_COLOR'); + + if ($forceColor !== null && $forceColor !== '') { + // force-color.org: only an empty value is disregarded. return true; } - if (is_windows()) { - return $this->streamSupports('sapi_windows_vt100_support', $stream) - || $this->hasEnv('ANSICON') - || getenv('ConEmuANSI') === 'ON' - || getenv('TERM') === 'xterm'; + $msystem = strtoupper((string) env('MSYSTEM', '')); + + if (! $this->streamSupports('stream_isatty', $stream) && $msystem !== 'MINGW32' && $msystem !== 'MINGW64') { + return false; } - return $this->streamSupports('stream_isatty', $stream); - } + if (is_windows() && $this->streamSupports('sapi_windows_vt100_support', $stream)) { + return true; + } - /** - * 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) + if ( + env('TERM_PROGRAM') === 'Hyper' + || env('COLORTERM') !== null + || env('ANSICON') !== null + || env('ConEmuANSI') === 'ON' + ) { + return true; + } + + $term = (string) env('TERM', ''); + + if ($term === 'dumb') { + return false; + } + + return ! is_windows() + || preg_match('/^((screen|xterm|vt100|vt220|putty|rxvt|ansi|cygwin|linux).*)|(.*-256(color)?(-bce)?)$/', $term) === 1; } /** diff --git a/tests/system/CLI/TerminalTest.php b/tests/system/CLI/TerminalTest.php index cb1e9dd35515..809de47c594b 100644 --- a/tests/system/CLI/TerminalTest.php +++ b/tests/system/CLI/TerminalTest.php @@ -25,6 +25,60 @@ #[Group('Others')] final class TerminalTest extends CIUnitTestCase { + private const COLOR_HINTS = [ + 'NO_COLOR', + 'FORCE_COLOR', + 'MSYSTEM', + 'TERM_PROGRAM', + 'COLORTERM', + 'ANSICON', + 'ConEmuANSI', + 'TERM', + ]; + + /** + * @var array|float|int|string|null}> + */ + private array $savedHints = []; + + protected function setUp(): void + { + parent::setUp(); + + $superglobals = service('superglobals'); + + foreach (self::COLOR_HINTS as $name) { + $this->savedHints[$name] = [getenv($name), $_ENV[$name] ?? null, $superglobals->server($name)]; + + putenv($name); + unset($_ENV[$name]); + $superglobals->unsetServer($name); + } + } + + protected function tearDown(): void + { + $superglobals = service('superglobals'); + + foreach ($this->savedHints as $name => [$process, $env, $server]) { + putenv($process === false ? $name : "{$name}={$process}"); + + if ($env === null) { + unset($_ENV[$name]); + } else { + $_ENV[$name] = $env; + } + + if ($server === null) { + $superglobals->unsetServer($name); + } else { + $superglobals->setServer($name, $server); + } + } + + parent::tearDown(); + } + public function testDimensionsArePositive(): void { $terminal = new Terminal(); @@ -73,46 +127,25 @@ function_exists('sapi_windows_vt100_support'), ); } - public function testHasColorSupportFollowsNoColorInTheEnvironment(): void + public function testHasColorSupportFollowsNoColorInServer(): void { - $noColor = getenv('NO_COLOR'); - putenv('NO_COLOR=1'); + service('superglobals')->setServer('NO_COLOR', '1'); - try { - $this->assertFalse((new Terminal())->hasColorSupport(STDOUT)); - } finally { - putenv($noColor === false ? 'NO_COLOR' : "NO_COLOR={$noColor}"); - } + $this->assertFalse((new Terminal())->hasColorSupport(STDOUT)); } - public function testHasColorSupportFollowsNoColorInServer(): void + public function testHasColorSupportFollowsNoColorInDotEnv(): void { - $superglobals = service('superglobals'); + $_ENV['NO_COLOR'] = '1'; - $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); - } - } + $this->assertFalse((new Terminal())->hasColorSupport(STDOUT)); } - public function testHasColorSupportOnHyperTerminals(): void + public function testHasColorSupportFollowsForceColorInServer(): void { - $termProgram = getenv('TERM_PROGRAM'); - putenv('TERM_PROGRAM=Hyper'); + service('superglobals')->setServer('FORCE_COLOR', '1'); - try { - $this->assertTrue((new Terminal())->hasColorSupport(STDOUT)); - } finally { - putenv($termProgram === false ? 'TERM_PROGRAM' : "TERM_PROGRAM={$termProgram}"); - } + $this->assertTrue($this->createTerminal(tty: false)->hasColorSupport(STDOUT)); } public function testHasColorSupportFallsBackToTheStreamCheck(): void @@ -123,49 +156,85 @@ public function testHasColorSupportFallsBackToTheStreamCheck(): void /** * @param array $env */ - #[DataProvider('provideHasColorSupportOnWindowsFollowsTheConsoleHints')] - public function testHasColorSupportOnWindowsFollowsTheConsoleHints(array $env, bool $expected): void + #[DataProvider('provideHasColorSupportFollowsTheTerminalHints')] + public function testHasColorSupportFollowsTheTerminalHints(array $env, bool $windows, bool $tty, bool $vt100, 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); + is_windows($windows); - 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}"); - } - } + $this->assertSame($expected, $this->createTerminal($tty, $vt100)->hasColorSupport(STDOUT)); } /** - * @return iterable, bool}> + * @return iterable, bool, bool, bool, bool}> */ - public static function provideHasColorSupportOnWindowsFollowsTheConsoleHints(): iterable + public static function provideHasColorSupportFollowsTheTerminalHints(): iterable { - yield 'no hints' => [[], false]; + yield 'NO_COLOR over FORCE_COLOR' => [['NO_COLOR' => '1', 'FORCE_COLOR' => '1'], false, true, false, false]; + + yield 'NO_COLOR of 0' => [['NO_COLOR' => '0'], false, true, false, false]; + + yield 'NO_COLOR of false' => [['NO_COLOR' => 'false'], false, true, false, false]; + + yield 'empty NO_COLOR' => [['NO_COLOR' => ''], false, true, false, true]; + + yield 'FORCE_COLOR without a TTY' => [['FORCE_COLOR' => '1'], false, false, false, true]; + + yield 'FORCE_COLOR of 0 without a TTY' => [['FORCE_COLOR' => '0'], false, false, false, true]; + + yield 'FORCE_COLOR of false without a TTY' => [['FORCE_COLOR' => 'false'], false, false, false, true]; + + yield 'empty FORCE_COLOR without a TTY' => [['FORCE_COLOR' => ''], false, false, false, false]; + + yield 'no TTY' => [[], false, false, false, false]; + + yield 'no TTY under MINGW64' => [['MSYSTEM' => 'MINGW64', 'TERM' => 'xterm'], true, false, false, true]; + + yield 'no TTY under lowercase mingw32' => [['MSYSTEM' => 'mingw32', 'TERM' => 'xterm'], true, false, false, true]; - yield 'ANSICON' => [['ANSICON' => '1'], true]; + yield 'no TTY under MSYS' => [['MSYSTEM' => 'MSYS', 'TERM' => 'xterm'], true, false, false, false]; - yield 'ConEmuANSI on' => [['ConEmuANSI' => 'ON'], true]; + yield 'Windows VT100' => [[], true, true, true, true]; - yield 'ConEmuANSI off' => [['ConEmuANSI' => 'OFF'], false]; + yield 'VT100 support ignored off Windows' => [['TERM' => 'dumb'], false, true, true, false]; - yield 'TERM xterm' => [['TERM' => 'xterm'], true]; + yield 'Hyper' => [['TERM_PROGRAM' => 'Hyper', 'TERM' => 'dumb'], true, true, false, true]; + + yield 'COLORTERM' => [['COLORTERM' => 'truecolor'], true, true, false, true]; + + yield 'ANSICON' => [['ANSICON' => '1'], true, true, false, true]; + + yield 'ConEmuANSI on' => [['ConEmuANSI' => 'ON'], true, true, false, true]; + + yield 'ConEmuANSI off' => [['ConEmuANSI' => 'OFF'], true, true, false, false]; + + yield 'TERM dumb' => [['TERM' => 'dumb'], false, true, false, false]; + + yield 'TTY without TERM' => [[], false, true, false, true]; + + yield 'TTY with an unlisted TERM' => [['TERM' => 'unknown'], false, true, false, true]; + + yield 'Windows without hints' => [[], true, true, false, false]; + + yield 'Windows TERM xterm' => [['TERM' => 'xterm'], true, true, false, true]; + + yield 'Windows TERM cygwin' => [['TERM' => 'cygwin'], true, true, false, true]; + + yield 'Windows TERM 256color' => [['TERM' => 'tmux-256color'], true, true, false, true]; + + yield 'Windows unlisted TERM' => [['TERM' => 'unknown'], true, true, false, false]; + } + + private function createTerminal(bool $tty, bool $vt100 = false): Terminal + { + $terminal = $this->createPartialMock(Terminal::class, ['streamSupports']); + $terminal->method('streamSupports')->willReturnCallback( + static fn (string $function): bool => $function === 'stream_isatty' ? $tty : $vt100, + ); - yield 'TERM other' => [['TERM' => 'xterm-256color'], false]; + return $terminal; } } diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index e3e116d1f996..e9125b3ad5c7 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -36,6 +36,9 @@ Behavior Changes - **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. +- **CLI:** An empty ``NO_COLOR`` no longer turns colour off, following https://no-color.org/. Colour detection now turns colour off when ``TERM`` is ``dumb``, and on Windows when the stream is not a terminal (outside MSYS2's + ``MINGW32`` and ``MINGW64``). On Windows without VT100 support, any ``TERM`` starting with ``xterm``, ``screen``, ``vt100``, ``vt220``, ``putty``, + ``rxvt``, ``ansi``, ``cygwin`` or ``linux``, or ending in ``-256color``, now enables colour, where previously only ``xterm`` did. 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 @@ -269,6 +272,8 @@ Commands See :ref:`modern-commands-writing-output`. - Added ``--silent``, ``--quiet`` / ``-q``, and ``--verbose`` / ``-v``, ``-vv``, ``-vvv`` to every command, with ``SHELL_VERBOSITY`` as the fallback. Modern commands can write at a :php:class:`Verbosity ` through ``$this->getOutput()``. ``lang:find`` uses the global ``--verbose`` in place of its own. See :ref:`spark-verbosity` and :ref:`modern-commands-verbosity`. +- Colour detection now honours ``FORCE_COLOR`` (see https://force-color.org/), which turns colour on even when the output is piped, and ``COLORTERM``. + ``NO_COLOR`` still wins over both. The colour variables can also be set in the **.env** file. - 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`.