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    String::from_utf8_lossy(&render_all(errors, source, filename, true)).into_owned()
28}
29
30/// Render a list of compile errors to a string with colour disabled and the
31/// given filename as the source label. Unlike [`render_errors`], the output
32/// contains no ANSI escape codes, so it is byte-stable — suitable for the
33/// committed diagnostic transcripts under `site/src/diagnostics/`.
34pub fn render_errors_plain(errors: &[CompileError], source: &str, filename: &str) -> String {
35    String::from_utf8_lossy(&render_all(errors, source, filename, false)).into_owned()
36}
37
38/// Render to stderr, used by the CLI. Coloured only when [`stderr_color`]
39/// allows it.
40pub fn print_errors(errors: &[CompileError], source: &str, filename: &str) {
41    use std::io::Write;
42    let out = render_all(errors, source, filename, stderr_color());
43    let _ = std::io::stderr().lock().write_all(&out);
44}
45
46/// The ariadne reports for `errors`. A report whose spans touch a line
47/// longer than [`MAX_LINE`] renders its own copy of the source, cut by
48/// [`cropped`]; the rest share one view of `source`.
49fn render_all(errors: &[CompileError], source: &str, filename: &str, color: bool) -> Vec<u8> {
50    fn write<'a>(
51        err: &'a CompileError,
52        filename: &'a str,
53        cache: &mut (&'a str, Source<&str>),
54        color: bool,
55        out: &mut Vec<u8>,
56    ) {
57        let source = cache.1.text();
58        let report = if color {
59            err.report_for(filename, source)
60        } else {
61            err.report_plain_for(filename, source)
62        };
63        report
64            .write(&mut *cache, out)
65            .expect("write to Vec<u8> cannot fail");
66    }
67    let long = long_lines(source);
68    let mut cache = (filename, Source::from(source));
69    let mut index = None;
70    let mut out = Vec::new();
71    for err in errors {
72        let touches_long = std::iter::once(&err.span)
73            .chain(err.labels.iter().map(|(s, _)| s))
74            .filter(|s| fits(s, source))
75            .any(|s| {
76                long.iter()
77                    .any(|&(start, end)| s.start <= end && s.end >= start)
78            });
79        if !touches_long {
80            write(err, filename, &mut cache, color, &mut out);
81            continue;
82        }
83        let (shown, shown_err) = cropped(err, source);
84        let mut one = Vec::new();
85        write(
86            &shown_err,
87            filename,
88            &mut (filename, Source::from(&*shown)),
89            color,
90            &mut one,
91        );
92        // ariadne heads the report with the primary span's `line:col` in the
93        // text it was given; on a line cut at its start that column is the cut
94        // one. Put back the column in the file, as the short form reports it.
95        let cut = span::line_col(&shown, shown_err.span.start);
96        let real = index
97            .get_or_insert_with(|| span::LineIndex::new(source))
98            .line_col(source, err.span.start);
99        if cut != real {
100            one = fix_header(
101                &one,
102                &format!("{filename}:{}:{}", cut.0, cut.1),
103                &format!("{filename}:{}:{}", real.0, real.1),
104            );
105        }
106        out.extend(one);
107    }
108    out
109}
110
111/// The `[start, end)` byte ranges (line endings excluded) of the lines of
112/// `source` longer than [`MAX_LINE`].
113fn long_lines(source: &str) -> Vec<(usize, usize)> {
114    let mut long = Vec::new();
115    let mut start = 0;
116    for raw in source.split_inclusive('\n') {
117        let len = raw.trim_end_matches(['\n', '\r']).len();
118        if len > MAX_LINE {
119            long.push((start, start + len));
120        }
121        start += raw.len();
122    }
123    long
124}
125
126/// `report` with `cut` replaced by `real` on its header line, the first
127/// carrying ariadne's `╭`: only there, so a message that happens to quote
128/// `cut` is left alone.
129fn fix_header(report: &[u8], cut: &str, real: &str) -> Vec<u8> {
130    let text = String::from_utf8_lossy(report);
131    let Some(corner) = text.find('╭') else {
132        return report.to_vec();
133    };
134    let start = text[..corner].rfind('\n').map_or(0, |i| i + 1);
135    let end = text[corner..].find('\n').map_or(text.len(), |i| corner + i);
136    let header = text[start..end].replacen(cut, real, 1);
137    format!("{}{header}{}", &text[..start], &text[end..]).into_bytes()
138}
139
140/// #1666: a line longer than this (in bytes) is cut to a window when a report
141/// renders it. Rendering cost and output size grow with the line, and a
142/// generated or minified line can be a megabyte.
143pub const MAX_LINE: usize = 400;
144
145/// Bytes kept either side of the labels on a cut line.
146const CONTEXT: usize = 80;
147
148/// One source line as a report sees it after [`cropped`]: where the original
149/// line ends, the window `[win_start, win_end)` of it that is kept, and
150/// where the kept text begins in the cropped source (after any `…`).
151struct LineWindow {
152    end: usize,
153    win_start: usize,
154    win_end: usize,
155    new_text_start: usize,
156}
157
158/// #1666: `source` and `err` as a report should render them, every line
159/// longer than [`MAX_LINE`] cut to a window and marked `…` where text was
160/// dropped, and the error's spans (the primary one and each label's) moved
161/// into the cut text. A window holds the line's labels with [`CONTEXT`] bytes
162/// either side, a label longer than [`MAX_LINE`] keeping only its head; when
163/// the labels are too far apart for that, it holds the primary span's
164/// neighbourhood, and a label left outside becomes a note. A long line without
165/// labels keeps its first [`MAX_LINE`] bytes. Cuts land on char boundaries.
166fn cropped(err: &CompileError, source: &str) -> (String, CompileError) {
167    let spans: Vec<(usize, usize)> = std::iter::once(&err.span)
168        .chain(err.labels.iter().map(|(s, _)| s))
169        .filter(|s| fits(s, source))
170        .map(|s| (s.start, s.end))
171        .collect();
172    let mut out = String::with_capacity(source.len().min(64 * 1024));
173    let mut lines: Vec<LineWindow> = Vec::new();
174    let mut start = 0;
175    for raw in source.split_inclusive('\n') {
176        let line_len = raw.trim_end_matches(['\n', '\r']).len();
177        let end = start + line_len;
178        let (win_start, win_end) = if line_len <= MAX_LINE {
179            (start, end)
180        } else {
181            let mut lo = usize::MAX;
182            let mut hi = 0;
183            for &(s, e) in &spans {
184                if e < start || s > end {
185                    continue;
186                }
187                let s = s.max(start);
188                let e = e.min(end).min(s + MAX_LINE);
189                lo = lo.min(s);
190                hi = hi.max(e);
191            }
192            let (lo, hi) = if lo == usize::MAX {
193                (start, start + MAX_LINE)
194            } else {
195                (
196                    lo.saturating_sub(CONTEXT).max(start),
197                    (hi + CONTEXT).min(end),
198                )
199            };
200            // Labels far apart on one line could still leave a long window;
201            // then keep the primary span's neighbourhood.
202            let (lo, hi) = if hi - lo > MAX_LINE + 2 * CONTEXT {
203                let s = err.span.start.clamp(start, end);
204                (
205                    s.saturating_sub(CONTEXT).max(start),
206                    (s + MAX_LINE).min(end),
207                )
208            } else {
209                (lo, hi)
210            };
211            (floor_char(source, lo), ceil_char(source, hi))
212        };
213        if win_start > start {
214            out.push('…');
215        }
216        let new_text_start = out.len();
217        out.push_str(&source[win_start..win_end]);
218        if win_end < end {
219            out.push('…');
220        }
221        out.push_str(&raw[line_len..]);
222        lines.push(LineWindow {
223            end,
224            win_start,
225            win_end,
226            new_text_start,
227        });
228        start += raw.len();
229    }
230    let line_of = |offset: usize| lines.get(lines.partition_point(|l| l.end < offset));
231    let map = |offset: usize| -> usize {
232        let Some(l) = line_of(offset) else {
233            return out.len();
234        };
235        // An offset cut from the line lands on the nearer edge of its window.
236        l.new_text_start + offset.clamp(l.win_start, l.win_end) - l.win_start
237    };
238    let mut err = err.clone();
239    err.span.start = map(err.span.start);
240    err.span.end = map(err.span.end).max(err.span.start);
241    for (span, _) in &mut err.labels {
242        // Outside means no byte of the label is kept: it starts at or past a
243        // cut end, or ends at or before a cut start (an empty label, before).
244        let outside = line_of(span.start).is_some_and(|l| {
245            (span.start >= l.win_end && l.win_end < l.end)
246                || if span.start == span.end {
247                    span.start < l.win_start
248                } else {
249                    span.end <= l.win_start
250                }
251        });
252        if !fits(span, source) || outside {
253            // Another file's label, or one cut from its line: keep it out of
254            // range of the cut text too, so the report makes it a note.
255            span.start = usize::MAX;
256            span.end = usize::MAX;
257            continue;
258        }
259        span.start = map(span.start);
260        span.end = map(span.end).max(span.start);
261    }
262    (out, err)
263}
264
265/// Whether `span` lies in `source`, on char boundaries: the test a report
266/// applies before underlining a label (otherwise it is a note).
267fn fits(span: &span::Span, source: &str) -> bool {
268    span.start <= span.end
269        && span.end <= source.len()
270        && source.is_char_boundary(span.start)
271        && source.is_char_boundary(span.end)
272}
273
274fn floor_char(s: &str, mut i: usize) -> usize {
275    while !s.is_char_boundary(i) {
276        i -= 1;
277    }
278    i
279}
280
281fn ceil_char(s: &str, mut i: usize) -> usize {
282    while !s.is_char_boundary(i) {
283        i += 1;
284    }
285    i
286}
287
288/// #1666: whether diagnostics on stderr are coloured: only when stderr is a
289/// terminal and `NO_COLOR` is unset or empty (<https://no-color.org>). Colour
290/// piped into a file or a CI log is noise, and ariadne colours per character,
291/// so it multiplied the output's size by about twenty.
292///
293/// #1777: `FORCE_COLOR` (<https://force-color.org>) or `CLICOLOR_FORCE`
294/// (<https://bixense.com/clicolors/>), set to anything but the empty string or
295/// `0`, turns colour on without a terminal, for `less -R` or a CI log that
296/// renders ANSI. `0` means "don't force", not "force off": `NO_COLOR` is the
297/// off switch, and it wins over both. That is clap's order for `NO_COLOR` and
298/// `CLICOLOR_FORCE`; clap doesn't read `FORCE_COLOR`, so only
299/// `CLICOLOR_FORCE` also colours clap's own help and errors.
300pub fn stderr_color() -> bool {
301    use std::io::IsTerminal;
302    color_allowed(std::io::stderr().is_terminal(), |name| {
303        std::env::var_os(name)
304    })
305}
306
307/// [`stderr_color`]'s decision, over whether stderr is a terminal and a
308/// lookup of the environment.
309fn color_allowed(is_terminal: bool, var: impl Fn(&str) -> Option<std::ffi::OsString>) -> bool {
310    let set = |name| var(name).is_some_and(|v| !v.is_empty());
311    let forced = |name| var(name).is_some_and(|v| !v.is_empty() && v != "0");
312    if set("NO_COLOR") {
313        false
314    } else {
315        is_terminal || forced("FORCE_COLOR") || forced("CLICOLOR_FORCE")
316    }
317}
318
319/// Render project-level errors as plain `[category] message` lines — the
320/// fallback for errors with no file attribution. Rich, source-context rendering
321/// lives in the front-end's project-failure renderer (v0.24).
322pub fn print_project_errors(root: &Path, errors: &[CompileError]) {
323    let _ = root;
324    for err in errors {
325        eprintln!("[{}] {}", err.category, err.message);
326        for note in &err.notes {
327            eprintln!("  note: {note}");
328        }
329        // Finding #47: a label's *text* survives even with nowhere to
330        // underline it (there is no single file to render against here).
331        for (_, label) in &err.labels {
332            eprintln!("  label: {label}");
333        }
334    }
335}
336
337/// v0.38 (ADR 0071): one terse line per diagnostic for tooling consumers
338/// (`bynkc check --format short`):
339/// `path:line:col: <severity>[<category>]: <message>`. Line/column are
340/// 1-indexed, computed from the byte span against the source. The VS Code
341/// `bynkc` problem-matcher keys off this exact shape — keep it stable.
342pub fn print_errors_short(errors: &[CompileError], source: &str, filename: &str) {
343    eprint!("{}", render_errors_short(errors, source, filename));
344}
345
346/// The string form of [`print_errors_short`] — one `…[category]: message` line
347/// per error, each newline-terminated. The renderer behind the CLI's `--format
348/// short`, exposed for testing.
349///
350/// Finding #47 doesn't reach this one: `tests/check_format_short.rs` locks
351/// `short` to *exactly* one line per diagnostic (the VS Code problem-matcher's
352/// contract), so notes/labels can't grow extra lines here without breaking a
353/// real machine consumer — unlike [`print_project_errors`]/[`render_project_errors`],
354/// which have no such one-line contract.
355pub fn render_errors_short(errors: &[CompileError], source: &str, filename: &str) -> String {
356    let mut out = String::new();
357    for err in errors {
358        out.push_str(&short_line(filename, source, err));
359        out.push('\n');
360    }
361    out
362}
363
364/// One terse `path:line:col: severity[category]: message` line for a single
365/// error against its source. The front-end's project-failure short renderer
366/// flattens an attributed error to `(label, text, error)` and calls this.
367pub fn short_line(filename: &str, source: &str, err: &CompileError) -> String {
368    let (line, col) = span::line_col(source, err.span.start);
369    format!(
370        "{filename}:{line}:{col}: {}[{}]: {}",
371        severity_word(err),
372        err.category,
373        err.message
374    )
375}
376
377/// `"error"` / `"warning"` for an error's [`Severity`].
378pub fn severity_word(err: &CompileError) -> &'static str {
379    match Severity::for_error(err) {
380        Severity::Error => "error",
381        Severity::Warning => "warning",
382    }
383}
384
385/// Render a list of compile errors as plain `[category] message` lines (with
386/// notes and labels), for test assertion.
387pub fn render_project_errors(errors: &[CompileError]) -> String {
388    let mut out = String::new();
389    for err in errors {
390        out.push_str(&format!("[{}] {}\n", err.category, err.message));
391        for note in &err.notes {
392            out.push_str(&format!("  note: {note}\n"));
393        }
394        for (_, label) in &err.labels {
395            out.push_str(&format!("  label: {label}\n"));
396        }
397    }
398    out
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404    use bynk_syntax::span::Span;
405
406    /// Spans are byte offsets; ariadne 0.6 defaults to character indexing.
407    /// On a line with non-ASCII text before the span, the char-indexed
408    /// underline lands past the target. Pin the byte-indexed placement by
409    /// checking the caret column against the target's display column.
410    #[test]
411    fn underline_is_byte_indexed_on_non_ascii_lines() {
412        // `é` is 2 bytes / 1 display column; `bad` starts at byte 11,
413        // display column 10.
414        let source = "-- caféxyz bad\n";
415        let start = source.find("bad").unwrap();
416        let err = CompileError::new(
417            "bynk.test.example",
418            Span::new(start, start + 3),
419            "bad thing",
420        );
421        let rendered = render_errors_plain(&[err], source, "probe.bynk");
422        let source_line = rendered
423            .lines()
424            .find(|l| l.contains("caféxyz"))
425            .expect("snippet line present");
426        let marker_line = rendered
427            .lines()
428            .find(|l| l.contains('┬'))
429            .expect("marker line present");
430        let col_of = |line: &str, target: char| line.chars().take_while(|&c| c != target).count();
431        // The `┬` sits within the underline under `bad` — same display
432        // column as `b`, or one to its right for spans wider than 1.
433        let b_col = col_of(source_line, 'b');
434        let caret_col = col_of(marker_line, '┬');
435        assert!(
436            (b_col..b_col + 3).contains(&caret_col),
437            "caret at display column {caret_col}, expected within `bad` at {b_col}..{}:\n{rendered}",
438            b_col + 3
439        );
440    }
441
442    /// A label whose span lies past the end of the rendered source belongs to
443    /// another file; it must be demoted to a note, not underline unrelated
444    /// text (or panic).
445    #[test]
446    fn out_of_bounds_label_demotes_to_note() {
447        let source = "commons demo\n";
448        let err = CompileError::new("bynk.test.example", Span::new(0, 7), "problem here")
449            .with_label(
450                Span::new(5_000, 5_010),
451                "parameter declared here (in another file)",
452            );
453        let rendered = render_errors_plain(&[err], source, "probe.bynk");
454        assert!(
455            rendered.contains("parameter declared here"),
456            "label text survives as a note:\n{rendered}"
457        );
458    }
459
460    /// A cross-file label whose byte span is *in-bounds* but lands mid-codepoint
461    /// (the file it really belongs to has non-ASCII text) must be demoted, not
462    /// fed to ariadne — a byte offset splitting a codepoint panics its byte→char
463    /// mapping (#716). The rendered source here is all multi-byte, so an odd
464    /// offset is never a char boundary.
465    #[test]
466    fn mid_codepoint_label_demotes_to_note() {
467        let source = "café ☕\n"; // `é` and `☕` are multi-byte
468        let err = CompileError::new("bynk.test.example", Span::new(0, 3), "problem here")
469            .with_label(
470                Span::new(4, 5),
471                "declared here (mid-codepoint, another file)",
472            );
473        // Must not panic, and the label survives as a note rather than a caret.
474        let rendered = render_errors_plain(&[err], source, "probe.bynk");
475        assert!(
476            rendered.contains("declared here (mid-codepoint, another file)"),
477            "a mid-codepoint label must survive as a note:\n{rendered}"
478        );
479    }
480
481    /// #1666: colour needs a terminal, and `NO_COLOR` set to anything but
482    /// the empty string turns it off. #1777: `FORCE_COLOR` or
483    /// `CLICOLOR_FORCE`, set to anything but the empty string or `0`, turns
484    /// it on without one; `NO_COLOR` wins over both.
485    #[test]
486    fn color_needs_a_terminal_or_a_force_and_no_no_color() {
487        use std::ffi::OsString;
488        let allowed = |is_terminal, vars: &[(&str, &str)]| {
489            color_allowed(is_terminal, |name| {
490                vars.iter()
491                    .find(|(n, _)| *n == name)
492                    .map(|(_, v)| OsString::from(v))
493            })
494        };
495        assert!(allowed(true, &[]));
496        assert!(allowed(true, &[("NO_COLOR", "")]));
497        assert!(!allowed(true, &[("NO_COLOR", "1")]));
498        assert!(!allowed(false, &[]));
499        assert!(!allowed(false, &[("NO_COLOR", "")]));
500
501        for force in ["FORCE_COLOR", "CLICOLOR_FORCE"] {
502            assert!(allowed(false, &[(force, "1")]), "{force}=1");
503            assert!(allowed(false, &[(force, "true")]), "{force}=true");
504            assert!(!allowed(false, &[(force, "")]), "{force}= is unset");
505            assert!(!allowed(false, &[(force, "0")]), "{force}=0 doesn't force");
506            assert!(allowed(true, &[(force, "0")]), "{force}=0 doesn't forbid");
507            assert!(allowed(false, &[(force, "1"), ("NO_COLOR", "")]));
508            assert!(!allowed(false, &[(force, "1"), ("NO_COLOR", "1")]));
509            assert!(!allowed(true, &[(force, "1"), ("NO_COLOR", "1")]));
510        }
511    }
512
513    /// #1666: a megabyte line renders as a window around the label, marked
514    /// `…` both ends, with the header naming the label's real column.
515    #[test]
516    fn a_long_line_is_cut_around_the_label() {
517        let pad = "x".repeat(500_000);
518        let source = format!("commons c\n{pad}bad{pad}\n");
519        let start = source.find("bad").unwrap();
520        let err = CompileError::new("bynk.test.example", Span::new(start, start + 3), "bad");
521        let rendered = render_errors_plain(&[err], &source, "probe.bynk");
522        assert!(
523            rendered.len() < 4_000,
524            "{} bytes:\n{rendered}",
525            rendered.len()
526        );
527        assert!(
528            rendered.contains("probe.bynk:2:500001 "),
529            "the header keeps the column in the file:\n{rendered}"
530        );
531        let line = rendered.lines().find(|l| l.contains("xbadx")).unwrap();
532        assert!(line.contains('…') && line.ends_with('…'), "{line}");
533        let caret = rendered.lines().find(|l| l.contains('┬')).unwrap();
534        let col = |l: &str, s: &str| l[..l.find(s).unwrap()].chars().count();
535        assert!((col(line, "bad")..col(line, "bad") + 3).contains(&col(caret, "┬")));
536        // Coloured, the same report is just as small.
537        let err = CompileError::new("bynk.test.example", Span::new(start, start + 3), "bad");
538        assert!(render_errors(&[err], &source, "probe.bynk").len() < 8_000);
539    }
540
541    /// #1666: a cut lands on a char boundary, and a line no longer than
542    /// [`MAX_LINE`] renders as it is.
543    #[test]
544    fn cuts_respect_chars_and_short_lines_are_kept() {
545        let wide = "é".repeat(300); // 600 bytes
546        let short = "y".repeat(MAX_LINE);
547        let source = format!("commons c\n{short}\n{wide}bad{wide}\n");
548        let start = source.find("bad").unwrap();
549        let err = CompileError::new("bynk.test.example", Span::new(start, start + 3), "bad");
550        let rendered = render_errors_plain(&[err], &source, "probe.bynk");
551        assert!(rendered.contains("probe.bynk:3:301 "), "{rendered}");
552        let line = rendered.lines().find(|l| l.contains("ébadé")).unwrap();
553        assert!(line.contains("…éé") && line.contains("éé…"), "{line}");
554
555        let span = Span::new(10, 11);
556        let err = CompileError::new("bynk.test.example", span, "here");
557        let rendered = render_errors_plain(&[err], &source, "probe.bynk");
558        assert!(rendered.contains(&short), "{rendered}");
559    }
560
561    /// #1666: cutting a long line keeps another file's label a note.
562    #[test]
563    fn a_foreign_label_stays_a_note_on_a_cut_line() {
564        let source = format!("commons c\n{}\n", "z".repeat(1_000));
565        let err = CompileError::new("bynk.test.example", Span::new(10, 11), "here")
566            .with_label(Span::new(5_000, 5_010), "declared in another file");
567        let rendered = render_errors_plain(&[err], &source, "probe.bynk");
568        assert!(
569            rendered.contains("Note: declared in another file"),
570            "{rendered}"
571        );
572    }
573
574    /// #1666: labels too far apart for one window keep the primary span's
575    /// neighbourhood; a label cut from it becomes a note rather than a caret
576    /// on the `…`.
577    #[test]
578    fn a_label_cut_from_the_window_becomes_a_note() {
579        let source = format!("commons c\nhere{}there\n", "1 ".repeat(100_000));
580        let there = source.find("there").unwrap();
581        let err = CompileError::new("bynk.test.example", Span::new(10, 14), "primary")
582            .with_label(Span::new(there, there + 5), "far label");
583        let rendered = render_errors_plain(&[err], &source, "probe.bynk");
584        assert!(rendered.len() < 4_000, "{rendered}");
585        assert!(rendered.contains("Note: far label"), "{rendered}");
586        assert!(rendered.contains("probe.bynk:2:1 "), "{rendered}");
587    }
588
589    /// #1666: a CRLF line's `\r` is kept after a cut, outside the window.
590    #[test]
591    fn a_cut_crlf_line_keeps_its_line_ending() {
592        let source = format!("commons c\r\n{}bad\r\n", "w".repeat(1_000));
593        let start = source.find("bad").unwrap();
594        let (shown, _) = cropped(
595            &CompileError::new("bynk.test.example", Span::new(start, start + 3), "bad"),
596            &source,
597        );
598        assert!(shown.starts_with("commons c\r\n…"), "{shown:?}");
599        assert!(shown.ends_with("wbad\r\n"), "{shown:?}");
600    }
601
602    /// #1666 review: a label touching a window edge from outside keeps no
603    /// byte, so it is a note, not a caret on the `…` or on the first kept
604    /// character. The far label forces the primary span's window
605    /// `[primary - CONTEXT, primary + MAX_LINE)`.
606    #[test]
607    fn a_label_touching_the_window_edge_from_outside_is_a_note() {
608        let source = format!("commons c\n{}\n", "q".repeat(20_000));
609        let p = 10 + 1_000;
610        let err = CompileError::new("bynk.test.example", Span::new(p, p + 1), "primary")
611            .with_label(Span::new(p + MAX_LINE, p + MAX_LINE + 3), "after the cut")
612            .with_label(Span::new(p - CONTEXT - 3, p - CONTEXT), "before the cut")
613            .with_label(Span::new(p + 10_000, p + 10_003), "far away");
614        let rendered = render_errors_plain(&[err], &source, "probe.bynk");
615        for (i, label) in ["after the cut", "before the cut", "far away"]
616            .iter()
617            .enumerate()
618        {
619            let note = format!("Note {}: {label}", i + 1);
620            assert!(rendered.contains(&note), "{rendered}");
621        }
622    }
623
624    /// #1666 review: errors over one source, one on a cut line and one on a
625    /// short line, each get the right header; two long lines are cut
626    /// independently.
627    #[test]
628    fn several_errors_and_long_lines_render_independently() {
629        let pad = "x".repeat(5_000);
630        let source = format!("commons c\n{pad}one{pad}\nshort\n{pad}two{pad}\n");
631        let at = |word: &str| source.find(word).unwrap();
632        let err = |word: &str| {
633            CompileError::new("bynk.test.example", Span::new(at(word), at(word) + 3), word)
634        };
635        let errors = [
636            err("one"),
637            CompileError::new(
638                "bynk.test.example",
639                Span::new(at("short"), at("short") + 5),
640                "s",
641            ),
642            err("two"),
643        ];
644        let rendered = render_errors_plain(&errors, &source, "probe.bynk");
645        assert!(rendered.len() < 8_000, "{rendered}");
646        for header in [
647            "probe.bynk:2:5001 ",
648            "probe.bynk:3:1 ",
649            "probe.bynk:4:5001 ",
650        ] {
651            assert!(rendered.contains(header), "{header}:\n{rendered}");
652        }
653        assert!(
654            rendered
655                .lines()
656                .any(|l| l.contains("xonex") && l.ends_with('…'))
657        );
658        assert!(
659            rendered
660                .lines()
661                .any(|l| l.contains("xtwox") && l.ends_with('…'))
662        );
663    }
664
665    /// #1666 review: the coloured report's header names the column in the
666    /// file too.
667    #[test]
668    fn the_coloured_header_keeps_the_column_in_the_file() {
669        let source = format!("commons c\n{}bad\n", "x".repeat(50_000));
670        let start = source.find("bad").unwrap();
671        let err = CompileError::new("bynk.test.example", Span::new(start, start + 3), "bad");
672        let rendered = render_errors(&[err], &source, "probe.bynk");
673        assert!(rendered.contains("probe.bynk:2:50001 "), "{rendered}");
674    }
675
676    /// #1666 review: a header fix touches only the header, not a message
677    /// that quotes the same `path:line:col`.
678    #[test]
679    fn the_header_fix_leaves_the_message_alone() {
680        let source = format!("commons c\n{}bad\n", "x".repeat(1_000));
681        let start = source.find("bad").unwrap();
682        // The cut puts `bad` at column 82 of the shown line.
683        let err = CompileError::new(
684            "bynk.test.example",
685            Span::new(start, start + 3),
686            "see probe.bynk:2:82",
687        );
688        let rendered = render_errors_plain(&[err], &source, "probe.bynk");
689        assert!(
690            rendered.contains("Error: see probe.bynk:2:82"),
691            "{rendered}"
692        );
693        assert!(rendered.contains("[ probe.bynk:2:1001 ]"), "{rendered}");
694    }
695
696    /// #1666 review: a rendered report over CRLF source numbers the cut line
697    /// as in the file.
698    #[test]
699    fn a_cut_crlf_report_keeps_line_numbers() {
700        let source = format!("commons c\r\n\r\n{}bad\r\nnext\r\n", "w".repeat(1_000));
701        let start = source.find("bad").unwrap();
702        let err = CompileError::new("bynk.test.example", Span::new(start, start + 3), "bad");
703        let rendered = render_errors_plain(&[err], &source, "probe.bynk");
704        assert!(rendered.contains("[ probe.bynk:3:1001 ]"), "{rendered}");
705        assert!(
706            rendered
707                .lines()
708                .any(|l| l.starts_with(" 3 │ …") && l.contains("wbad")),
709            "{rendered}"
710        );
711    }
712}