Diagnostics rendering
Table of contents
- Overview
- What a diagnostic is made of
- Prior art
- The rendered form
- Style
- Fitting the terminal
- Normalizing source text
- Examples
- Implementation
- Future work
- Alternatives considered
- References
Overview
This document covers the form a diagnostic takes on a terminal: what one is made of, how it is laid out, its color and character set, and how each degrades when the terminal can’t render it. Diagnostics covers how one is produced and worded.
Rendering is built on common/terminal, which detects capabilities, models color and style, measures text, and holds a grid of styled cells. Layout decides what to show and measures it against the grid.
The audience for a rendered diagnostic is a person glancing at an error. Editors get diagnostics from the language server as structure, so editor integration is out of scope, and so is what a complete build should give an agent, which is a separate question. A build log gets one line per fact from --no-diagnostic-snippets, and a golden test file gets the drawing without the parts that a moved line would re-render, from --no-diagnostic-line-numbers.
What a diagnostic is made of
A diagnostic reports one problem. It has a kind, its identity in the registry and what a test matches on, and a level, error or warning.
Its message is the one sentence saying what is wrong, and it begins with the level word. It has a location, where an editor would put the cursor, and one or more ranges that the problem covers: the location’s own, plus any the emitter added, such as the operand of an & that has no address to take. A range containing the location is underlined instead of the location itself, and the message’s text is placed under that range; every other range is underlined by itself. In the rendered form the text appears under the range it marks, and in the compact form or an editor it appears by itself, so it is worded for both.
Labels are attached to the message to explain it. A label is a range of source with optional text saying what that range has to do with the problem, read together with the code it marks: declared here is not a sentence anyone would read alone. A label with no text marks its range and nothing more, telling the reader to look there but not why, which is all a Clang note’s ranges convey.
The path is how the message’s location was reached: an import, an #include, a macro expansion. Each step is drawn as a single <text>: <location> row above the anchor of the file it reached, with no snippet. The emitter calls a step location information.
Which code emits the message and which attaches a label is the emitter’s concern: a callee that can’t state the problem as well as its caller builds the message through a diagnoser the caller passes down, and a caller that only knows what it was in the middle of attaches a label to everything emitted inside a context scope. Diagnostics covers both. Either way, one message states the problem and everything else explains it.
Prior art
Every renderer here is shown the same diagnostic, one argument passed to a function declared with none. The rustc, Clang, and GCC blocks are those compilers’ own output on the equivalent program; the ariadne and miette blocks are the Carbon diagnostic fed through those libraries.
Rust
rustc puts the message first and the location on its own row inside the gutter, and puts the first label on the underline row:
error[E0061]: this function takes 0 arguments but 1 argument was supplied
--> run0.rs:4:3
|
4 | run0(1);
| ^^^^ - unexpected argument of type `{integer}`
|
note: function defined here
--> run0.rs:1:4
|
1 | fn run0() {}
| ^^^^
A five-row fix-it follows. Taken from here: the most important words are read together with the code, and ^ versus - distinguishes a primary range from a secondary one without color. Not taken: the frame per message, which makes one problem look like two blocks with two location rows.
Ariadne
ariadne puts the location in the frame’s first row, places labels under their ranges, and continues one frame through every file:
Error: 1 argument passed to function expecting 0 arguments
╭─[ foo.carbon:4:3 ]
│
4 │ Run0(1);
│ ───┬───
│ ╰───── 1 argument passed here
│
├─[ lib.carbon:1:1 ]
│
1 │ fn Run0() {}
│ ──────┬─────
│ ╰─────── calling function declared here, expecting 0 arguments
───╯
All three are taken.
Nushell and miette
Nushell renders through miette, which closes the frame into a box and sets annotations off from source with ·:
× 1 argument passed to function expecting 0 arguments
╭─[foo.carbon:4:3]
3 │ fn Call() {
4 │ Run0(1);
· ───┬───
· ╰── 1 argument passed here
5 │ }
╰────
The closed frame, short close, and separator are taken. A miette report holds one source, so the second file cannot appear.
Clang and GCC
Both put the location first. Clang uses six rows, which is the density to match:
foo.cpp:4:3: error: no matching function for call to 'Run0'
4 | Run0(1);
| ^~~~
./lib.h:1:6: note: candidate function not viable: requires 0 arguments, but 1 was provided
1 | void Run0();
| ^
GCC adds an In function line and an In file included from line before the note, which Clang leaves out. The argument for this shape is file:line:column: at the start of every line, which every tool has historically parsed; not taking it is the first entry under Alternatives considered. One Clang choice is taken: the include stack is printed for the error’s own location, never for a note’s.
On this diagnostic the form here uses ten rows with a snippet of both files, against Clang’s six, GCC’s eight, miette’s eight with one file, ariadne’s thirteen, and rustc’s eleven before its suggestion.
The rendered form
A diagnostic is one frame, and the sentence stating the problem is drawn inside it, under the exact range that is wrong:
╭─┤ foo.carbon:4:3
-> 4 │ Run0(1);
· ━━━┳━━━
· ┗━━┫ error: 1 argument passed to function expecting 0 arguments
┆
├─┤ lib.carbon:1:4
1 │ fn Run0() {}
· ──┬─
· ╰──┤ calling function declared here
╰────
The parts of a frame
╷ ╭── imported from: c.carbon:2:1 path row
├─┤ b.carbon:3:1 anchor
-> 3 │ fn Shape() {} source row, marked in the margin
· ━━━━━━┳━━━━━ mark row: the mark
· ┗━━┫ error: duplicate name ... label row: connector, bar, text
┆ elision
├─┤ a.carbon:3:7 anchor for a second file
3 │ class Shape {} source row
· ──┬── mark row
· ╰──┤ previously declared here label row
╰──── close
-
The frame runs from the
╭of the first anchor to the╰────close. -
An anchor,
╭─┤ <file>:<line>:<column>, gives the location of the part whose snippet is below it: the message’s, or a label’s for a file the message is not in. It is a location to go to, not the first row shown: ranges in one file share one view of it in source order, so a declaration above the problem is drawn above the message, and the anchor’s line can name a row further down. Unknown parts of the location are omitted from the right. The frame’s first anchor starts with╭, any other with├. -
A source row is a line of the file, numbered in the gutter. The line the message is placed under is marked
->in the margin, and its number takes the level’s color: that is where to begin reading, wherever it is in the frame. -
A mark row holds the marks, the underlines under ranges, with
·in place of the frame so the row isn’t mistaken for source. The mark the message is placed under is heavy; every other mark is light, in the level’s colors for another of the message’s ranges and the label color for a label’s. Everything marking one line goes on one row. -
A label row holds the text placed under a mark: a connector leaves the mark at a tee and meets the bar framing the text,
╰──┤. The message’s row is a label row beginning with the level word,error:, with bold text and a heavy connector and bar. -
An elision row,
┆, continues the frame as a dotted line across whatever isn’t contiguous: skipped lines, or the step to the next anchor. A single skipped line is shown instead, which takes the same number of rows. -
A path row,
╭── <text>: <location>, is drawn above an anchor and states how that file was reached, its line continuing down into the anchor’s bracket. -
A part with no source, because its location names only a file or nothing at all, is the fallback. It still gets an anchor, naming what location it has or
<no location>, and its text is placed under the anchor instead of under a mark, starting at the source’s first column. A message’s text is placed the same way, beginning with the level word, with the->on its anchor, and the file’s other ranges follow under that anchor past an elision:-> ╭─┤ thunk.carbon · ┗━━┫ error: parameter has incomplete type `B` in function definition ┆ 7 │ class B; · ┬ · ╰──┤ class was forward declared here ┆ 11 │ fn F(c: C); · ─────┬───── · ╰──┤ while building thunk calling this function ╰────Today every part drawn this way is in code the compiler generated or imported without a location; giving each one a location is future work.
Finding the message
There is no headline. Everything that makes the message findable is on its own row: the level word, its weight and color, the heavy mark, and the -> in the margin of its line. With color the red error: is the first thing seen; without it the -> points to the heavy mark, whose connector runs down to the sentence. The reading order is the same either way: the sentence and its code, the other labels as needed, and the anchors as the coordinates for going there.
The drawback is that the message is not at a fixed row of its frame, since a declaration above the problem is drawn first. The fixed positions to scan for in a long log are the ╭ that starts every diagnostic and the -> in column zero, drawn once per frame on the row to begin reading at. A message with no source to mark is placed under its anchor, and the -> marks the anchor.
One frame per diagnostic, rather than one per message, shows the reader where one problem ends and the next begins. Each file the diagnostic points into gets an anchor inside that frame, and the frame continues as a dotted ┆ rather than breaking.
The message is placed under its location’s range unless another of its ranges contains that location, in which case that range is used instead: the location is a point chosen for a cursor, and the range shows what the problem covers. Overlapping ranges are drawn widest first, so the narrowest mark on a column is the one left visible, except that the heavy mark is drawn last so nothing repaints it. A range inside it appears only as the tee where its connector leaves the heavy stroke, or not at all if it has no text.
Placing the text
The rows under a mark row hold the text placed under its marks, laid out by these rules in this order.
Order. The rows read top to bottom the way the code reads left to right: the message first, then each label in the order of its range. Each label’s text starts two columns right of the text above, so the indentation steps with the order.
Meeting the text. A connector reaches the bar on the first row of its text, where the level word is, so following the heavy line arrives at the start of the sentence. The bar continues down every row the text wraps onto.
Placement. Every label’s text, the message’s included, starts right of every connector’s column, so a connector descending to a later label runs beside the text above it, never behind it.
Crossings. Reading order makes a later connector descend through rows already drawn. Earlier rows keep what they drew: the descending line breaks around each run it meets in a single cell, never at a junction, and the gap shows that the two don’t connect. On a terminal wide enough that nothing wraps this is the common shape: a label right of a one-row message descends ┬, a gap, ╰, with no vertical cell showing. The tee and the corner are what show the connection.
Out-dented blocks. Text that does not fit under its mark, because its widest word does not fit right of the connector, is out-dented to the source’s column (see Fitting the terminal). A block occupies its rows entirely, blank cells included, so a connector descending past it skips those rows and resumes below.
Quoted code. Wrapping breaks at spaces, except inside backticks: a quoted type or snippet moves to the next row whole or extends past the edge like a path.
A mark and the connector and bar of its label take the color of the message they belong to, following rustc; the label’s text does not, since color belongs on what it points at. Only the message’s marks are heavy and bold: the heavy strokes supply the emphasis where a terminal ignores bold on line art, and bold matches the marks to the text where it doesn’t.
Ranges that touch would run together, so an end that meets another range stops at the center of its cell, leaving a gap, but only where the range still has a column for its label’s connector. A one-column mark that gives up an end has nothing to tee into, so its connector is drawn down the column in place of it, from the cell’s center, and two such columns side by side look like two marks. ASCII has no half cell, so it does not draw the gap.
Several files in one diagnostic
Each file a diagnostic points into gets an anchor, in the order the parts reach it, and the frame runs down through all of them.
Only the path to the problem itself is drawn: the message’s own location, never a label’s. How the file holding a declaration was reached is rarely useful to the reader, since there is one import to find and the filename already names the file; Clang and GCC suppress note include stacks for the same reason. Where the problem itself is in a reached file, as when two imports collide, the path is the part of the reader’s own build that led there.
A path is dim: it shows how the reader got somewhere, not what is wrong, and an include chain is as deep as it is. Its rows have the form <text>: <location>, which is why they use included from rather than in file included here.
The compact form
Where there is no source to frame, the terminal is under 60 columns wide, or --no-diagnostic-snippets asked for it, a diagnostic renders as one line per part with text, each beginning with its location and the extent of its range (none for a single column):
missing.carbon: error: unable to open file: No such file or directory
foo.carbon:4:3-9: error: 1 argument passed to function expecting 0 arguments
lib.carbon:1:4-7: note: calling function declared here
Nothing is positioned against a line number, which is what a build log needs, and the extent is still stated without the underline. This is rustc’s --error-format=short, keeping the location miette’s ErrorStyle::Short drops and the extent Clang keeps behind -fdiagnostics-print-source-range-info.
Sixty columns is a guess; nothing depends on it being right.
Diagnostics from C++ interop
Clang’s diagnostics are drawn here rather than by Clang. Its message, location, ranges, and fix-its each become the thing they correspond to. A location names a point and is marked as the one column Clang’s caret marks; a range containing that point is used instead, the ~~~ of ^~~~, so the text is placed under the whole range. The diagnostic’s other ranges are the message’s, in the level’s color; a note’s are labels with no text, in the label color, as Clang would draw them. A fix-it becomes a label saying what to do, which is still shown in the compact form, where an inline rendering would not be; carrying the edit as data is future work.
SemIR::ConvertClangRangeToLoc turns a Clang range into the location the renderer needs, on both the path with a Carbon Context and the one without. Several notes on one line share its source row, each placed under its own mark.
Overload-resolution candidate notes are written as <what was considered>: <why it was not viable> and mark the source the second half is about, so each becomes two labels: the candidate on the declaration the note names, and the reason on what Clang marked. The notes split are listed by kind, since any note might contain a colon. Moving the split upstream is future work.
The #include and macro expansion stacks arrive as location information and read included from, imported from module at, and expanded from macro defined at above the anchor of the message’s own location. They are left out for a note, as Clang leaves them out.
Style
The palette
Styles are named for what they mark, so changing a color is one edit.
| Element | Style | Rationale |
|---|---|---|
error | bold, bright red | Clang, GCC, and rustc agree. |
warning | bold, bright yellow | rustc’s choice and the conventional caution color; Clang and GCC use magenta. |
note | bold, bright cyan | GCC’s choice; distinct from error and warning at any brightness. Begins a label’s line in the compact form; in the frame a label’s text is placed under its mark or anchor with no level word. |
| Message | bold, no color | The longest run of text; color belongs on what it points at. Bold only when placed under code. |
| Frame | bold, bright blue | rustc’s choice. Not dim, which several terminals don’t implement. |
| Line numbers | bright blue | Not bold, so the one number colored as the reported line stands out. |
| Location in an anchor | plain | The bracket already delimits it. |
| Location in the compact form | bold | Begins the line with nothing around it. |
| The message’s marks | the level color, bold | Makes the range and the sentence one unit. |
| A label’s marks and connectors | the label ramp | Connects a mark to its text. Not bold or heavy: that emphasis is the message’s alone. |
| Path | dim | How the reader got here, not what is wrong. |
| Kind | dim | Only present under a flag. |
On a terminal with only the sixteen named colors those are emitted, so they render through the palette the user chose, which is what makes bright yellow usable, since as an RGB value it fails against white. Where the terminal can express more, colors are given as RGB, and each theme gets a ramp of three:
| dark background | light background | |
|---|---|---|
| Error | #b55600 #ff7261 #ffbcc8 | #803500 #d00000 #ff286a |
| Warning | #518600 #acac00 #ffc921 | #3d5900 #737300 #ad8c00 |
| Label | #00829a #00bcbc #00f3c6 | #005967 #007f7f #00a78e |
A ramp is assigned darker, center, lighter, so several ranges on one line read as a progression; a range with no others of its theme beside it takes the center. The mark the message is placed under always takes the center, and its other ranges on that line take successive colors from the ramp, with the center left out.
The three centers are at one lightness: bright yellow is over four times the luminance of bright red, which would make a warning brighter than an error, and readers take brightness for importance. Lightness varies inside a theme, where a change in it means a different range, but not across themes, where it would mean a more important one.
Which palette is used follows Capabilities::background, reached by --terminal-background and COLORFGBG; a terminal that says nothing is taken to be dark. Dim is used only where a terminal that ignores it loses nothing: a path rendered without dim is still readable as a path, while line numbers rendered without dim would make the frame as prominent as the code.
Character set
| Element | Charset::Utf8 | Charset::Ascii |
|---|---|---|
| Frame and connectors | │ ╭ ├ ╰ ─ ┤ ╯ | \| . \| ' - \| ' |
| Frame stub above a path row | ╷ | \| |
| Mark and label rows’ frame | · | : |
| Elision | ┆ | : |
| Light marks | ─ ┬ ╷ ╴ ╶ | - . \| - - |
| The message’s marks | ━ ┳ ┗ ┫ ┃ ╻ ╸ ╺ | ^ ^ ' \| \| ^ ^ ^ |
| Out-dented message’s reach | ┏ ┛ | . ' |
| A label’s tee on the heavy | ┯ | . |
| The margin’s pointer | -> | -> |
The last two light marks and the last two heavy ones are ends that stop at the center of their cell, the gap between touching ranges.
Everything is drawn as lines through Terminal::Buffer, which picks the glyph for the character set and forms corners and tees where lines meet, so a connector joins an underline at a junction and a light connector on the heavy underline forms ┯. Each glyph is one column in both character sets, so the frame occupies the same columns. A label’s connector never joins the message’s: sharing its column it leaves the shared stretch heavy, and crossing its rows it gaps.
ASCII has no weights, so weight degrades to one distinction. A cell holding a heavy stroke that no line enters from above is ^, the emphatic underline C++ compilers have long used: the heavy run, the tee the message’s own connector leaves it by, and a one-column mark that the connector replaces. The connector below keeps a label’s strokes. A light tee degrades to ., the corner for a line leaving downward, on a light run or the heavy one: the dashes either side draw the through-stroke, and the branch is the only thing marking the connection when the connector below may be rows away with only gaps between. + is a crossing where lines connect, which a diagnostic never draws.
A run of ~ provides no junction for a connector to join, and Unicode has no one-column wavy character. A glyph also has to be one column in practice, not only by the width tables: a terminal that counts one as two cells shifts the rest of its row, and in the margin that puts the source a column off from its underline. Terminals disagree on dingbats, emoji-presentation shapes, and everything East Asian Ambiguous, so the pointer is two ASCII characters rather than an arrow.
Color and character set are independent, and every distinction is carried by something other than color, so plain output loses appearance and no information.
Fitting the terminal
Nothing a diagnostic says is ever dropped; source outside a marked range is what gets elided. The width comes from COLUMNS if exported, otherwise from the terminal with TIOCGWINSZ, and otherwise is the width code is formatted to plus the gutter, so a formatted line is shown whole.
-
A source line too wide is windowed around its range, with
...at each elided end and the underline shifted to match, since a wrapped source line and a wrapped underline would land on different rows. Ranges sharing a line share one window. -
Words longer than the width wrap into a column of their own, past the level word, rather than at column zero where a continuation would look like another diagnostic. Wrapping breaks only at spaces outside backticks, so a path, a URL, or a quoted type overhangs rather than splitting.
-
Connectors slide left before any text wraps. Since text starts right of every connector, making room for one label’s text can require moving several: each connector right of where the text needs to start slides within its own range, as far as the widest word requires, whichever label it belongs to.
-
A label whose widest word still does not fit is out-dented to the source’s column, keeping its place in the reading order, with the top of its bar turning right to reach back to its connector. Several such blocks stack in that order.
Normalizing source text
Source text is normalized before it is measured or drawn. Tabs expand to eight-column stops, bytes with no printable rendering become <XX>, and a trailing carriage return is dropped. Under Charset::Utf8 the buffer measures double-width characters as two columns and combining marks as none, and replaces invalid UTF-8. Under Charset::Ascii, chosen from the locale since the locale determines how bytes will be decoded, every byte outside printable ASCII is escaped. The range’s ends are measured against the normalized text through the buffer that draws it, so the two agree.
Examples
These are the renderer’s output transcribed by hand: two on a terminal with 24-bit color and Unicode, one without color, and the last with neither. The tour CI checks is toolchain/diagnostics/testdata/fail_diagnostics_demo.carbon. Generating them is the HTML target under Future work.
Several labels on one line
The message is placed under the operator, which connects the syntax to the interface it reports missing, a rule the reader has no other way to learn. The operands are labeled with the types they contributed, since a type is nothing the source can show. Their marks take successive colors from the label ramp, the labels read in operand order stepping right, and the right operand’s connector runs beside the message, crossing its run in single cells. That connector is at its range’s start rather than its middle because the message’s text occupies the columns its middle would need.
╭─┤ example.carbon:4:16
-> 4 │ return count * flag;
· ──┬── ┳ ┬───
· │ ┗━━━━┫ error: type `i32` does not implement interface
· │ │ ┃ `Core.MulWith(bool)`
· ╰──────────┤ left operand has type `i32`
· ╰──────┤ right operand has type `bool`
╰────
Several files, reached by an import
The problem is in an imported file, so the path by which the reader’s own build reached it draws above its anchor, dim. The declaration it collides with gets a bare anchor, since a label’s path is never drawn, and the label marks the name.
╷ ╭── imported from: c.carbon:2:1
├─┤ b.carbon:3:1
-> 3 │ fn Shape() {}
· ━━━━━━┳━━━━━
· ┗━━┫ error: duplicate name `Shape` in the same scope
┆
├─┤ a.carbon:3:7
3 │ class Shape {}
· ──┬──
· ╰──┤ previously declared here
╰────
Labels from the operations underway
The error is in the prelude, where MakeInt rejects a width of zero, so that is where the message is placed. The labels are on the reader’s own code: each operation the checker was in the middle of, monomorphizing a specific and completing a type, attached one to whatever was diagnosed inside it.
╭─┤ int.carbon:18:9
-> 18 │ adapt MakeInt(N);
· ━━━━━┳━━━━
· ┗━━┫ error: integer type width of 0 is not positive
┆
├─┤ call.carbon:4:17
4 │ var unused v: Core.Int(N);
· ─────┬─────
· ╰──┤ in monomorphization of `i0` here
· ╰────┤ in completion of type `i0` here
┆
8 │ ErrorIfNIsZero(0);
· ───────┬──────
· ╰──┤ in monomorphization of `ErrorIfNIsZero(0)` here
╰────
Without color or Unicode
The message’s mark degrades to ^, every other mark to - with a . where a label leaves it, and the ->, the ^ run, and the level word still say where to look first:
.-| foo.carbon:4:3
-> 4 | Run0(1);
: ^^^^^^^
: '--| error: 1 argument passed to function expecting 0 arguments
:
|-| lib.carbon:1:4
1 | fn Run0() {}
: --.-
: '--| calling function declared here
'----
Implementation
Diagnostics::Renderer in toolchain/diagnostics holds the terminal capabilities and draws a Diagnostic into a Terminal::Buffer. Loc is data and draws nothing. StreamConsumer owns a renderer and writes its bytes to a stream. The crash handlers’ PrintSnippet draws one range in plain ASCII, sharing the normalizing and windowing but not the layout.
Three invariants shape the code:
-
Everything is drawn as lines through the buffer, so every corner and tee is a junction it forms, and the ASCII fallback is whatever it draws lines with. The message’s marks are the same lines drawn heavy.
-
The renderer repairs what it is given rather than failing: a range past the end of its line is clamped, a location missing parts still draws, and overlapping ranges are drawn. Rendering must never be the reason a compiler crashes while reporting a problem.
-
Nothing depends on the environment unless something measured it.
Terminal::Capabilities::Detectneeds a file descriptor, so the driver passes the error stream’s alongside the stream, and where there is none, as in tests, nothing is detected.
Nothing is rendered until a diagnostic is emitted; every run performs one Detect. Escape sequences are computed only where the style changes between cells, and each diagnostic reaches the stream as one write, so it never interleaves with another writer.
Command line flags
--color=auto|always|neverselects whether to emit color.--terminal-unicode=auto|always|neverselects box drawing, detected from the locale by default.--terminal-background=auto|dark|lightsets the background, read fromCOLORFGBGby default and assumed dark otherwise.--no-diagnostic-snippetsrenders every diagnostic in the compact form.--no-diagnostic-line-numberswithholds the gutter’s numbers and names any later file without a position, leaving the layout the same as the numbered one. This is the form golden files capture.--include-diagnostic-kindappends each part’s kind,[NameNotFound], dim. Tests match on it.
Testing
renderer_test.cpp checks the layout: each row kind, every level, the shapes ranges on one row can take (swept rather than named), each thing dropped as the width runs out (covered separately, since they are chosen one after another), both character sets, and color in Ansi16 and Truecolor. renderer_fuzzer builds a diagnostic from arbitrary input and checks that it renders without crashing.
File tests cover the rendering end to end. toolchain/diagnostics/testdata keeps full numbering, and its fail_rendering.carbon checks the ASCII form. Every other file_test runs with --terminal-unicode=always and --no-diagnostic-line-numbers, so its goldens are the frames nearly every terminal gets, with one location apiece, the leading anchor’s, which autoupdate maintains as a [[@LINE...]] offset. Full numbering would make a golden depend on the positions of its own CHECK lines, and an anchor in a second file names lines no offset can express. The rows above the leading anchor, the path, take its location in ToolchainFileTest::FinalizeCheckLines so a frame moves as one block.
Goldens differ from what a user sees in two ways: every file test also passes --include-diagnostic-kind, so a wrapped block may use an extra row that only the kind made necessary, and a test writing any snippet or line-number flag in ARGS opts out of the default numbering, which the diagnostics tour relies on.
Future work
None of this is needed for the rendering to be useful.
-
Removing location-less parts: everything that reaches the renderer with only a filename, such as conversions in compiler-generated thunks and C++ imports without a mapped location, restructured to carry one, and a diagnostic about a file as a whole anchored at its start or end.
-
Ranges spanning several lines, which are clamped to their first line today, usually a declaration’s least informative part.
-
Two ranges on one line too far apart to share a window. The one outside the window is marked at its edge, over the
..., rather than getting its own view of the line. -
Marking ranges within the source row, with color on or behind the text, where past two or three the underlines stack faster than a reader can match them; it has to leave the row no worse to read without color.
-
Styling within a message, so quoted code can be emphasized. The arguments are user data, so scanning for delimiters is wrong; structured styled runs (
rustc) and semantic markup in the format string (GCC) are the options to consider. -
Semantic highlight roles, GCC’s idea: a parameter named
expectedoractualkeeps one color across the diagnostic. Carbon’s parameters are already typed. -
Fix-it hints as data, one structure for Carbon’s and Clang’s: a range, replacement text, and a confidence, so
carbon fix, an editor, and the renderer work from the same edit. The hints on one diagnostic or note are one edit, applied together or not at all, as Clang means them: a(inserted in one place and its)in another. Render them as GCC does, a unified diff of the line as written and as fixed, which also covers a fix that inserts or removes whole lines; Clang’s replacement text under a column looks like another label. -
Splitting Clang’s candidate notes upstream, where Clang knows which half is which, so nothing here has to take them apart by kind.
-
A diagnostic inside a macro expansion shows the expansion site’s line number with the macro definition’s text; the snippet should follow the number.
-
An HTML target. A cell carries its style as data until
Terminal::Buffer::Render, so a sink emitting<span>elements would traverse the same grid, and this document’s examples would stop being transcribed by hand. -
A screen reader mode, probably a different form with the level and location in words and the range quoted, which needs a screen reader user to judge.
Alternatives considered
-
A headline row above the frame, as an earlier iteration did. It uses a row to say, away from the code, what the message says better on its mark, and splits the cues a reader finds a diagnostic by between the headline and the mark below. It provides a fixed row to scan for, which the
╭corner and the margin’s->provide instead. -
Stacking labels rightmost first, so no connector crosses another. Every line stayed whole, but the rows read in reverse and the message could end up mid-stack. Reading order matters more than unbroken connectors.
-
Meeting wrapped words at their middle row, where a brace would. A reader following the heavy line then arrives partway through the sentence.
-
Out-denting a label before its text fails to fit, whenever placing it under its mark would wrap deeply. At seventy columns a message anchored far right can wrap four ways in a twenty-column column where out-denting would give two clean rows. Placing it under the mark is kept: out-denting adds a reach-back row and moves the text away from its mark, and a threshold would be one more guess.
-
Clang and GCC’s location-first header. The message is what the reader needs, and the tooling case for the location is met by the language server and the compact form.
-
A frame per message, as
rustcdraws: simpler, but one diagnostic looks like several. -
A frame per file, separated by blank lines: a blank line stops meaning “next diagnostic”, and it uses more rows.
-
A heavier frame, with a blank row under each anchor, a rule between snippets, and a full-width close. Each added a row that showed nothing the neighboring rows didn’t already show.
-
A path above every anchor. On anything explanatory it restates the filename, and it was the most prominent text in the frame and the least useful.
-
A message row,
├─ note: <text>, for a part with no source, as an earlier iteration did. It was a row shorter than an anchor and its label, but a form of its own, which dropped the part’s filename and began with a level word no other label has. -
Closing the anchor after the location,
╭─┤ file:1:1 │, asariadnedraws it: the location then looks like a caption rather than where the snippet comes from. -
Placing the path below the anchor. It only reads correctly with the steps reversed, since each row’s subject is the file named on the row above.
-
Underlining with
~, as Clang does: a run of characters gives a connector no junction to join, and doesn’t degrade through the same line drawing as the frame. -
Reverse video on the source row instead of an underline, which halves a snippet’s rows. It shows nothing without color, and the mark row is where every connector tees in.
-
Emoji for the severity. They are one column in some terminals and two in others, and a font drawing one in color ignores the color asked for, which is what separates a warning from an error.
References
- Diagnostics for how a diagnostic is produced and worded.
common/terminalfor the capability, color, style, and buffer model this is built on.annotate-snippets, the rendererrustcuses.miette, the renderer Nushell uses.- GCC diagnostic message formatting options
gcc/text-art, GCC’s canvas of styled cells, the closest thing in another compiler toTerminal::Buffer.