mirror of
https://github.com/carbon-language/carbon-lang.git
synced 2026-09-24 21:30:12 +01:00
Rich diagnostic rendering needs a layer underneath it that knows what the attached terminal can do and can position styled text in two dimensions. This adds that layer, both as the foundation the diagnostics rendering work will build on and as something usable directly for ordinary CLI output. Nothing depends on it yet, so it lands and is reviewed on its own. Four libraries, each with its own tests: - `color`: a color, either one of the 16 named ANSI colors or a 24-bit RGB value, and the escape sequences that select it at a given color depth. - `style`: colors plus text attributes, and the escapes that move a terminal from one style to another. - `capabilities`: what the terminal behind a stream supports, detected from the environment. - `buffer`: a grid of styled cells that layout code draws into and that renders itself once. Rationale for the design decisions lives in the headers, next to what it explains. Four things are worth review attention in particular: - The color detection precedence documented on `ChooseColorMode`. It settles how a `--color` flag, `NO_COLOR`, `CLICOLOR`, `FORCE_COLOR`, and the terminal itself interact. The policy is a pure function of those inputs, so the whole table is tested without touching the process environment. - `Charset`, which decides whether any UTF-8 processing happens at all. Column counts only follow from code points if the terminal agrees about the encoding, so anything short of a locale naming UTF-8 is treated as bytes. - `Buffer` owning column accounting instead of its callers, which is what keeps double-width characters, combining marks, and stray bytes from misaligning everything after them. - The API surface, which is held to operations that nothing else covers. Junctions in line art come only from lines overlapping, and turning a style on or off is spelled as a transition to or from the default style. `terminal_benchmark` covers style transitions, full-screen rendering, and text drawing. On an M-series laptop, rendering an 80x24 screen in which every cell changes style costs about 14us with color off and 76-95us with it, and drawing a 40-column line of source costs about 140ns without UTF-8 processing and 394ns with it. Assisted-by: Gemini and Claude --------- Co-authored-by: Richard Smith <richard@metafoo.co.uk>
198 lines
7.8 KiB
C++
198 lines
7.8 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
|
|
|
|
// Adversarial inputs across the terminal library's surface.
|
|
//
|
|
// The library draws whatever a source file contains, for a terminal whose width
|
|
// came from an environment variable. Both are things a user controls, and
|
|
// neither may crash, read out of bounds, or quietly produce a position that is
|
|
// wrong. The tests here feed each entry point the inputs most likely to do one
|
|
// of those, and assert that what comes back is coherent rather than asserting
|
|
// any particular rendering.
|
|
//
|
|
// What is deliberately not here: anything a caller is checked for getting
|
|
// wrong, which is text past `MaxTextBytes` and coordinates outside the width
|
|
// laid out for. Those are death tests in `buffer_test`. What remains is the
|
|
// text and the width, neither of which a caller can validate ahead of drawing.
|
|
|
|
#include <gtest/gtest.h>
|
|
|
|
#include <algorithm>
|
|
#include <limits>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
#include "common/terminal/buffer.h"
|
|
#include "common/terminal/capabilities.h"
|
|
#include "common/terminal/metrics.h"
|
|
#include "common/terminal/style.h"
|
|
#include "llvm/ADT/SmallString.h"
|
|
#include "llvm/ADT/StringRef.h"
|
|
|
|
namespace Carbon::Terminal {
|
|
namespace {
|
|
|
|
// Byte sequences that stress column accounting.
|
|
auto HostileText() -> std::vector<std::string> {
|
|
return {
|
|
"",
|
|
"\x01\x02\x7f", // C0 controls and delete
|
|
"\xff\xfe\xfd", // never valid UTF-8
|
|
"\xe4\xb8", // truncated multi-byte sequence
|
|
"\xe4\xb8\x96\xe4\xb8", // valid then truncated
|
|
"\xcc\x81", // combining mark with no base
|
|
"e\xcc\x81\xcc\x82\xcc\x83", // a base with several marks
|
|
"\xf0\x9f\x94\xa5", // outside the basic plane
|
|
"\xed\xa0\x80", // a surrogate, which UTF-8 forbids
|
|
"\xc0\x80", // overlong encoding of NUL
|
|
"中中中", // double-width throughout
|
|
"a\tb\nc\r\nd", // every positional character
|
|
"\t\t\t\t\t\t\t\t", // nothing but tabs
|
|
"\n\n\n", // nothing but newlines
|
|
std::string(4096, ' '), // a long run of blanks
|
|
std::string(1024, '\t'), // a long run of tabs
|
|
};
|
|
}
|
|
|
|
// Renders `buffer` and checks the bytes are coherent. Everything here draws
|
|
// with the default style, so what a style leaves behind is `buffer_test`'s to
|
|
// cover; this is about the text surviving at all.
|
|
auto RenderAndCheck(const Buffer& buffer, ColorMode mode) -> std::string {
|
|
llvm::SmallString<256> out;
|
|
buffer.Render(out, mode);
|
|
std::string rendered(out);
|
|
if (rendered.empty()) {
|
|
return rendered;
|
|
}
|
|
EXPECT_EQ(rendered.back(), '\n');
|
|
return rendered;
|
|
}
|
|
|
|
TEST(PressureTest, DrawTextSurvivesHostileBytes) {
|
|
for (Charset charset : {Charset::Ascii, Charset::Utf8}) {
|
|
for (const std::string& text : HostileText()) {
|
|
for (int x : {0, 1, 7}) {
|
|
Buffer buffer(8, charset);
|
|
Buffer::DrawEnd end = buffer.DrawText(x, 0, text, Style());
|
|
|
|
// The end is where drawing would carry on, which is not always a cell
|
|
// that exists: text ending in a newline leaves it on a row nothing was
|
|
// drawn on. Nor does the width follow from it, since the grid grows by
|
|
// halves and overshoots. What must hold is that the end names a
|
|
// non-negative cell, and that no row was created past where drawing
|
|
// ended.
|
|
EXPECT_GE(end.y, 0) << text;
|
|
EXPECT_LE(buffer.height(), end.y + 1) << text;
|
|
EXPECT_GE(end.x, 0) << text;
|
|
|
|
// Measuring answers what drawing did.
|
|
EXPECT_EQ(buffer.MeasureText(x, 0, text), end) << text;
|
|
|
|
RenderAndCheck(buffer, ColorMode::Ansi16);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
TEST(PressureTest, WrappedTextSurvivesHostileBytes) {
|
|
for (Charset charset : {Charset::Ascii, Charset::Utf8}) {
|
|
for (const std::string& text : HostileText()) {
|
|
// A width of one is the tightest anything can be asked to wrap into.
|
|
for (int width : {1, 2, 3, 80}) {
|
|
Buffer buffer(width, charset);
|
|
Buffer::DrawEnd end =
|
|
buffer.DrawWrappedText(0, 0, 0, width, text, Style());
|
|
|
|
EXPECT_GE(end.y, 0) << text;
|
|
EXPECT_LE(buffer.height(), end.y + 1) << text;
|
|
EXPECT_GE(end.x, 0) << text;
|
|
EXPECT_EQ(buffer.MeasureWrappedText(0, 0, 0, width, text), end) << text;
|
|
// The width wrapping this wouldn't overhang is a fact about the text
|
|
// rather than about the block it was drawn into.
|
|
EXPECT_GE(buffer.MeasureWrapWidth(text), 0) << text;
|
|
|
|
RenderAndCheck(buffer, ColorMode::Truecolor);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
TEST(PressureTest, MetricsSurviveHostileBytes) {
|
|
for (Charset charset : {Charset::Ascii, Charset::Utf8}) {
|
|
Metrics metrics(charset);
|
|
for (const std::string& text : HostileText()) {
|
|
// `Width` requires text with no positional characters in it, which is
|
|
// checked, so only the rest is measured here.
|
|
if (llvm::StringRef(text).find_first_of("\t\n\r") ==
|
|
llvm::StringRef::npos) {
|
|
int width = metrics.Width(text);
|
|
EXPECT_GE(width, 0) << text;
|
|
|
|
// Cutting at any column gives back a prefix that is no wider than
|
|
// asked for and that leaves the rest of the string behind it.
|
|
for (int columns : {-1, 0, 1, 2, width, width + 1}) {
|
|
llvm::StringRef rest = text;
|
|
llvm::StringRef prefix = metrics.TakeColumns(rest, columns);
|
|
EXPECT_EQ(prefix.size() + rest.size(), text.size()) << text;
|
|
EXPECT_LE(metrics.Width(prefix), std::max(columns, 0)) << text;
|
|
}
|
|
}
|
|
|
|
// Taking code points consumes the whole string however invalid it is,
|
|
// rather than stalling on a byte it can't decode.
|
|
llvm::StringRef rest = text;
|
|
size_t steps = 0;
|
|
while (!rest.empty()) {
|
|
metrics.TakeCodePoint(rest);
|
|
++steps;
|
|
ASSERT_LE(steps, text.size()) << "TakeCodePoint failed to consume";
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
TEST(PressureTest, OverlappingDrawsLeaveNoHalfCharacters) {
|
|
// Double-width characters, lines, and text all writing over each other is
|
|
// where a stale continuation cell would show up as a rendering with half a
|
|
// character in it.
|
|
Buffer buffer(8, Charset::Utf8);
|
|
for (int pass = 0; pass < 3; ++pass) {
|
|
buffer.DrawText(0, 0, "中中中中", Style());
|
|
buffer.DrawHorizontalLine(1, 0, 3, Style());
|
|
buffer.DrawText(2, 0, "中", Style());
|
|
buffer.DrawVerticalLine(3, 0, 2, Style());
|
|
buffer.DrawCodePoint(4, 0, U'中', Style());
|
|
buffer.DrawCodePoint(5, 0, U'x', Style());
|
|
buffer.DrawText(0, 0, "ab", Style());
|
|
}
|
|
|
|
// `Render` encodes every cell it emits, so this checks the overdraws leave a
|
|
// grid that renders at all rather than that no half character survived.
|
|
std::string rendered = RenderAndCheck(buffer, ColorMode::NoColor);
|
|
Metrics metrics(Charset::Utf8);
|
|
llvm::StringRef rest = rendered;
|
|
while (!rest.empty()) {
|
|
EXPECT_NE(metrics.TakeCodePoint(rest), 0xfffd) << rendered;
|
|
}
|
|
}
|
|
|
|
TEST(PressureTest, CapabilitiesWidthNeverBreaksTheBuffer) {
|
|
// A width claimed by the environment can be anything at all.
|
|
for (int columns :
|
|
{-1, 0, 1, 2, 80, Buffer::MaxColumns - 1, Buffer::MaxColumns,
|
|
Buffer::MaxColumns + 1, 1 << 20, std::numeric_limits<int>::max()}) {
|
|
Capabilities capabilities = {.charset = Charset::Utf8, .columns = columns};
|
|
Buffer buffer(capabilities);
|
|
// Whatever was claimed, what comes out is a width that can be laid out for
|
|
// and drawn into.
|
|
EXPECT_GE(buffer.columns(), 1) << columns;
|
|
EXPECT_LE(buffer.columns(), Buffer::MaxColumns) << columns;
|
|
buffer.DrawText(0, 0, "中x", Style());
|
|
RenderAndCheck(buffer, ColorMode::Ansi256);
|
|
}
|
|
}
|
|
|
|
} // namespace
|
|
} // namespace Carbon::Terminal
|