Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 33 additions & 16 deletions system/CLI/Terminal.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
185 changes: 127 additions & 58 deletions tests/system/CLI/TerminalTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, array{false|string, mixed, array<array-key, mixed>|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();
Expand Down Expand Up @@ -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
Expand All @@ -123,49 +156,85 @@ public function testHasColorSupportFallsBackToTheStreamCheck(): void
/**
* @param array<string, string> $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<string, array{array<string, string>, bool}>
* @return iterable<string, array{array<string, string>, 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;
}
}
5 changes: 5 additions & 0 deletions user_guide_src/source/changelogs/v4.8.0.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <CodeIgniter\\CLI\\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 <CodeIgniter\\CLI\\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`.
Expand Down
Loading