on master help sign in
Use ariadne crate for error reporting with Unicode box-drawing characters
commit85d802f812abefab291a279dfd6b97a35a2b13cc authoriko <mail@iko.soy> dateFri 20 Mar 2026, 03:56 (6 months ago) parentbb3b4b0
4 files changed+61−127
Cargo.lock+25 Cargo.toml+1 crates/knot/Cargo.toml+3 crates/knot/src/diagnostic.rs+32−127
Cargo.lock+25
@@ -30,6 +30,16 @@ version = "1.4.2"
3030source = "registry+https://github.com/rust-lang/crates.io-index"
3131checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
3232
33+[[package]]
34+name = "ariadne"
35+version = "0.6.0"
36+source = "registry+https://github.com/rust-lang/crates.io-index"
37+checksum = "8454c8a44ce2cb9cc7e7fae67fc6128465b343b92c6631e94beca3c8d1524ea5"
38+dependencies = [
39+ "unicode-width",
40+ "yansi",
41+]
42+
3343[[package]]
3444name = "ascii"
3545version = "1.1.0"
@@ -511,6 +521,9 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
511521[[package]]
512522name = "knot"
513523version = "0.1.0"
524+dependencies = [
525+ "ariadne",
526+]
514527
515528[[package]]
516529name = "knot-compiler"
@@ -889,6 +902,12 @@ version = "1.0.24"
889902source = "registry+https://github.com/rust-lang/crates.io-index"
890903checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
891904
905+[[package]]
906+name = "unicode-width"
907+version = "0.2.2"
908+source = "registry+https://github.com/rust-lang/crates.io-index"
909+checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
910+
892911[[package]]
893912name = "universal-hash"
894913version = "0.5.1"
@@ -944,6 +963,12 @@ dependencies = [
944963 "zeroize",
945964]
946965
966+[[package]]
967+name = "yansi"
968+version = "1.0.1"
969+source = "registry+https://github.com/rust-lang/crates.io-index"
970+checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
971+
947972[[package]]
948973name = "zeroize"
949974version = "1.8.2"
Cargo.toml+1
@@ -21,3 +21,4 @@ tiny_http = "0.12"
2121num-bigint = "0.4"
2222num-traits = "0.2"
2323num-integer = "0.1"
24+ariadne = "0.6"
crates/knot/Cargo.toml+3
@@ -2,3 +2,6 @@
22name = "knot"
33version.workspace = true
44edition.workspace = true
5+
6+[dependencies]
7+ariadne = { workspace = true }
crates/knot/src/diagnostic.rs+32−127
@@ -1,7 +1,5 @@
11//! Error reporting infrastructure for the Knot compiler.
22
3−use std::fmt::Write;
4−
53use crate::ast::Span;
64
75// ── Types ────────────────────────────────────────────────────────────
@@ -58,7 +56,7 @@ impl Diagnostic {
5856 }
5957}
6058
61−// ── Source helpers ────────────────────────────────────────────────────
59+// ── Source helpers (still used by LSP and other consumers) ───────────
6260
6361/// Returns `(line, col)` for a byte offset. Line is 1-based, column is 0-based.
6462pub fn line_col(source: &str, byte_offset: usize) -> (usize, usize) {
@@ -82,7 +80,6 @@ pub fn get_line(source: &str, line: usize) -> &str {
8280 if line == 0 {
8381 return "";
8482 }
85− // Find the start of the target line by counting newlines in bytes
8683 let bytes = source.as_bytes();
8784 let mut current_line = 1;
8885 let mut start = 0;
@@ -95,145 +92,53 @@ pub fn get_line(source: &str, line: usize) -> &str {
9592 None => return "",
9693 }
9794 }
98− // Find the end of this line
9995 let end = bytes[start..].iter().position(|&b| b == b'\n')
10096 .map_or(source.len(), |pos| start + pos);
10197 &source[start..end]
10298}
10399
104−// ── ANSI color helpers ───────────────────────────────────────────────
105−
106−fn use_color() -> bool {
107− if std::env::var_os("NO_COLOR").is_some() {
108− return false;
109− }
110− std::io::IsTerminal::is_terminal(&std::io::stderr())
111−}
112−
113−struct Colors {
114− red: &'static str,
115− yellow: &'static str,
116− cyan: &'static str,
117− blue: &'static str,
118− bold: &'static str,
119− reset: &'static str,
120−}
121−
122−const COLORS_ON: Colors = Colors {
123− red: "\x1b[31m",
124− yellow: "\x1b[33m",
125− cyan: "\x1b[36m",
126− blue: "\x1b[34m",
127− bold: "\x1b[1m",
128− reset: "\x1b[0m",
129−};
130−
131−const COLORS_OFF: Colors = Colors {
132− red: "",
133− yellow: "",
134− cyan: "",
135− blue: "",
136− bold: "",
137− reset: "",
138−};
139−
140−fn colors() -> &'static Colors {
141− if use_color() { &COLORS_ON } else { &COLORS_OFF }
142−}
143−
144−// ── Rendering ────────────────────────────────────────────────────────
100+// ── Rendering (ariadne) ─────────────────────────────────────────────
145101
146102impl Diagnostic {
147103 pub fn render(&self, source: &str, filename: &str) -> String {
148− let mut out = String::new();
149− let c = colors();
104+ use ariadne::{CharSet, Config, ColorGenerator, Label as ALabel, Report, ReportKind, Source};
150105
151− // Header: "error: message" or "warning: message"
152− let (sev, sev_color) = match self.severity {
153− Severity::Error => ("error", c.red),
154− Severity::Warning => ("warning", c.yellow),
106+ let kind = match self.severity {
107+ Severity::Error => ReportKind::Error,
108+ Severity::Warning => ReportKind::Warning,
155109 };
156− let _ = writeln!(
157− out,
158− "{sev_color}{bold}{sev}{reset}: {bold}{msg}{reset}",
159− bold = c.bold,
160− reset = c.reset,
161− msg = self.message,
162− );
163110
164− // Group labels by line number so we only show each source line once.
165− let mut by_line: std::collections::BTreeMap<usize, Vec<&Label>> =
166− std::collections::BTreeMap::new();
167− for label in &self.labels {
168− let (line, _) = line_col(source, label.span.start);
169− by_line.entry(line).or_default().push(label);
170− }
171−
172− // Compute gutter width from the largest line number we'll display.
173− let max_line = by_line.keys().last().copied().unwrap_or(1);
174− let gutter = if max_line == 0 { 1 } else { (max_line.ilog10() + 1) as usize };
111+ // Pick the offset for the report header from the first label, or 0.
112+ let header_offset = self.labels.first().map_or(0, |l| l.span.start);
175113
176− for (&line_no, labels) in &by_line {
177− let src_line = get_line(source, line_no);
114+ let mut colors = ColorGenerator::new();
178115
179− // Location arrow for the first label in this group.
180− let (_, first_col) = line_col(source, labels[0].span.start);
181− let _ = writeln!(
182− out,
183− "{blue}{:>gutter$}--> {reset}{filename}:{line_no}:{col}",
184− "",
185− col = first_col + 1,
186− blue = c.blue,
187− reset = c.reset,
188− );
189− let _ = writeln!(out, "{blue}{:>gutter$} |{reset}", "", blue = c.blue, reset = c.reset);
116+ let mut builder = Report::build(kind, header_offset..header_offset)
117+ .with_message(&self.message)
118+ .with_config(Config::default().with_char_set(CharSet::Unicode));
190119
191− // Source line.
192− let _ = writeln!(
193− out,
194− "{blue}{line_no:>gutter$} |{reset} {src_line}",
195− blue = c.blue,
196− reset = c.reset,
120+ for label in &self.labels {
121+ let color = colors.next();
122+ builder = builder.with_label(
123+ ALabel::new(label.span.start..label.span.end)
124+ .with_message(&label.message)
125+ .with_color(color),
197126 );
198−
199− // Underline + message for each label on this line.
200− let ul_color = match self.severity {
201− Severity::Error => c.red,
202− Severity::Warning => c.yellow,
203− };
204− for label in labels {
205− let (_, col) = line_col(source, label.span.start);
206− let span_len = (label.span.end - label.span.start).max(1);
207− let _ = writeln!(
208− out,
209− "{blue}{:>gutter$} |{reset} {:>col$}{ul_color}{bold}{carets}{reset} {ul_color}{msg}{reset}",
210− "",
211− "",
212− carets = "^".repeat(span_len),
213− msg = label.message,
214− blue = c.blue,
215− reset = c.reset,
216− ul_color = ul_color,
217− bold = c.bold,
218− );
219− }
220−
221− let _ = writeln!(out, "{blue}{:>gutter$} |{reset}", "", blue = c.blue, reset = c.reset);
222127 }
223128
224− // Notes.
225129 for note in &self.notes {
226− let _ = writeln!(
227− out,
228− "{blue}{:>gutter$} = {cyan}help:{reset} {note}",
229− "",
230− blue = c.blue,
231− cyan = c.cyan,
232− reset = c.reset,
233− );
130+ builder = builder.with_help(note);
234131 }
235132
236− out
133+ let report = builder.finish();
134+
135+ let mut buf = Vec::new();
136+ report.write(Source::from(source), &mut buf).expect("write to Vec cannot fail");
137+
138+ // Replace the default `<unknown>` filename with the actual filename.
139+ String::from_utf8(buf)
140+ .expect("ariadne output is always UTF-8")
141+ .replace("<unknown>", filename)
237142 }
238143}
239144
@@ -276,9 +181,9 @@ mod tests {
276181 .label(Span::new(12, 17), "expected `then` after this")
277182 .note("add `then` before the consequent");
278183 let rendered = diag.render(src, "input");
279− assert!(rendered.contains("error: expected `then` after condition"));
280− assert!(rendered.contains("--> input:2:3"));
281− assert!(rendered.contains("^^^^^"));
282− assert!(rendered.contains("= help: add `then`"));
184+ assert!(rendered.contains("Error"), "should contain error header: {}", rendered);
185+ assert!(rendered.contains("expected `then` after condition"), "should contain message: {}", rendered);
186+ assert!(rendered.contains("expected `then` after this"), "should contain label: {}", rendered);
187+ assert!(rendered.contains("add `then`"), "should contain help note: {}", rendered);
283188 }
284189}