// 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 "toolchain/diagnostics/renderer.h" #include #include #include #include #include "common/raw_string_ostream.h" #include "common/terminal/capabilities.h" #include "common/terminal/metrics.h" #include "llvm/ADT/STLExtras.h" #include "llvm/ADT/Sequence.h" #include "llvm/ADT/SmallString.h" namespace Carbon::Diagnostics { namespace { using ::testing::Eq; using ::testing::HasSubstr; using ::testing::Not; // Returns a function that formats as `text`, whatever it is handed. static auto Says(llvm::StringRef text) -> FormatFn { return [text = text.str()](llvm::StringLiteral /*format*/, llvm::ArrayRef /*args*/) { return text; }; } // Builds a step in the path by which a location was reached. static auto MakeOrigin(Loc loc, llvm::StringLiteral text) -> LocationInfo { return {.loc = loc, .name = "TestOrigin", .format = text}; } // Builds a message whose formatted text is `text`, reached by `origin`. static auto MakeMessage(Loc loc, llvm::StringRef text, llvm::SmallVector origin = {}, Level level = Level::Error) -> Message { return {.kind = Kind::TestDiagnostic, .level = level, .loc = loc, .location_info = std::move(origin), .format = "", .format_args = {}, .format_fn = Says(text)}; } // Builds a label attached to `loc` and saying `text`, reached by `origin`. An // empty `text` underlines the range and says nothing. static auto MakeLabel(LabelCategory category, Loc loc, llvm::StringRef text, llvm::SmallVector origin = {}) -> Label { return {.category = category, .loc = loc, .location_info = std::move(origin), .name = "TestLabel", .format = "", .format_args = {}, .format_fn = text.empty() ? FormatFn() : Says(text)}; } // Builds a context saying `text` about `loc`, reached by `origin`. static auto MakeContext(Loc loc, llvm::StringRef text, llvm::SmallVector origin = {}) -> Context { return {.loc = loc, .location_info = std::move(origin), .name = "TestContext", .format = "", .format_args = {}, .format_fn = Says(text)}; } // The columns of source a diagnostic targets with no terminal to measure, which // is `Renderer`'s own `FormattedSourceColumns`. Spelled again here rather than // exposed: a test that pins the width should say which width it pins. static constexpr int TargetSourceColumns = 80; // The capabilities of a stream with no terminal behind it, which is what tests // and redirected output get. static auto Plain() -> Terminal::Capabilities { return {}; } // The capabilities of a terminal too narrow to hold a frame, which is how a // diagnostic with source to show still reaches the compact form. static auto Narrow() -> Terminal::Capabilities { return {.is_terminal = true, .columns = 40}; } // Renders `diagnostic` for `capabilities`. static auto Render(const Terminal::Capabilities& capabilities, const Diagnostic& diagnostic, bool snippets = true) -> std::string { Renderer renderer(capabilities); renderer.set_snippets(snippets); llvm::SmallString<256> bytes; renderer.Render(bytes, diagnostic); return std::string(bytes); } // Renders a diagnostic with a single message at `level`. static auto RenderOne(const Terminal::Capabilities& capabilities, Level level, Loc loc, llvm::StringRef text) -> std::string { return Render(capabilities, {.level = level, .message = MakeMessage(loc, text, {}, level)}); } // The file the tests point spans into. static constexpr llvm::StringLiteral File = "fn Run0() {}\n" "\n" "fn Main() {\n" " Run0(1);\n" "}\n"; // Returns a location naming only a file, which is what a part attached at no // particular code converts to. static auto FileOnly() -> Loc { return {.filename = "foo.carbon"}; } // Returns a location in `File` naming a line but no column. No producer emits // one -- a location either reaches source or names at most a file -- but a // `Loc` is a plain aggregate, so the renderer still has to draw it. static auto LineOnly(int line_number) -> Loc { return {.filename = "foo.carbon", .line_number = line_number}; } // Returns a location in `File`. static auto At(int line_number, int column, int length) -> Loc { llvm::StringRef rest = File; for (auto _ : llvm::seq(1, line_number)) { rest = rest.split('\n').second; } return {.filename = "foo.carbon", .line = rest.split('\n').first, .file_text = File, .line_number = line_number, .column_number = column, .length = length}; } TEST(RendererTest, ErrorWithSnippet) { // The common case: the location row starts the frame, the message's text is // placed under its own range after the level word, and the close ends it. EXPECT_THAT(RenderOne(Plain(), Level::Error, At(4, 3, 7), "bad call"), Eq(" .-| foo.carbon:4:3\n" "-> 4 | Run0(1);\n" " : ^^^^^^^\n" " : '--| error: bad call\n" " '----\n" "")); } TEST(RendererTest, ASpanStartingWhereTheLineEndsIsStillDrawn) { // A location can name the column after the last character on a line: a // fix-it inserting there, or a token synthesized past the end of the line by // error recovery. The underline reaches one column further than the source // rather than the renderer refusing to draw it. Loc loc = {.filename = "foo.carbon", .line = "var x: i32", .file_text = "var x: i32", .line_number = 1, .column_number = 11, .length = 1}; // Drawn as UTF-8 because that is where the span is walked back to the start // of whatever symbol it lands in, and there is no symbol at the end of a line // to walk back through. Terminal::Capabilities capabilities = {.charset = Terminal::Charset::Utf8}; EXPECT_THAT(RenderOne(capabilities, Level::Error, loc, "missing `;`"), Eq(" ╭─┤ foo.carbon:1:11\n" "-> 1 │ var x: i32\n" " · ┳\n" " · ┗━━┫ error: missing `;`\n" " ╰────\n" "")); } TEST(RendererTest, WarningLevelWord) { EXPECT_THAT(RenderOne(Plain(), Level::Warning, At(1, 4, 4), "unused"), Eq(" .-| foo.carbon:1:4\n" "-> 1 | fn Run0() {}\n" " : ^^^^\n" " : '--| warning: unused\n" " '----\n" "")); } TEST(RendererTest, FrameMovesRightForLongLineNumbers) { Loc loc = {.filename = "foo.carbon", .line = "var x: i32;", .line_number = 123456, .column_number = 1, .length = 3}; EXPECT_THAT(RenderOne(Plain(), Level::Error, loc, "bad"), Eq(" .-| foo.carbon:123456:1\n" "->123456 | var x: i32;\n" " : ^^^\n" " : '--| error: bad\n" " '----\n" "")); } TEST(RendererTest, NoLocationIsOneCompactRow) { EXPECT_THAT(RenderOne(Plain(), Level::Error, {}, "no file here"), Eq("error: no file here\n")); } TEST(RendererTest, NothingToFrameFallsBackToCompact) { EXPECT_THAT( RenderOne(Plain(), Level::Error, {.filename = "foo.carbon"}, "broken"), Eq("foo.carbon: error: broken\n")); Loc line_only = {.filename = "foo.carbon", .line_number = 3}; EXPECT_THAT(RenderOne(Plain(), Level::Error, line_only, "broken"), Eq("foo.carbon:3: error: broken\n")); } TEST(RendererTest, TabsExpandToStops) { Loc loc = {.filename = "foo.carbon", .line = "\tvar\tx;", .line_number = 1, .column_number = 6, .length = 1}; // The tabs reach columns 8 and 16, so `x` lands in column 17. EXPECT_THAT(RenderOne(Plain(), Level::Error, loc, "bad"), Eq(" .-| foo.carbon:1:6\n" "-> 1 | var x;\n" " : ^\n" " : '--| error: bad\n" " '----\n" "")); } TEST(RendererTest, UnprintableBytesAreEscaped) { Loc loc = {.filename = "foo.carbon", .line = "a\x01=b", .line_number = 1, .column_number = 4, .length = 1}; EXPECT_THAT(RenderOne(Plain(), Level::Error, loc, "bad"), Eq(" .-| foo.carbon:1:4\n" "-> 1 | a<01>=b\n" " : ^\n" " : '--| error: bad\n" " '----\n" "")); } TEST(RendererTest, NonAsciiIsEscapedUnderAscii) { Loc loc = {.filename = "foo.carbon", .line = "x = \xc3\xa9;", .line_number = 1, .column_number = 1, .length = 1}; EXPECT_THAT(RenderOne(Plain(), Level::Error, loc, "bad"), Eq(" .-| foo.carbon:1:1\n" "-> 1 | x = ;\n" " : ^\n" " : '--| error: bad\n" " '----\n" "")); } TEST(RendererTest, WideCharactersTakeTwoColumnsUnderUtf8) { Terminal::Capabilities capabilities = {.charset = Terminal::Charset::Utf8}; // `世界` is at byte offset 15, and occupies four columns. Loc loc = {.filename = "foo.carbon", .line = " var x: i32 = \xe4\xb8\x96\xe7\x95\x8c;", .line_number = 3, .column_number = 16, .length = 6}; EXPECT_THAT(RenderOne(capabilities, Level::Error, loc, "not found"), Eq(" ╭─┤ foo.carbon:3:16\n" "-> 3 │ var x: i32 = 世界;\n" " · ━━┳━\n" " · ┗━━┫ error: not found\n" " ╰────\n" "")); } TEST(RendererTest, SpanPastEndOfLineIsClamped) { Loc loc = {.filename = "foo.carbon", .line = "var", .line_number = 1, .column_number = 40, .length = 10}; EXPECT_THAT(RenderOne(Plain(), Level::Error, loc, "at end"), Eq(" .-| foo.carbon:1:40\n" "-> 1 | var\n" " : ^\n" " : '--| error: at end\n" " '----\n" "")); } TEST(RendererTest, MergedShowsOneViewOfTheFile) { Diagnostic diagnostic = { .level = Level::Error, .message = MakeMessage(At(4, 3, 7), "too many arguments"), .labels = { MakeLabel(LabelCategory::Info, At(1, 1, 12), "declared here")}}; // The note is above the error in the file, so it is drawn first, and the two // lines between them are elided. EXPECT_THAT(Render(Plain(), diagnostic), Eq(" .-| foo.carbon:4:3\n" " 1 | fn Run0() {}\n" " : ------.-----\n" " : '--| declared here\n" " :\n" "-> 4 | Run0(1);\n" " : ^^^^^^^\n" " : '--| error: too many arguments\n" " '----\n" "")); } TEST(RendererTest, MergedShowsASingleSkippedLine) { Diagnostic diagnostic = { .level = Level::Error, .message = MakeMessage(At(3, 1, 2), "bad"), .labels = {MakeLabel(LabelCategory::Info, At(1, 1, 2), "here")}}; // Only line 2 is between them, so it is shown rather than elided. EXPECT_THAT(Render(Plain(), diagnostic), Eq(" .-| foo.carbon:3:1\n" " 1 | fn Run0() {}\n" " : -.\n" " : '--| here\n" " 2 |\n" "-> 3 | fn Main() {\n" " : ^^\n" " : '--| error: bad\n" " '----\n" "")); } TEST(RendererTest, NotesInAnotherFileGetTheirOwnLocationRow) { Loc other = {.filename = "bar.carbon", .line = "class C {", .line_number = 9, .column_number = 1, .length = 9}; Diagnostic diagnostic = { .level = Level::Error, .message = MakeMessage(At(4, 3, 7), "bad"), .labels = {MakeLabel(LabelCategory::Info, other, "declared here")}}; EXPECT_THAT(Render(Plain(), diagnostic), Eq(" .-| foo.carbon:4:3\n" "-> 4 | Run0(1);\n" " : ^^^^^^^\n" " : '--| error: bad\n" " :\n" " |-| bar.carbon:9:1\n" " 9 | class C {\n" " : ----.----\n" " : '--| declared here\n" " '----\n" "")); } TEST(RendererTest, APrimaryLabelTeesIntoItsRange) { // The label line leaves the middle of the underline through a junction, so // the underline and what it says are one line and the words point into the // range rather than off an end of it. Diagnostic diagnostic = { .level = Level::Error, .message = MakeMessage(At(4, 3, 7), "1 argument passed to function expecting 0"), .labels = {MakeLabel(LabelCategory::Primary, At(4, 3, 7), "1 argument passed here")}}; EXPECT_THAT( Render(Plain(), diagnostic), Eq(" .-| foo.carbon:4:3\n" "-> 4 | Run0(1);\n" " : ^^^^^^^\n" " : '--| error: 1 argument passed to function expecting 0\n" " : '----| 1 argument passed here\n" " '----\n" "")); } TEST(RendererTest, TheShapeTheDesignDocDraws) { // The anatomy illustration in /toolchain/docs/diagnostics_rendering.md, so // that the picture there is output this produced rather than one drawn by // hand and left behind by the next change. static constexpr llvm::StringLiteral LibFile = "fn Run0() {}\n"; Loc declaration = {.filename = "lib.carbon", .line = "fn Run0() {}", .file_text = LibFile, .line_number = 1, .column_number = 1, .length = 12}; Terminal::Capabilities capabilities = {.charset = Terminal::Charset::Utf8}; Diagnostic diagnostic = { .level = Level::Error, .message = MakeMessage( At(4, 3, 7), "1 argument passed to function expecting 0 arguments"), .labels = {MakeLabel(LabelCategory::Info, declaration, "calling function declared here")}}; EXPECT_THAT(Render(capabilities, diagnostic), Eq(" ╭─┤ foo.carbon:4:3\n" "-> 4 │ Run0(1);\n" " · ━━━┳━━━\n" " · ┗━━┫ error: 1 argument passed to function " "expecting 0 arguments\n" " ┆\n" " ├─┤ lib.carbon:1:1\n" " 1 │ fn Run0() {}\n" " · ──────┬─────\n" " · ╰──┤ calling function declared here\n" " ╰────\n" "")); // The same diagnostic on a terminal with neither color nor Unicode, which is // the document's degradation example. EXPECT_THAT(Render(Plain(), diagnostic), Eq(" .-| foo.carbon:4:3\n" "-> 4 | Run0(1);\n" " : ^^^^^^^\n" " : '--| error: 1 argument passed to function " "expecting 0 arguments\n" " :\n" " |-| lib.carbon:1:1\n" " 1 | fn Run0() {}\n" " : ------.-----\n" " : '--| calling function declared here\n" " '----\n" "")); } TEST(RendererTest, AFileOnlyLabelIsPlacedUnderItsLocationRow) { // A label whose location names only a file has its text placed under a // location row of its own, which an ordinary diagnostic doesn't need. Terminal::Capabilities capabilities = {.charset = Terminal::Charset::Utf8}; Diagnostic diagnostic = { .level = Level::Error, .message = MakeMessage(At(4, 8, 1), "cannot copy argument of type `A`"), .labels = {MakeLabel(LabelCategory::Info, FileOnly(), "type `A` does not implement interface " "`Core.Copy`")}}; EXPECT_THAT( Render(capabilities, diagnostic), Eq(" ╭─┤ foo.carbon:4:8\n" "-> 4 │ Run0(1);\n" " · ┳\n" " · ┗━━┫ error: cannot copy argument of type `A`\n" " ┆\n" " ├─┤ foo.carbon\n" " · ╰──┤ type `A` does not implement interface `Core.Copy`\n" " ╰────\n" "")); } TEST(RendererTest, ARangeContainingTheMessageLocHostsIt) { // A range containing the message's location is drawn over the code that // location names, so it stands in for it and the location has no underline of // its own. Diagnostic diagnostic = { .level = Level::Error, .message = MakeMessage(At(4, 3, 1), "bad call"), .labels = {MakeLabel(LabelCategory::Primary, At(4, 3, 7), "")}}; EXPECT_THAT(Render(Plain(), diagnostic), Eq(" .-| foo.carbon:4:3\n" "-> 4 | Run0(1);\n" " : ^^^^^^^\n" " : '--| error: bad call\n" " '----\n" "")); } TEST(RendererTest, ARangeElsewhereOnTheLineDoesNotHostTheMessage) { // Only a range containing the message's location takes its place. A range // elsewhere on the same line is not about the code the location names, so // the message keeps its own underline and text. Diagnostic diagnostic = { .level = Level::Error, .message = MakeMessage(At(4, 3, 1), "bad call"), .labels = {MakeLabel(LabelCategory::Primary, At(4, 8, 3), "")}}; EXPECT_THAT(Render(Plain(), diagnostic), Eq(" .-| foo.carbon:4:3\n" "-> 4 | Run0(1);\n" " : ^ ---\n" " : '--| error: bad call\n" " '----\n" "")); } TEST(RendererTest, RepeatedSpansAreOneUnderline) { // Two labels attached to the same range draw the same underline in the same // columns, so they are one underline rather than two identical rows. A // message that a context leads and the range attached to it reach this shape. Diagnostic diagnostic = { .level = Level::Error, .message = MakeMessage(At(4, 3, 1), "bad call"), .labels = {MakeLabel(LabelCategory::Primary, At(4, 3, 7), ""), MakeLabel(LabelCategory::Primary, At(4, 3, 7), "wrong")}}; EXPECT_THAT(Render(Plain(), diagnostic), Eq(" .-| foo.carbon:4:3\n" "-> 4 | Run0(1);\n" " : ^^^^^^^\n" " : '--| error: bad call\n" " : '----| wrong\n" " '----\n" "")); } TEST(RendererTest, ARangeCoveringOthersIsRepairedRatherThanStacked) { // A range attached over the whole expression, with ranges attached to the // operands inside it, is the shape overlap actually arrives in. Nothing is in // a position to reject it, so it is drawn as one row: the covering range is // the heavy underline and stays whole, and each operand shows as the tee // where its label line leaves that underline -- which ASCII keeps as the // corner inside the `^` run, since the `^` would hide where the label line // left. Diagnostic diagnostic = { .level = Level::Error, .message = MakeMessage(At(4, 3, 1), "bad call"), .labels = {MakeLabel(LabelCategory::Primary, At(4, 3, 7), ""), MakeLabel(LabelCategory::Info, At(4, 3, 4), "callee"), MakeLabel(LabelCategory::Info, At(4, 8, 1), "argument")}}; EXPECT_THAT(Render(Plain(), diagnostic), Eq(" .-| foo.carbon:4:3\n" "-> 4 | Run0(1);\n" " : ^^.^^.^\n" " : |'----| error: bad call\n" " : '-------| callee\n" " : '------| argument\n" " '----\n" "")); } TEST(RendererTest, ALabelOnASingleColumnRangeDropsStraightDown) { // With one column underlined there is no underline to turn out of, and // reaching in from the side would read as attaching to the column beside it. // The label line drops straight down the underline's own column, teeing into // it. Diagnostic diagnostic = { .level = Level::Error, .message = MakeMessage(At(4, 8, 1), "bad index"), .labels = {MakeLabel(LabelCategory::Primary, At(4, 3, 4), "indexed here"), MakeLabel(LabelCategory::Primary, At(4, 8, 1), "with this")}}; EXPECT_THAT(Render(Plain(), diagnostic), Eq(" .-| foo.carbon:4:8\n" "-> 4 | Run0(1);\n" " : --.- ^\n" " : | '--| error: bad index\n" " : '-------| indexed here\n" " : '------| with this\n" " '----\n" "")); } TEST(RendererTest, OverlappingSpansStillShareOneRow) { // Ranges covering some of the same columns are still one underline row. The // wider one here is the heavy underline, drawn last, so a range inside it // with no text of its own shows nothing. Diagnostic diagnostic = { .level = Level::Error, .message = MakeMessage(At(4, 3, 1), "bad call"), .labels = {MakeLabel(LabelCategory::Primary, At(4, 3, 7), ""), MakeLabel(LabelCategory::Info, At(4, 7, 3), "")}}; EXPECT_THAT(Render(Plain(), diagnostic), Eq(" .-| foo.carbon:4:3\n" "-> 4 | Run0(1);\n" " : ^^^^^^^\n" " : '--| error: bad call\n" " '----\n" "")); } TEST(RendererTest, ARangeInsideTheHeavyUnderlineShowsAsItsTee) { // The heavy underline is drawn last, so a narrower range inside it leaves it // whole, with no stretch of it light or in a ramp color, and the range shows // as the tee where its label line leaves the heavy underline. Over any other // underline, the narrower range is drawn on top. Terminal::Capabilities capabilities = {.charset = Terminal::Charset::Utf8}; Diagnostic diagnostic = { .level = Level::Error, .message = MakeMessage(At(4, 3, 7), "bad call"), .labels = {MakeLabel(LabelCategory::Info, At(4, 3, 4), "callee"), MakeLabel(LabelCategory::Info, At(4, 8, 1), "argument")}}; EXPECT_THAT(Render(capabilities, diagnostic), Eq(" ╭─┤ foo.carbon:4:3\n" "-> 4 │ Run0(1);\n" " · ━━┯┳━┯━\n" " · │┗━━━━┫ error: bad call\n" " · ╰───────┤ callee\n" " · ╰──────┤ argument\n" " ╰────\n" "")); } // The glyphs an underline row must never hold. A corner reads as an underline // turning into the label line leaving it, a crossing as two underlines meeting, // and an upward tee as an underline joining the source above it. None of those // is ever what happened, whatever the ranges on the row are. static constexpr std::array ForbiddenInUnderlines = { "╭", "╮", "╯", "╰", "┼", "┴"}; // The ASCII stand-ins for the same shapes: `'` is a corner arriving from // above and `+` a crossing. `.`, the corner leaving downward, is left out // because it is also how a point with no room for anything else degrades. static constexpr std::array ForbiddenInAsciiUnderlines = {"'", "+"}; // Returns the underline row under the source, which is the row after it. static auto UnderlineRow(llvm::StringRef rendered) -> std::string { llvm::SmallVector rows; rendered.split(rows, '\n'); for (auto [index, row] : llvm::enumerate(rows)) { if (row.contains("abcdefghij") && index + 1 < rows.size()) { return rows[index + 1].str(); } } return ""; } TEST(RendererTest, UnderlinesNeverHoldACornerOrACrossing) { // Adjacency moves the ends of underlines and the columns label lines leave // them from, which is where a shape nothing drew could come from. This sweeps // what ranges on one row can be rather than naming the combinations one at a // time. static constexpr llvm::StringLiteral Line = " abcdefghijklmnopqrstuvwxyz"; auto at = [&](int column, int length) { return Loc{.filename = "f.carbon", .line = Line, .line_number = 1, .column_number = column, .length = length}; }; struct Placed { int start; int length; bool has_words; }; auto render = [&](llvm::ArrayRef ranges, Terminal::Charset charset, LabelCategory category) { Terminal::Capabilities capabilities = {.charset = charset}; llvm::SmallVector