Skip to main content

bynk_render/
lib.rs

1//! Bynk's shared diagnostic-rendering layer.
2//!
3//! The presentation layer over [`bynk_syntax::CompileError`]: ariadne human
4//! output and the `short`/`json`-feeding line forms. Every renderer takes
5//! `&[CompileError]` + `source` + `filename` — it is agnostic about *where* the
6//! errors came from. Both CLI front-ends adopt it so they render identically
7//! (ADR 0100).
8//!
9//! **Invariant (ADR 0100):** this crate depends on `bynk-syntax` **only** (plus
10//! `ariadne`). It must never see `AttributedError`/`ProjectFailure` (which live
11//! in `bynk-emit`): the `AttributedError → CompileError` flattening stays *above*
12//! render, in the front-end, so there is no `render → emit` cycle. A function
13//! here taking a `ProjectFailure` would not even compile — the dependency isn't
14//! present, by design.
15//!
16//! Extracted from `bynkc` as slice 6 of the crate-decomposition track.
17
18use std::path::Path;
19
20use ariadne::Source;
21use bynk_syntax::error::Severity;
22use bynk_syntax::{CompileError, span};
23
24/// Render a list of compile errors to a string (for tests) using the given
25/// filename as the diagnostic source label.
26pub fn render_errors(errors: &[CompileError], source: &str, filename: &str) -> String {
27    let mut out = Vec::new();
28    let mut cache = (filename, Source::from(source));
29    for err in errors {
30        err.report_for(filename, source)
31            .write(&mut cache, &mut out)
32            .expect("write to Vec<u8> cannot fail");
33    }
34    String::from_utf8_lossy(&out).into_owned()
35}
36
37/// Render a list of compile errors to a string with colour disabled and the
38/// given filename as the source label. Unlike [`render_errors`], the output
39/// contains no ANSI escape codes, so it is byte-stable — suitable for the
40/// committed diagnostic transcripts under `site/src/diagnostics/`.
41pub fn render_errors_plain(errors: &[CompileError], source: &str, filename: &str) -> String {
42    let mut out = Vec::new();
43    let mut cache = (filename, Source::from(source));
44    for err in errors {
45        err.report_plain_for(filename, source)
46            .write(&mut cache, &mut out)
47            .expect("write to Vec<u8> cannot fail");
48    }
49    String::from_utf8_lossy(&out).into_owned()
50}
51
52/// Render to stderr with color, used by the CLI.
53pub fn print_errors(errors: &[CompileError], source: &str, filename: &str) {
54    let mut cache = (filename, Source::from(source));
55    for err in errors {
56        let _ = err.report_for(filename, source).eprint(&mut cache);
57    }
58}
59
60/// Render project-level errors as plain `[category] message` lines — the
61/// fallback for errors with no file attribution. Rich, source-context rendering
62/// lives in the front-end's project-failure renderer (v0.24).
63pub fn print_project_errors(root: &Path, errors: &[CompileError]) {
64    let _ = root;
65    for err in errors {
66        eprintln!("[{}] {}", err.category, err.message);
67        for note in &err.notes {
68            eprintln!("  note: {note}");
69        }
70        // Finding #47: a label's *text* survives even with nowhere to
71        // underline it (there is no single file to render against here).
72        for (_, label) in &err.labels {
73            eprintln!("  label: {label}");
74        }
75    }
76}
77
78/// v0.38 (ADR 0071): one terse line per diagnostic for tooling consumers
79/// (`bynkc check --format short`):
80/// `path:line:col: <severity>[<category>]: <message>`. Line/column are
81/// 1-indexed, computed from the byte span against the source. The VS Code
82/// `bynkc` problem-matcher keys off this exact shape — keep it stable.
83pub fn print_errors_short(errors: &[CompileError], source: &str, filename: &str) {
84    eprint!("{}", render_errors_short(errors, source, filename));
85}
86
87/// The string form of [`print_errors_short`] — one `…[category]: message` line
88/// per error, each newline-terminated. The renderer behind the CLI's `--format
89/// short`, exposed for testing.
90///
91/// Finding #47 doesn't reach this one: `tests/check_format_short.rs` locks
92/// `short` to *exactly* one line per diagnostic (the VS Code problem-matcher's
93/// contract), so notes/labels can't grow extra lines here without breaking a
94/// real machine consumer — unlike [`print_project_errors`]/[`render_project_errors`],
95/// which have no such one-line contract.
96pub fn render_errors_short(errors: &[CompileError], source: &str, filename: &str) -> String {
97    let mut out = String::new();
98    for err in errors {
99        out.push_str(&short_line(filename, source, err));
100        out.push('\n');
101    }
102    out
103}
104
105/// One terse `path:line:col: severity[category]: message` line for a single
106/// error against its source. The front-end's project-failure short renderer
107/// flattens an attributed error to `(label, text, error)` and calls this.
108pub fn short_line(filename: &str, source: &str, err: &CompileError) -> String {
109    let (line, col) = span::line_col(source, err.span.start);
110    format!(
111        "{filename}:{line}:{col}: {}[{}]: {}",
112        severity_word(err),
113        err.category,
114        err.message
115    )
116}
117
118/// `"error"` / `"warning"` for an error's [`Severity`].
119pub fn severity_word(err: &CompileError) -> &'static str {
120    match Severity::for_error(err) {
121        Severity::Error => "error",
122        Severity::Warning => "warning",
123    }
124}
125
126/// Render a list of compile errors as plain `[category] message` lines (with
127/// notes and labels), for test assertion.
128pub fn render_project_errors(errors: &[CompileError]) -> String {
129    let mut out = String::new();
130    for err in errors {
131        out.push_str(&format!("[{}] {}\n", err.category, err.message));
132        for note in &err.notes {
133            out.push_str(&format!("  note: {note}\n"));
134        }
135        for (_, label) in &err.labels {
136            out.push_str(&format!("  label: {label}\n"));
137        }
138    }
139    out
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use bynk_syntax::span::Span;
146
147    /// Spans are byte offsets; ariadne 0.6 defaults to character indexing.
148    /// On a line with non-ASCII text before the span, the char-indexed
149    /// underline lands past the target. Pin the byte-indexed placement by
150    /// checking the caret column against the target's display column.
151    #[test]
152    fn underline_is_byte_indexed_on_non_ascii_lines() {
153        // `é` is 2 bytes / 1 display column; `bad` starts at byte 11,
154        // display column 10.
155        let source = "-- caféxyz bad\n";
156        let start = source.find("bad").unwrap();
157        let err = CompileError::new(
158            "bynk.test.example",
159            Span::new(start, start + 3),
160            "bad thing",
161        );
162        let rendered = render_errors_plain(&[err], source, "probe.bynk");
163        let source_line = rendered
164            .lines()
165            .find(|l| l.contains("caféxyz"))
166            .expect("snippet line present");
167        let marker_line = rendered
168            .lines()
169            .find(|l| l.contains('┬'))
170            .expect("marker line present");
171        let col_of = |line: &str, target: char| line.chars().take_while(|&c| c != target).count();
172        // The `┬` sits within the underline under `bad` — same display
173        // column as `b`, or one to its right for spans wider than 1.
174        let b_col = col_of(source_line, 'b');
175        let caret_col = col_of(marker_line, '┬');
176        assert!(
177            (b_col..b_col + 3).contains(&caret_col),
178            "caret at display column {caret_col}, expected within `bad` at {b_col}..{}:\n{rendered}",
179            b_col + 3
180        );
181    }
182
183    /// A label whose span lies past the end of the rendered source belongs to
184    /// another file; it must be demoted to a note, not underline unrelated
185    /// text (or panic).
186    #[test]
187    fn out_of_bounds_label_demotes_to_note() {
188        let source = "commons demo\n";
189        let err = CompileError::new("bynk.test.example", Span::new(0, 7), "problem here")
190            .with_label(
191                Span::new(5_000, 5_010),
192                "parameter declared here (in another file)",
193            );
194        let rendered = render_errors_plain(&[err], source, "probe.bynk");
195        assert!(
196            rendered.contains("parameter declared here"),
197            "label text survives as a note:\n{rendered}"
198        );
199    }
200
201    /// A cross-file label whose byte span is *in-bounds* but lands mid-codepoint
202    /// (the file it really belongs to has non-ASCII text) must be demoted, not
203    /// fed to ariadne — a byte offset splitting a codepoint panics its byte→char
204    /// mapping (#716). The rendered source here is all multi-byte, so an odd
205    /// offset is never a char boundary.
206    #[test]
207    fn mid_codepoint_label_demotes_to_note() {
208        let source = "café ☕\n"; // `é` and `☕` are multi-byte
209        let err = CompileError::new("bynk.test.example", Span::new(0, 3), "problem here")
210            .with_label(
211                Span::new(4, 5),
212                "declared here (mid-codepoint, another file)",
213            );
214        // Must not panic, and the label survives as a note rather than a caret.
215        let rendered = render_errors_plain(&[err], source, "probe.bynk");
216        assert!(
217            rendered.contains("declared here (mid-codepoint, another file)"),
218            "a mid-codepoint label must survive as a note:\n{rendered}"
219        );
220    }
221}