194. Diagnostics
One of the CLI’s messages pointed at the exact code; two of them didn’t say where at all. Follow a position from oxc to the caret, read the CLI’s errors and exit codes, and compute the missing position yourself.
- Say what makes a message a diagnostic, using rustc’s structure as the model.
- Trace a source position from oxc’s span through SourceRef and bail to the caret.
- Predict the CLI’s message, help text and exit status for a failure.
- Compute acorn’s line and column from a byte offset, and explain each unit.
Three messages
Here are three things the Rust CLI printed for me (release build, rustc 1.97). The first, for a six-line program (wrapped):
$ viscosity explain --frames k.js
ps (line 1) {x} → kept as array of objects
· unsafe: line 6: an element is used by
push
6 | if (ps[i].x > 3) keep.push(ps[i]);
| ^^^^^
The second, for a file holding const ps = [;, and the third, for a
file that doesn’t exist, are as the CLI printed them when this chapter
was first written (wrapped):
$ viscosity explain bad.js
viscosity: SyntaxError: Unexpected token
$ viscosity explain nope.js
viscosity: No such file or directory (os
error 2)
The first tells you what and where, down to the character. The second
tells you what, but not where. The third doesn’t even say which file.
The TypeScript CLI printed Unexpected token (1:12) and
ENOENT: no such file or directory, open 'nope.js' for the same two.
Both gaps are closed now; the last section shows how.
“Your car is making a noise” is a complaint. “The front left brake squeals when you stop” is a diagnosis: it names the part, so you can go and look.
What a diagnostic has
rustc’s own guide lists the parts of an error: a level (error,
warning), an optional code such as E0308, a message that
makes sense on its own, and a window with the file, line and column of
the primary span, the code itself, and labels under the spans
[1]. Viscosity’s frames have the
message, a line, the code and carets. They have no level, no code and
no file name. Its syntax errors had only a message.
A diagnostic is a compiler message tied to the source: a message that stands on its own, plus a span (where it starts and ends) that a tool turns into a file, a line, a column and a picture of the code. A message without a span is just an error.
From a span to a caret
Chapter 185 drew the frame. Here is where its numbers come from, for
the ps[i] in the first message:
Three facts to notice. The position is made once, during lowering
(Lowerer::src in lower.rs), and then rides along on the instruction;
no later pass touches it. The line in the message and the frame come
from the same SourceRef, so they can’t disagree. And a frame is only
as good as its span. For for (const p of ps) keep.push(p);, the
element p was made by the for...of statement, so its SourceRef is
the whole statement, and both engines underline the whole line:
5 | for (const p of ps) keep.push(p);
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Correct, and useless. tests/extra/frames_columns.js holds the
opposite, carefully pointed cases: tabs, emoji before the span, CRLF,
U+2028 and a span over two lines. Its .frames.txt fixture is the
TypeScript output, and parity.rs checks the Rust output against it
byte for byte.
The CLI’s own messages
Failures in crates/cli/src/main.rs are one of two kinds (adapted):
/// Only a usage error (the command line
/// itself was wrong) gets the help text.
enum CliError {
Usage(String),
Other(String),
}
and main turns them into text and an exit status:
match run(&argv, &mut out, &mut err) {
Ok(true) => ExitCode::SUCCESS,
Ok(false) => ExitCode::FAILURE,
Err(CliError::Usage(e)) => {
let _ = write!(err,
"viscosity: {e}\n\n{HELP}");
ExitCode::FAILURE
}
Err(CliError::Other(e)) => {
let _ = writeln!(err,
"viscosity: {e}");
ExitCode::FAILURE
}
}
ExitCode::SUCCESS and FAILURE are the standard library’s canonical
codes[2]: 0 and 1 here. The same shape,
small enough to run:
use std::process::ExitCode;
const HELP: &str = "Usage: ...";
enum CliError {
Usage(String),
Other(String),
}
// What main prints on stderr
fn report(e: &CliError) -> String {
match e {
CliError::Usage(m) => format!(
"viscosity: {m}\n\n{HELP}"
),
CliError::Other(m) => {
format!("viscosity: {m}")
}
}
}
fn main() -> ExitCode {
let fails = [
CliError::Usage(
"unknown option --colour"
.into(),
),
CliError::Other(
"Unexpected token (1:12)"
.into(),
),
];
for e in &fails {
println!("{}", report(e));
println!("(exit status 1)\n");
}
ExitCode::SUCCESS
}
What the real CLI did for me:
command exit help?
explain a.js 0
explain --colour a.js 1 yes
explain --jobs 0 a.js 1 yes
explain 1 yes
explain bad.js 1 no
explain nope.js 1 no
explain a.js nope.js 1 no
Ok(false) is the multi-file case: each failing file is reported as
viscosity: nope.js: No such file or directory (os error 2) while the
others are still printed, and the status is 1 at the end. There the
file name is present, because the loop adds it.
The rule “help only for usage errors” matters. A syntax error followed by thirty lines of options buries the one line you need, and suggests that you typed the command wrong when the problem is in your file.
Where the position went
oxc knows where the syntax error is. Its parser builds it as
OxcDiagnostic::error("Unexpected token").with_label(span), and the
label holds the span. But OxcDiagnostic’s Display prints only the
message (I read both in the oxc 0.156 sources), and the plugin wrote
format!("SyntaxError: {e}"). The span was dropped on the floor.
To print what acorn prints, (1:12), the plugin needs the label’s
start and one more function: from a byte offset to acorn’s line
(counted from 1, at the same line breaks as LineIndex) and column
(from 0, in UTF-16 units: for const s = "🎉"; [; acorn[3]
says (1:17), though that ; is byte 19). That’s your exercise, and
it is what the port does now, in syntax_error (adapted):
match e.labels.first() {
Some(l) => {
let ix = LineIndex::new(src);
let (line, col) =
ix.line_column(src, l.offset());
CompileError(format!(
"{e} ({line}:{col})"))
}
None => CompileError(e.to_string()),
}
Both CLIs print viscosity: Unexpected token (1:12) for bad.js
now, with no SyntaxError: prefix in either. The words can still
differ, since oxc writes its own messages, and so can the position of
an early error that oxc anchors on another token (chapter 172). The
file name came back the simple way: a single missing file now gives
cannot read nope.js: No such file or directory (os error 2).
rustc’s guide asks two things of a diagnostic that Viscosity could
copy. A primary span should make sense even if it were the only thing
shown, say as an underline in an editor. And when a longer explanation
would say more than the message, the error gets a code, whose
explanation --explain prints[1].
Viscosity’s unsafe: reasons are stable, so they could carry codes. Its
for...of frames fail the first test: nobody chose that span, so you
get whichever one was attached.
Your turn
The Rust CLI prints Unexpected token (1:12) as the TypeScript one does, by turning oxc’s byte offset into acorn’s position. Write line_col(src, at): the line (from 1) and column (from 0) of byte offset at. Lines break at \n, \r, \r\n (one break), U+2028 and U+2029. Columns count UTF-16 units. Every expected value in the tests is what acorn printed.
fn line_col(
src: &str,
at: usize,
) -> (usize, usize) {
todo!()
}What you learned
- A diagnostic is a message plus a span; rustc adds a level, a code and labels.
- A position is made once, at lowering, as a
SourceRef, and both the message’s line and the frame come from it. - The CLI has two failure kinds: usage errors (help text) and other errors (one line); the exit status is 0 or 1.
- oxc’s span was once dropped from syntax errors; now it becomes
acorn’s
(line:col), whose column counts UTF-16 units.
Check your understanding
0/6- 1.
Which of these is a diagnostic in this chapter’s sense?
- 2.
For
viscosity explain --colour a.js, what does the Rust CLI do? - 3.
Why doesn’t a syntax error get the help text?
- 4.
Where does the “line 6” in “line 6: an element is used by push” come from?
- 5.
Running
viscosity explain a.js bad.js b.js, where bad.js has a syntax error: what happens? - 6.
In
"let é = [;", the;is byte 10. Which column does acorn report?
References
- [1] The Rust Project Developers. Rust Compiler Development Guide: Errors and lints (diagnostic structure). rustc-dev-guide.rust-lang.org; source in github.com/rust-lang/rustc-dev-guide. rustc-dev-guide.rust-lang.org/diagnostics.html (checked 2026-10-31)
- [2] The Rust Project Developers. Struct std::process::ExitCode (Rust standard library documentation). Rust standard library API documentation; source in library/std/src/process.rs of github.com/rust-lang/rust. doc.rust-lang.org/std/process/struct.ExitCode.html (checked 2026-10-31)
- [3] Acorn contributors. Acorn: a tiny, fast JavaScript parser written in JavaScript (README). github.com/acornjs/acorn. github.com/acornjs/acorn/tree/master/acorn (checked 2026-10-31)