Files
carbon-lang/common/terminal/capabilities.cpp
T
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

244 lines
8.6 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 <sys/ioctl.h>
#include <unistd.h>
#include <cstdlib>
#include "llvm/ADT/StringExtras.h"
#include "llvm/ADT/StringSwitch.h"
namespace Carbon::Terminal {
// Returns the value of `name` in the process environment, empty when unset.
static auto GetEnv(const char* name) -> llvm::StringRef {
const char* value = std::getenv(name);
return value ? llvm::StringRef(value) : llvm::StringRef();
}
auto ColorEnvironment::FromProcess() -> ColorEnvironment {
return {.no_color = GetEnv("NO_COLOR"),
.clicolor_force = GetEnv("CLICOLOR_FORCE"),
.force_color = GetEnv("FORCE_COLOR"),
.clicolor = GetEnv("CLICOLOR"),
.colorterm = GetEnv("COLORTERM"),
.term_program = GetEnv("TERM_PROGRAM"),
.term = GetEnv("TERM")};
}
// Returns whether the environment and the stream call for color, ignoring any
// explicit preference. See `ChooseColorMode` for the precedence this
// implements and where it comes from.
static auto EnvironmentEnablesColor(const ColorEnvironment& env,
bool is_terminal) -> bool {
if (!env.no_color.empty()) {
return false;
}
// The forcing variables use `0` to decline to force, and `FORCE_COLOR` takes
// it further as a request to disable.
if (env.force_color == "0") {
return false;
}
if (!env.force_color.empty() ||
(!env.clicolor_force.empty() && env.clicolor_force != "0")) {
return true;
}
if (env.clicolor == "0") {
return false;
}
if (!is_terminal) {
return false;
}
// `dumb` says outright that escape sequences won't render, which outranks
// anything below claiming they will.
if (env.term == "dumb") {
return false;
}
// Any of these identifies the terminal as something that renders escapes. A
// terminal that none of them describe can't be assumed to.
//
// `COLORTERM` and `TERM_PROGRAM` stand on their own rather than refining
// `TERM`: `TERM` names a terminfo entry, while these name the emulator and
// the color it handles.
return !env.term.empty() || !env.colorterm.empty() ||
!env.term_program.empty();
}
// Returns the richest color escapes the terminal is believed to accept.
//
// Every signal here is a heuristic: there is no way to ask a terminal what it
// supports without writing to it and parsing a reply, which would be far too
// invasive for a compiler. Guessing too high garbles color on a terminal that
// can't keep up, and guessing too low only makes output plainer, so unknown
// terminals get the conservative answer.
static auto DetectColorDepth(const ColorEnvironment& env) -> ColorMode {
// `FORCE_COLOR`'s levels name a depth outright.
if (auto mode = llvm::StringSwitch<std::optional<ColorMode>>(env.force_color)
.Case("1", ColorMode::Ansi16)
.Case("2", ColorMode::Ansi256)
.Case("3", ColorMode::Truecolor)
.Default(std::nullopt)) {
return *mode;
}
// The convention documented at
// https://github.com/termstandard/colors#checking-for-colorterm.
if (env.colorterm == "truecolor" || env.colorterm == "24bit") {
return ColorMode::Truecolor;
}
// `TERM_PROGRAM` identifies the emulator regardless of how `TERM` is set,
// which matters because several of these ship a conservative `TERM` while
// rendering far more than it claims.
{
if (auto mode =
llvm::StringSwitch<std::optional<ColorMode>>(env.term_program)
.Case("vscode", ColorMode::Truecolor)
.Case("iTerm.app", ColorMode::Truecolor)
.Case("WarpTerminal", ColorMode::Truecolor)
.Case("Hyper", ColorMode::Truecolor)
.Case("Tabby", ColorMode::Truecolor)
.Case("Terminus", ColorMode::Truecolor)
// Apple's Terminal renders only the 256-color palette.
.Case("Apple_Terminal", ColorMode::Ansi256)
.Default(std::nullopt)) {
return *mode;
}
}
// The enumerated terminals that stand in for a terminfo lookup.
{
if (auto mode = llvm::StringSwitch<std::optional<ColorMode>>(env.term)
.Case("xterm-kitty", ColorMode::Truecolor)
.Case("alacritty", ColorMode::Truecolor)
.Case("wezterm", ColorMode::Truecolor)
.Case("ghostty", ColorMode::Truecolor)
.StartsWith("foot", ColorMode::Truecolor)
.StartsWith("contour", ColorMode::Truecolor)
.StartsWith("vte", ColorMode::Truecolor)
.EndsWith("-direct", ColorMode::Truecolor)
.EndsWith("-truecolor", ColorMode::Truecolor)
.EndsWith("-256color", ColorMode::Ansi256)
.EndsWith("-256", ColorMode::Ansi256)
.Default(std::nullopt)) {
return *mode;
}
}
// Color is called for, but nothing said how much of it works.
return ColorMode::Ansi16;
}
auto ChooseColorMode(Preference preference, const ColorEnvironment& env,
bool is_terminal) -> ColorMode {
switch (preference) {
case Preference::Never:
return ColorMode::NoColor;
case Preference::Always:
break;
case Preference::Auto:
if (!EnvironmentEnablesColor(env, is_terminal)) {
return ColorMode::NoColor;
}
break;
}
return DetectColorDepth(env);
}
auto ChooseCharset(Preference preference, llvm::StringRef locale) -> Charset {
switch (preference) {
case Preference::Never:
return Charset::Ascii;
case Preference::Always:
return Charset::Utf8;
case Preference::Auto:
break;
}
// Locale names spell the encoding several ways: `en_US.UTF-8`, `C.utf8`, and
// bare `UTF-8` all appear in the wild.
return locale.contains_insensitive("utf-8") ||
locale.contains_insensitive("utf8")
? Charset::Utf8
: Charset::Ascii;
}
auto ChooseBackground(BackgroundPreference preference,
llvm::StringRef colorfgbg) -> Background {
switch (preference) {
case BackgroundPreference::Dark:
return Background::Dark;
case BackgroundPreference::Light:
return Background::Light;
case BackgroundPreference::Auto:
break;
}
// The background is the last field, since some terminals write a third one
// between the foreground and it.
llvm::StringRef background = colorfgbg.rsplit(';').second;
unsigned index = 0;
if (!llvm::to_integer(background, index) || index > 15) {
// Anything else, including the `default` some terminals write and the
// variable being unset, says nothing.
return Background::Dark;
}
// The first eight palette entries are the dark half, except that the eighth
// is white and the ninth is the dark gray that follows it.
return (index <= 6 || index == 8) ? Background::Dark : Background::Light;
}
// Returns the locale that determines the terminal's character encoding,
// following the precedence POSIX defines for `LC_CTYPE`.
static auto GetLocale() -> llvm::StringRef {
for (const char* name : {"LC_ALL", "LC_CTYPE", "LANG"}) {
if (llvm::StringRef value = GetEnv(name); !value.empty()) {
return value;
}
}
return "";
}
// Returns the terminal's width in columns, or nullopt when there is nothing to
// ask.
//
// `COLUMNS` comes first: when it is exported, the user has deliberately
// overridden the real width.
static auto GetColumns(int fd) -> std::optional<int> {
int columns = 0;
if (llvm::to_integer(GetEnv("COLUMNS"), columns) && columns > 0) {
return columns;
}
struct winsize size = {};
if (ioctl(fd, TIOCGWINSZ, &size) == 0 && size.ws_col > 0) {
return size.ws_col;
}
return std::nullopt;
}
auto Capabilities::Detect(Filesystem::WriteFileRef file,
Preferences preferences) -> Capabilities {
int fd = file.unix_fd();
Capabilities capabilities;
capabilities.is_terminal = isatty(fd) != 0;
capabilities.color_mode =
ChooseColorMode(preferences.color, ColorEnvironment::FromProcess(),
capabilities.is_terminal);
capabilities.charset = ChooseCharset(preferences.utf8, GetLocale());
capabilities.background =
ChooseBackground(preferences.background, GetEnv("COLORFGBG"));
capabilities.columns = GetColumns(fd);
return capabilities;
}
} // namespace Carbon::Terminal