Files
Chandler Carruth 40aa4419c0 Update the terminal library for rendering diagnostics (#7659)
Everything drawn into a buffer was checked against `columns()`. That is
right for wrapping and for line drawing, both of which have somewhere
else to put what doesn't fit, but wrong for `DrawText`, which exists for
text that must not be broken and sometimes has to run past the width
with no other answer available. It and `DrawCodePoint` now check only
that the column is non-negative and the row is one a grid can index, and
widen the buffer as far as the text needs; `DrawWrappedText`,
`DrawHorizontalLine`, `DrawVerticalLine`, and `DrawBox` are unchanged.
That also settles what a caller does after a drawing overhangs, since
`DrawEnd` exists so that a run can continue where the last one ended,
and that continuation was itself a checked error whenever the previous
run overhung. A column computed to be negative, such as a gutter
narrower than the line number it holds, still fails.

A color picked to read against black is hard to read against white, and
nothing in `Capabilities` said which a stream was going into.
`ChooseBackground` reads `COLORFGBG`, which `rxvt` and its derivatives
set to the foreground and background palette indices, and takes anything
it doesn't answer to be dark: guessing dark costs contrast, while
guessing light puts pale text on a pale background. Asking the terminal
itself with an `OSC 11` query is the accurate answer, and needs raw
mode, a timeout, and somewhere to put the reply, so there is a TODO for
it rather than an implementation.

Every corner, tee, and crossing came out of `Charset::Ascii` as `+`,
which left six of the shapes a diagnostic draws indistinguishable: the
rule closing a frame read as the one separating two snippets, and the
anchor opening a diagnostic as the one carrying it on. Each stand-in now
keeps the axis its line runs through, which leaves `+` meaning a
crossing and nothing else. A tee keeps its through-stroke and leaves the
branch to what is drawn beside it, and a corner is `.` where its line
leaves downward and `'` where it arrives from above, which is where
those characters sit in their cells. A box is a box again:

```
    +--+        .--.
    |  |   ->   |  |
    +--+        '--'
```

Assisted-by: Claude Code
2026-08-21 22:19:12 +00:00

341 lines
15 KiB
C++

// Part of the Carbon Language project, under the Apache License v2.0 with LLVM
// Exceptions. See /LICENSE for license information.
// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
#include "common/terminal/capabilities.h"
#include <gtest/gtest.h>
#include <utility>
#include "common/filesystem.h"
namespace Carbon::Terminal {
namespace {
// Detection policy is a pure function of the environment and whether the
// stream is a terminal, so these tests build the environment directly instead
// of mutating the process environment, which would leak between tests and race
// with anything else running.
auto OnTerminal(const ColorEnvironment& env) -> ColorMode {
return ChooseColorMode(Preference::Auto, env, /*is_terminal=*/true);
}
auto OffTerminal(const ColorEnvironment& env) -> ColorMode {
return ChooseColorMode(Preference::Auto, env, /*is_terminal=*/false);
}
// A terminal that supports color, for tests varying one other variable.
auto ColorTerminal() -> ColorEnvironment { return {.term = "xterm-256color"}; }
TEST(CapabilitiesTest, ColorNeedsATerminal) {
EXPECT_EQ(OnTerminal(ColorTerminal()), ColorMode::Ansi256);
// Writing to a file or a pipe must stay plain, or every redirected build log
// fills with escape sequences.
EXPECT_EQ(OffTerminal(ColorTerminal()), ColorMode::NoColor);
// A terminal that nothing says anything about can't be assumed to render
// escapes.
EXPECT_EQ(OnTerminal({}), ColorMode::NoColor);
EXPECT_EQ(OnTerminal({.term = ""}), ColorMode::NoColor);
EXPECT_EQ(OnTerminal({.term = "dumb"}), ColorMode::NoColor);
}
TEST(CapabilitiesTest, ColorFromTheEmulatorWithoutTerm) {
// `TERM` is unset for anything not launched from a shell, but the emulator
// sets `COLORTERM` and `TERM_PROGRAM` itself, and both are specifically
// about color. Either one identifies the terminal on its own, at whatever
// depth it names.
EXPECT_EQ(OnTerminal({.colorterm = "truecolor"}), ColorMode::Truecolor);
EXPECT_EQ(OnTerminal({.colorterm = "yes"}), ColorMode::Ansi16);
EXPECT_EQ(OnTerminal({.term_program = "vscode"}), ColorMode::Truecolor);
EXPECT_EQ(OnTerminal({.term_program = "unknown"}), ColorMode::Ansi16);
// An empty value says nothing at all.
EXPECT_EQ(OnTerminal({.colorterm = "", .term_program = ""}),
ColorMode::NoColor);
// `dumb` outranks them: it states outright that escapes won't render.
EXPECT_EQ(OnTerminal({.colorterm = "truecolor", .term = "dumb"}),
ColorMode::NoColor);
// And none of them enable color off a terminal.
EXPECT_EQ(OffTerminal({.colorterm = "truecolor"}), ColorMode::NoColor);
EXPECT_EQ(OffTerminal({.term_program = "vscode"}), ColorMode::NoColor);
}
TEST(CapabilitiesTest, ExplicitPreferenceWins) {
ColorEnvironment forcing = {.force_color = "3", .term = "xterm-256color"};
EXPECT_EQ(ChooseColorMode(Preference::Never, forcing, /*is_terminal=*/true),
ColorMode::NoColor);
ColorEnvironment disabling = {.no_color = "1", .term = "dumb"};
EXPECT_EQ(ChooseColorMode(Preference::Always, disabling,
/*is_terminal=*/false),
ColorMode::Ansi16);
// Forcing color on without any hint of what the terminal handles gets the
// depth every color terminal supports.
EXPECT_EQ(ChooseColorMode(Preference::Always, {}, /*is_terminal=*/false),
ColorMode::Ansi16);
EXPECT_EQ(ChooseColorMode(Preference::Always, ColorTerminal(),
/*is_terminal=*/false),
ColorMode::Ansi256);
}
TEST(CapabilitiesTest, NoColor) {
// https://no-color.org: any non-empty value disables color, whatever it is.
EXPECT_EQ(OnTerminal({.no_color = "1", .term = "xterm-256color"}),
ColorMode::NoColor);
EXPECT_EQ(OnTerminal({.no_color = "0", .term = "xterm-256color"}),
ColorMode::NoColor);
// Being set to the empty string carries no meaning, so it must not disable
// color: an empty variable inherited from a wrapper script would otherwise
// silently turn color off everywhere.
EXPECT_EQ(OnTerminal({.no_color = "", .term = "xterm-256color"}),
ColorMode::Ansi256);
// It outranks the forcing variables.
EXPECT_EQ(OnTerminal({.no_color = "1", .force_color = "3"}),
ColorMode::NoColor);
EXPECT_EQ(OnTerminal({.no_color = "1", .clicolor_force = "1"}),
ColorMode::NoColor);
}
TEST(CapabilitiesTest, ForceColor) {
// Color even without a terminal, at the depth the level names.
EXPECT_EQ(OffTerminal({.force_color = "1"}), ColorMode::Ansi16);
EXPECT_EQ(OffTerminal({.force_color = "2"}), ColorMode::Ansi256);
EXPECT_EQ(OffTerminal({.force_color = "3"}), ColorMode::Truecolor);
// The level overrides what the terminal claims.
EXPECT_EQ(OnTerminal({.force_color = "1", .colorterm = "truecolor"}),
ColorMode::Ansi16);
// Any other non-empty value enables color without naming a depth.
EXPECT_EQ(OffTerminal({.force_color = "true"}), ColorMode::Ansi16);
EXPECT_EQ(OffTerminal({.force_color = "true", .term = "xterm-256color"}),
ColorMode::Ansi256);
// Zero disables color outright, even on a capable terminal.
EXPECT_EQ(OnTerminal({.force_color = "0", .term = "xterm-256color"}),
ColorMode::NoColor);
}
TEST(CapabilitiesTest, EmptyValuesCarryNoOpinion) {
// An empty variable means the same as an unset one throughout, so a wrapper
// script that exports one without a value changes nothing.
EXPECT_EQ(OffTerminal({.force_color = ""}), ColorMode::NoColor);
EXPECT_EQ(OffTerminal({.clicolor_force = ""}), ColorMode::NoColor);
EXPECT_EQ(OnTerminal({.no_color = "", .term = "xterm-256color"}),
ColorMode::Ansi256);
EXPECT_EQ(OnTerminal({.clicolor = "", .term = "xterm-256color"}),
ColorMode::Ansi256);
EXPECT_EQ(OnTerminal({.force_color = "", .term = "xterm-256color"}),
ColorMode::Ansi256);
}
TEST(CapabilitiesTest, CliColor) {
// The BSD convention: `CLICOLOR_FORCE` enables color off a terminal, and
// `CLICOLOR=0` disables it on one.
EXPECT_EQ(OffTerminal({.clicolor_force = "1"}), ColorMode::Ansi16);
EXPECT_EQ(OffTerminal({.clicolor_force = "1", .term = "xterm-256color"}),
ColorMode::Ansi256);
// `0` means "don't force", not "disable", so a terminal still gets color.
EXPECT_EQ(OnTerminal({.clicolor_force = "0", .term = "xterm-256color"}),
ColorMode::Ansi256);
EXPECT_EQ(OffTerminal({.clicolor_force = "0"}), ColorMode::NoColor);
EXPECT_EQ(OnTerminal({.clicolor = "0", .term = "xterm-256color"}),
ColorMode::NoColor);
EXPECT_EQ(OnTerminal({.clicolor = "1", .term = "xterm-256color"}),
ColorMode::Ansi256);
// Forcing beats disabling.
EXPECT_EQ(OnTerminal({.clicolor_force = "1", .clicolor = "0"}),
ColorMode::Ansi16);
}
TEST(CapabilitiesTest, ColorDepthFromColorterm) {
EXPECT_EQ(OnTerminal({.colorterm = "truecolor", .term = "xterm"}),
ColorMode::Truecolor);
EXPECT_EQ(OnTerminal({.colorterm = "24bit", .term = "xterm"}),
ColorMode::Truecolor);
EXPECT_EQ(OnTerminal({.colorterm = "", .term = "xterm"}), ColorMode::Ansi16);
// `COLORTERM` can't enable color off a terminal, so a rich value inherited
// by a redirected stream can't smuggle escapes into it.
EXPECT_EQ(OffTerminal({.colorterm = "truecolor", .term = "xterm"}),
ColorMode::NoColor);
}
TEST(CapabilitiesTest, ColorDepthFromTermProgram) {
EXPECT_EQ(OnTerminal({.term_program = "vscode", .term = "xterm"}),
ColorMode::Truecolor);
EXPECT_EQ(OnTerminal({.term_program = "iTerm.app", .term = "xterm"}),
ColorMode::Truecolor);
EXPECT_EQ(OnTerminal({.term_program = "WarpTerminal", .term = "xterm"}),
ColorMode::Truecolor);
EXPECT_EQ(OnTerminal({.term_program = "Hyper", .term = "xterm"}),
ColorMode::Truecolor);
EXPECT_EQ(OnTerminal({.term_program = "Tabby", .term = "xterm"}),
ColorMode::Truecolor);
EXPECT_EQ(OnTerminal({.term_program = "Terminus", .term = "xterm"}),
ColorMode::Truecolor);
// Apple's Terminal renders only the 256-color palette.
EXPECT_EQ(OnTerminal({.term_program = "Apple_Terminal", .term = "xterm"}),
ColorMode::Ansi256);
EXPECT_EQ(OnTerminal({.term_program = "unknown", .term = "xterm-256color"}),
ColorMode::Ansi256);
}
TEST(CapabilitiesTest, ColorDepthFromTerm) {
EXPECT_EQ(OnTerminal({.term = "xterm-kitty"}), ColorMode::Truecolor);
EXPECT_EQ(OnTerminal({.term = "alacritty"}), ColorMode::Truecolor);
EXPECT_EQ(OnTerminal({.term = "wezterm"}), ColorMode::Truecolor);
EXPECT_EQ(OnTerminal({.term = "foot-extra"}), ColorMode::Truecolor);
EXPECT_EQ(OnTerminal({.term = "xterm-direct"}), ColorMode::Truecolor);
EXPECT_EQ(OnTerminal({.term = "ghostty"}), ColorMode::Truecolor);
EXPECT_EQ(OnTerminal({.term = "contour-latest"}), ColorMode::Truecolor);
EXPECT_EQ(OnTerminal({.term = "xterm-truecolor"}), ColorMode::Truecolor);
EXPECT_EQ(OnTerminal({.term = "xterm-256color"}), ColorMode::Ansi256);
EXPECT_EQ(OnTerminal({.term = "screen-256color"}), ColorMode::Ansi256);
EXPECT_EQ(OnTerminal({.term = "putty-256"}), ColorMode::Ansi256);
// A terminal matching both a truecolor and a 256-color pattern takes the
// richer one, so the order these are tried in is load-bearing.
EXPECT_EQ(OnTerminal({.term = "vte-256color"}), ColorMode::Truecolor);
// Known to render color, but with nothing saying how much.
EXPECT_EQ(OnTerminal({.term = "xterm"}), ColorMode::Ansi16);
EXPECT_EQ(OnTerminal({.term = "linux"}), ColorMode::Ansi16);
}
TEST(CapabilitiesTest, Charset) {
EXPECT_EQ(ChooseCharset(Preference::Auto, "en_US.UTF-8"), Charset::Utf8);
EXPECT_EQ(ChooseCharset(Preference::Auto, "C.utf8"), Charset::Utf8);
EXPECT_EQ(ChooseCharset(Preference::Auto, "en_US.utf-8"), Charset::Utf8);
// Drawing box characters into a terminal decoding something else turns them
// into several bytes of mojibake and destroys the alignment they were for.
EXPECT_EQ(ChooseCharset(Preference::Auto, "C"), Charset::Ascii);
EXPECT_EQ(ChooseCharset(Preference::Auto, "POSIX"), Charset::Ascii);
EXPECT_EQ(ChooseCharset(Preference::Auto, "en_US.ISO-8859-1"),
Charset::Ascii);
EXPECT_EQ(ChooseCharset(Preference::Auto, ""), Charset::Ascii);
EXPECT_EQ(ChooseCharset(Preference::Never, "en_US.UTF-8"), Charset::Ascii);
EXPECT_EQ(ChooseCharset(Preference::Always, "C"), Charset::Utf8);
}
TEST(CapabilitiesTest, BackgroundFromColorFgBg) {
// `fg;bg`, which is what `rxvt` and its derivatives write.
EXPECT_EQ(ChooseBackground(BackgroundPreference::Auto, "15;0"),
Background::Dark);
EXPECT_EQ(ChooseBackground(BackgroundPreference::Auto, "0;15"),
Background::Light);
// The eighth entry is white and the ninth the dark gray after it, so the
// halves are not simply the low and high eight.
EXPECT_EQ(ChooseBackground(BackgroundPreference::Auto, "0;7"),
Background::Light);
EXPECT_EQ(ChooseBackground(BackgroundPreference::Auto, "15;8"),
Background::Dark);
EXPECT_EQ(ChooseBackground(BackgroundPreference::Auto, "15;6"),
Background::Dark);
EXPECT_EQ(ChooseBackground(BackgroundPreference::Auto, "0;9"),
Background::Light);
// Some terminals write a third field between the two.
EXPECT_EQ(ChooseBackground(BackgroundPreference::Auto, "15;default;0"),
Background::Dark);
EXPECT_EQ(ChooseBackground(BackgroundPreference::Auto, "0;default;15"),
Background::Light);
}
TEST(CapabilitiesTest, BackgroundWithNothingToGoOn) {
// Unset, unparsable, and out of range all say nothing, and what nothing
// gets is the assumption that costs contrast rather than legibility.
EXPECT_EQ(ChooseBackground(BackgroundPreference::Auto, ""), Background::Dark);
EXPECT_EQ(ChooseBackground(BackgroundPreference::Auto, "default;default"),
Background::Dark);
EXPECT_EQ(ChooseBackground(BackgroundPreference::Auto, "0;99"),
Background::Dark);
EXPECT_EQ(ChooseBackground(BackgroundPreference::Auto, "15"),
Background::Dark);
EXPECT_EQ(ChooseBackground(BackgroundPreference::Auto, "15;"),
Background::Dark);
}
TEST(CapabilitiesTest, BackgroundPreferenceWins) {
EXPECT_EQ(ChooseBackground(BackgroundPreference::Dark, "0;15"),
Background::Dark);
EXPECT_EQ(ChooseBackground(BackgroundPreference::Light, "15;0"),
Background::Light);
}
TEST(CapabilitiesTest, Defaults) {
// The defaults describe a plain-text sink, which is what a file or a pipe
// gets and what tests should use unless exercising something richer.
Capabilities capabilities;
EXPECT_EQ(capabilities.color_mode, ColorMode::NoColor);
EXPECT_EQ(capabilities.charset, Charset::Ascii);
EXPECT_EQ(capabilities.background, Background::Dark);
EXPECT_FALSE(capabilities.is_terminal);
EXPECT_FALSE(capabilities.columns.has_value());
}
TEST(CapabilitiesTest, Detect) {
// Detection reads the process environment and the descriptor it is handed, so
// only what neither can change is pinned here. What the policy decides from
// given inputs is tested above, against `ChooseColorMode` and `ChooseCharset`
// directly.
//
// It detects against a file rather than the process's own streams: those are
// a pipe under the test runner but a terminal under a debugger, and an
// exported `FORCE_COLOR` turns color on for either.
auto dir = Filesystem::MakeTmpDir();
ASSERT_TRUE(dir.ok()) << dir.error();
auto file = dir->OpenWriteOnly("out", Filesystem::CreationOptions::CreateNew);
ASSERT_TRUE(file.ok()) << file.error();
Capabilities capabilities = Capabilities::Detect(*file);
// A file is never a terminal.
EXPECT_FALSE(capabilities.is_terminal);
// `COLUMNS` reaches detection from the environment, so whether a width is
// found depends on it, but one that is found is usable.
if (capabilities.columns) {
EXPECT_GT(*capabilities.columns, 0);
}
EXPECT_GT(capabilities.tab_width, 0);
// A preference decides on its own, whatever the environment holds. Color
// forced on picks a depth from the environment, so only that it is on can be
// pinned here.
EXPECT_EQ(Capabilities::Detect(
*file, {.color = Preference::Never, .utf8 = Preference::Never})
.color_mode,
ColorMode::NoColor);
EXPECT_NE(
Capabilities::Detect(*file, {.color = Preference::Always}).color_mode,
ColorMode::NoColor);
EXPECT_EQ(Capabilities::Detect(*file, {.utf8 = Preference::Always}).charset,
Charset::Utf8);
EXPECT_EQ(Capabilities::Detect(*file, {.utf8 = Preference::Never}).charset,
Charset::Ascii);
EXPECT_EQ(
Capabilities::Detect(*file, {.background = BackgroundPreference::Light})
.background,
Background::Light);
EXPECT_EQ(
Capabilities::Detect(*file, {.background = BackgroundPreference::Dark})
.background,
Background::Dark);
(*std::move(file)).Close().Check();
}
} // namespace
} // namespace Carbon::Terminal