// 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 marking `loc` and saying `text`, reached by `origin`. An empty // `text` marks 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 row below the anchor is an elision, because the file goes on above the // line the snippet starts at. EXPECT_THAT(RenderOne(Plain(), Level::Error, At(4, 3, 7), "bad call"), Eq("error: bad call\n" " .-| foo.carbon:4:3\n" " :\n" "-> 4 | Run0(1);\n" " : -------\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 mark 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("error: missing `;`\n" " ╭─┤ foo.carbon:1:11\n" " │\n" "-> 1 │ var x: i32\n" " · ─\n" " ╰────────────\n" "")); } TEST(RendererTest, WarningLevelWord) { // The snippet starts at the top of the file, so nothing was skipped and the // row below the anchor is just the frame. EXPECT_THAT(RenderOne(Plain(), Level::Warning, At(1, 4, 4), "unused"), Eq("warning: unused\n" " .-| foo.carbon:1:4\n" " |\n" "-> 1 | fn Run0() {}\n" " : ----\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("error: bad\n" " .-| foo.carbon:123456:1\n" " :\n" "->123456 | var x: i32;\n" " : ---\n" " '------------\n" "")); } TEST(RendererTest, NoLocationIsJustTheHeadline) { 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("error: bad\n" " .-| foo.carbon:1:6\n" " |\n" "-> 1 | var x;\n" " : -\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("error: bad\n" " .-| foo.carbon:1:4\n" " |\n" "-> 1 | a<01>=b\n" " : -\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("error: bad\n" " .-| foo.carbon:1:1\n" " |\n" "-> 1 | x = ;\n" " : -\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("error: not found\n" " ╭─┤ foo.carbon:3:16\n" " ┆\n" "-> 3 │ var x: i32 = 世界;\n" " · ────\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("error: at end\n" " .-| foo.carbon:1:40\n" " |\n" "-> 1 | var\n" " : -\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. The row below the anchor is not an elision, // because the snippet starts at the top of the file. EXPECT_THAT(Render(Plain(), diagnostic), Eq("error: too many arguments\n" " .-| foo.carbon:4:3\n" " |\n" " 1 | fn Run0() {}\n" " : ------------\n" " : '--| declared here\n" " :\n" "-> 4 | Run0(1);\n" " : -------\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("error: bad\n" " .-| foo.carbon:3:1\n" " |\n" " 1 | fn Run0() {}\n" " : --\n" " : '--| here\n" " 2 |\n" "-> 3 | fn Main() {\n" " : --\n" " '------------\n" "")); } TEST(RendererTest, NotesInAnotherFileGetTheirOwnAnchor) { 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("error: bad\n" " .-| foo.carbon:4:3\n" " :\n" "-> 4 | Run0(1);\n" " : -------\n" " |------------\n" " |-| bar.carbon:9:1\n" " :\n" " 9 | class C {\n" " : ---------\n" " : '--| declared here\n" " '------------\n" "")); } TEST(RendererTest, APrimaryLabelTeesIntoItsRange) { // The connector leaves the middle of the underline through a junction, so the // mark and what it says are one stroke 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("error: 1 argument passed to function expecting 0\n" " .-| foo.carbon:4:3\n" " :\n" "-> 4 | Run0(1);\n" " : -------\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. // The declaration is in an imported file, so the picture carries the path // above an anchor as well as the rows a single file needs. 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}; Loc import = {.filename = "foo.carbon", .line_number = 2, .column_number = 1}; 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, expecting 0 arguments", {MakeOrigin(import, "imported from")}), MakeLabel(LabelCategory::Primary, At(4, 3, 7), "1 argument passed here")}}; EXPECT_THAT(Render(capabilities, diagnostic), Eq("error: 1 argument passed to function expecting 0 arguments\n" " ╭─┤ foo.carbon:4:3\n" " ┆\n" "-> 4 │ Run0(1);\n" " · ───┬───\n" " · ╰──┤ 1 argument passed here\n" " ├────────────\n" " │ ╭── imported from: foo.carbon:2:1\n" " ├─┤ lib.carbon:1:1\n" " │\n" " 1 │ fn Run0() {}\n" " · ──────┬─────\n" " · ╰──┤ calling function declared here, " "expecting 0 arguments\n" " ╰────────────\n" "")); } TEST(RendererTest, TheMessageRowTheDesignDocDraws) { // The message row illustration in /toolchain/docs/diagnostics_rendering.md, // for the one row an ordinary diagnostic doesn't reach. 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("error: cannot copy argument of type `A`\n" " ╭─┤ foo.carbon:4:8\n" " ┆\n" "-> 4 │ Run0(1);\n" " · ─\n" " ├────────────\n" " ├─ note: 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 marks nothing 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("error: bad call\n" " .-| foo.carbon:4:3\n" " :\n" "-> 4 | Run0(1);\n" " : -------\n" " '------------\n" "")); } TEST(RendererTest, AMessageLocOutsideEveryRangeMarksNothing) { // A range on the line the message's location names stands in for it whether // or not it contains it, so the location itself is not drawn and the line // carries only the range's own mark. 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("error: bad call\n" " .-| foo.carbon:4:3\n" " :\n" "-> 4 | Run0(1);\n" " : ---\n" " '------------\n" "")); } TEST(RendererTest, RepeatedSpansAreOneMark) { // Two labels marking the same range draw the same underline in the same // columns, so they are one mark 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("error: bad call\n" " .-| foo.carbon:4:3\n" " :\n" "-> 4 | Run0(1);\n" " : -------\n" " : '--| wrong\n" " '------------\n" "")); } TEST(RendererTest, ARangeCoveringOthersIsRepairedRatherThanStacked) { // A range attached over the whole expression, with the operands inside it // marked separately, is the shape overlap actually arrives in. Nothing is in // a position to reject it, so it is drawn as one row with the operands // showing through the range that covers them. 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("error: bad call\n" " .-| foo.carbon:4:3\n" " :\n" "-> 4 | Run0(1);\n" " : -------\n" " : | '--| argument\n" " : '--| callee\n" " '------------\n" "")); } TEST(RendererTest, ALabelOnASingleColumnRangeDropsStraightDown) { // With one column marked there is no underline to turn out of, and reaching // in from the side would read as attaching to the column beside it. The // connector starts on the row below instead and takes the gap, which says // the column above is a point and not a range. 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("error: bad index\n" " .-| foo.carbon:4:8\n" " :\n" "-> 4 | Run0(1);\n" " : ---- -\n" " : | '--| with this\n" " : '--| indexed here\n" " '------------\n" "")); } TEST(RendererTest, OverlappingSpansStillShareOneRow) { // Ranges covering some of the same columns are still one row of marks. The // narrower one is drawn over the wider, since a range containing another says // less about the columns they share than the range inside it does. 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("error: bad call\n" " .-| foo.carbon:4:3\n" " :\n" "-> 4 | Run0(1);\n" " : -------\n" " '------------\n" "")); } // The glyphs a row of marks must never hold. A corner reads as a mark turning // into the connector leaving it, a crossing as two marks meeting, and an upward // tee as a mark joining the source above it. None of those is ever what // happened, whatever the ranges on the row are. static constexpr std::array ForbiddenInMarks = { "╭", "╮", "╯", "╰", "┼", "┴"}; // 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 ForbiddenInAsciiMarks = { "'", "+"}; // Returns the row of marks under the source, which is the row after it. static auto MarkRow(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, MarksNeverHoldACornerOrACrossing) { // Adjacency moves the ends of marks and the columns connectors 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