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/// Re-exported for [`render_error_in`]'s callers, which say with it which file
25/// each of an error's labels lies in.
26pub use bynk_syntax::error::LabelHome;
27
28/// Render a list of compile errors to a string (for tests) using the given
29/// filename as the diagnostic source label.
30pub fn render_errors(errors: &[CompileError], source: &str, filename: &str) -> String {
31    String::from_utf8_lossy(&render_all(errors, source, filename, true)).into_owned()
32}
33
34/// Render a list of compile errors to a string with colour disabled and the
35/// given filename as the source label. Unlike [`render_errors`], the output
36/// contains no ANSI escape codes, so it is byte-stable — suitable for the
37/// committed diagnostic transcripts under `site/src/diagnostics/`.
38pub fn render_errors_plain(errors: &[CompileError], source: &str, filename: &str) -> String {
39    String::from_utf8_lossy(&render_all(errors, source, filename, false)).into_owned()
40}
41
42/// Render to stderr, used by the CLI. Coloured only when [`stderr_color`]
43/// allows it.
44pub fn print_errors(errors: &[CompileError], source: &str, filename: &str) {
45    use std::io::Write;
46    let out = render_all(errors, source, filename, stderr_color());
47    let _ = std::io::stderr().lock().write_all(&out);
48}
49
50/// The ariadne reports for `errors`. A report whose spans touch a line
51/// longer than [`MAX_LINE`] renders its own copy of the source, cut by
52/// [`cropped`]; the rest share one view of `source`.
53fn render_all(errors: &[CompileError], source: &str, filename: &str, color: bool) -> Vec<u8> {
54    fn write<'a>(
55        err: &'a CompileError,
56        filename: &'a str,
57        cache: &mut (&'a str, Source<&str>),
58        color: bool,
59        out: &mut Vec<u8>,
60    ) {
61        let source = cache.1.text();
62        let report = if color {
63            err.report_for(filename, source)
64        } else {
65            err.report_plain_for(filename, source)
66        };
67        report
68            .write(&mut *cache, out)
69            .expect("write to Vec<u8> cannot fail");
70    }
71    let long = long_lines(source);
72    let mut cache = (filename, Source::from(source));
73    let mut index = None;
74    let mut out = Vec::new();
75    for err in errors {
76        let touches_long = std::iter::once(&err.span)
77            .chain(err.labels.iter().map(|(s, _)| s))
78            .filter(|s| fits(s, source))
79            .any(|s| {
80                long.iter()
81                    .any(|&(start, end)| s.start <= end && s.end >= start)
82            });
83        if !touches_long {
84            write(err, filename, &mut cache, color, &mut out);
85            continue;
86        }
87        let (shown, shown_err) = cropped(err, source);
88        let mut one = Vec::new();
89        write(
90            &shown_err,
91            filename,
92            &mut (filename, Source::from(&*shown)),
93            color,
94            &mut one,
95        );
96        // ariadne heads the report with the primary span's `line:col` in the
97        // text it was given; on a line cut at its start that column is the cut
98        // one. Put back the column in the file, as the short form reports it.
99        let cut = span::line_col(&shown, shown_err.span.start);
100        let real = index
101            .get_or_insert_with(|| span::LineIndex::new(source))
102            .line_col(source, err.span.start);
103        if cut != real {
104            one = fix_header(
105                &one,
106                &format!("{filename}:{}:{}", cut.0, cut.1),
107                &format!("{filename}:{}:{}", real.0, real.1),
108            );
109        }
110        out.extend(one);
111    }
112    out
113}
114
115/// Render one error whose secondary labels may lie in other files, coloured
116/// as [`render_errors`] is: `homes[i]` says which file `err.labels[i]` lies in
117/// (see [`CompileError::report_in`]). A label in another file is drawn in a
118/// snippet of that file, under its own name, rather than against `source`.
119pub fn render_error_in(
120    err: &CompileError,
121    source: &str,
122    filename: &str,
123    homes: &[LabelHome<'_>],
124) -> String {
125    String::from_utf8_lossy(&render_in(err, source, filename, homes, true)).into_owned()
126}
127
128/// [`render_error_in`] with colour disabled, byte-stable like
129/// [`render_errors_plain`].
130pub fn render_error_in_plain(
131    err: &CompileError,
132    source: &str,
133    filename: &str,
134    homes: &[LabelHome<'_>],
135) -> String {
136    String::from_utf8_lossy(&render_in(err, source, filename, homes, false)).into_owned()
137}
138
139/// [`render_error_in`] to stderr, coloured as [`print_errors`] is.
140pub fn print_error_in(err: &CompileError, source: &str, filename: &str, homes: &[LabelHome<'_>]) {
141    use std::io::Write;
142    let out = render_in(err, source, filename, homes, stderr_color());
143    let _ = std::io::stderr().lock().write_all(&out);
144}
145
146/// The report behind [`render_error_in`]. With every label in `source` it is
147/// [`render_all`]'s. Otherwise the labels in `source` are cut to fit its long
148/// lines as [`render_all`] cuts them (#1666), and the others are added after:
149/// another file is never cut, so a label on one of its long lines becomes a
150/// note instead.
151fn render_in(
152    err: &CompileError,
153    source: &str,
154    filename: &str,
155    homes: &[LabelHome<'_>],
156    color: bool,
157) -> Vec<u8> {
158    let home = |i: usize| homes.get(i).copied().unwrap_or(LabelHome::Primary);
159    if (0..err.labels.len()).all(|i| home(i) == LabelHome::Primary) {
160        return render_all(std::slice::from_ref(err), source, filename, color);
161    }
162    let mut local = err.clone();
163    local.labels.clear();
164    let mut elsewhere = Vec::new();
165    for (i, label) in err.labels.iter().enumerate() {
166        match home(i) {
167            LabelHome::Primary => local.labels.push(label.clone()),
168            other => elsewhere.push((label.clone(), other)),
169        }
170    }
171    let touches_long = |s: &span::Span, text: &str| {
172        fits(s, text)
173            && long_lines(text)
174                .iter()
175                .any(|&(start, end)| s.start <= end && s.end >= start)
176    };
177    let cut = std::iter::once(&local.span)
178        .chain(local.labels.iter().map(|(s, _)| s))
179        .any(|s| touches_long(s, source));
180    let (shown, mut shown_err) = if cut {
181        cropped(&local, source)
182    } else {
183        (source.to_string(), local)
184    };
185    let mut shown_homes = vec![LabelHome::Primary; shown_err.labels.len()];
186    for ((span, label), home) in elsewhere {
187        let home = match home {
188            LabelHome::File { source: text, .. } if touches_long(&span, text) => {
189                LabelHome::Unavailable
190            }
191            other => other,
192        };
193        shown_err.labels.push((span, label));
194        shown_homes.push(home);
195    }
196    let mut cache = Files(vec![(filename, Source::from(shown.as_str()))]);
197    for home in &shown_homes {
198        if let LabelHome::File { filename, source } = *home
199            && !cache.0.iter().any(|(f, _)| *f == filename)
200        {
201            cache.0.push((filename, Source::from(source)));
202        }
203    }
204    let mut out = Vec::new();
205    shown_err
206        .report_in(filename, &shown, &shown_homes, color)
207        .write(&mut cache, &mut out)
208        .expect("write to Vec<u8> cannot fail");
209    if cut {
210        // As in `render_all`: put back the primary span's column in the file.
211        let at_cut = span::line_col(&shown, shown_err.span.start);
212        let real = span::line_col(source, err.span.start);
213        if at_cut != real {
214            out = fix_header(
215                &out,
216                &format!("{filename}:{}:{}", at_cut.0, at_cut.1),
217                &format!("{filename}:{}:{}", real.0, real.1),
218            );
219        }
220    }
221    out
222}
223
224/// A report's sources by filename — the primary file's and each other file a
225/// label lies in. ariadne's own multi-source cache wants `'static` ids.
226struct Files<'a>(Vec<(&'a str, Source<&'a str>)>);
227
228impl<'a> ariadne::Cache<&'a str> for Files<'a> {
229    type Storage = &'a str;
230
231    fn fetch(&mut self, id: &&'a str) -> Result<&Source<&'a str>, impl std::fmt::Debug> {
232        self.0
233            .iter()
234            .find(|(name, _)| name == id)
235            .map(|(_, source)| source)
236            .ok_or_else(|| format!("no source for `{id}`"))
237    }
238
239    fn display<'b>(&self, id: &'b &'a str) -> Option<impl std::fmt::Display + 'b> {
240        Some(*id)
241    }
242}
243
244/// The `[start, end)` byte ranges (line endings excluded) of the lines of
245/// `source` longer than [`MAX_LINE`].
246fn long_lines(source: &str) -> Vec<(usize, usize)> {
247    let mut long = Vec::new();
248    let mut start = 0;
249    for raw in source.split_inclusive('\n') {
250        let len = raw.trim_end_matches(['\n', '\r']).len();
251        if len > MAX_LINE {
252            long.push((start, start + len));
253        }
254        start += raw.len();
255    }
256    long
257}
258
259/// `report` with `cut` replaced by `real` on its header line, the first
260/// carrying ariadne's `╭`: only there, so a message that happens to quote
261/// `cut` is left alone.
262fn fix_header(report: &[u8], cut: &str, real: &str) -> Vec<u8> {
263    let text = String::from_utf8_lossy(report);
264    let Some(corner) = text.find('╭') else {
265        return report.to_vec();
266    };
267    let start = text[..corner].rfind('\n').map_or(0, |i| i + 1);
268    let end = text[corner..].find('\n').map_or(text.len(), |i| corner + i);
269    let header = text[start..end].replacen(cut, real, 1);
270    format!("{}{header}{}", &text[..start], &text[end..]).into_bytes()
271}
272
273/// #1666: a line longer than this (in bytes) is cut to a window when a report
274/// renders it. Rendering cost and output size grow with the line, and a
275/// generated or minified line can be a megabyte.
276pub const MAX_LINE: usize = 400;
277
278/// Bytes kept either side of the labels on a cut line.
279const CONTEXT: usize = 80;
280
281/// One source line as a report sees it after [`cropped`]: where the original
282/// line ends, the window `[win_start, win_end)` of it that is kept, and
283/// where the kept text begins in the cropped source (after any `…`).
284struct LineWindow {
285    end: usize,
286    win_start: usize,
287    win_end: usize,
288    new_text_start: usize,
289}
290
291/// #1666: `source` and `err` as a report should render them, every line
292/// longer than [`MAX_LINE`] cut to a window and marked `…` where text was
293/// dropped, and the error's spans (the primary one and each label's) moved
294/// into the cut text. A window holds the line's labels with [`CONTEXT`] bytes
295/// either side, a label longer than [`MAX_LINE`] keeping only its head; when
296/// the labels are too far apart for that, it holds the primary span's
297/// neighbourhood, and a label left outside becomes a note. A long line without
298/// labels keeps its first [`MAX_LINE`] bytes. Cuts land on char boundaries.
299fn cropped(err: &CompileError, source: &str) -> (String, CompileError) {
300    let spans: Vec<(usize, usize)> = std::iter::once(&err.span)
301        .chain(err.labels.iter().map(|(s, _)| s))
302        .filter(|s| fits(s, source))
303        .map(|s| (s.start, s.end))
304        .collect();
305    let mut out = String::with_capacity(source.len().min(64 * 1024));
306    let mut lines: Vec<LineWindow> = Vec::new();
307    let mut start = 0;
308    for raw in source.split_inclusive('\n') {
309        let line_len = raw.trim_end_matches(['\n', '\r']).len();
310        let end = start + line_len;
311        let (win_start, win_end) = if line_len <= MAX_LINE {
312            (start, end)
313        } else {
314            let mut lo = usize::MAX;
315            let mut hi = 0;
316            for &(s, e) in &spans {
317                if e < start || s > end {
318                    continue;
319                }
320                let s = s.max(start);
321                let e = e.min(end).min(s + MAX_LINE);
322                lo = lo.min(s);
323                hi = hi.max(e);
324            }
325            let (lo, hi) = if lo == usize::MAX {
326                (start, start + MAX_LINE)
327            } else {
328                (
329                    lo.saturating_sub(CONTEXT).max(start),
330                    (hi + CONTEXT).min(end),
331                )
332            };
333            // Labels far apart on one line could still leave a long window;
334            // then keep the primary span's neighbourhood.
335            let (lo, hi) = if hi - lo > MAX_LINE + 2 * CONTEXT {
336                let s = err.span.start.clamp(start, end);
337                (
338                    s.saturating_sub(CONTEXT).max(start),
339                    (s + MAX_LINE).min(end),
340                )
341            } else {
342                (lo, hi)
343            };
344            (floor_char(source, lo), ceil_char(source, hi))
345        };
346        if win_start > start {
347            out.push('…');
348        }
349        let new_text_start = out.len();
350        out.push_str(&source[win_start..win_end]);
351        if win_end < end {
352            out.push('…');
353        }
354        out.push_str(&raw[line_len..]);
355        lines.push(LineWindow {
356            end,
357            win_start,
358            win_end,
359            new_text_start,
360        });
361        start += raw.len();
362    }
363    let line_of = |offset: usize| lines.get(lines.partition_point(|l| l.end < offset));
364    let map = |offset: usize| -> usize {
365        let Some(l) = line_of(offset) else {
366            return out.len();
367        };
368        // An offset cut from the line lands on the nearer edge of its window.
369        l.new_text_start + offset.clamp(l.win_start, l.win_end) - l.win_start
370    };
371    let mut err = err.clone();
372    err.span.start = map(err.span.start);
373    err.span.end = map(err.span.end).max(err.span.start);
374    for (span, _) in &mut err.labels {
375        // Outside means no byte of the label is kept: it starts at or past a
376        // cut end, or ends at or before a cut start (an empty label, before).
377        let outside = line_of(span.start).is_some_and(|l| {
378            (span.start >= l.win_end && l.win_end < l.end)
379                || if span.start == span.end {
380                    span.start < l.win_start
381                } else {
382                    span.end <= l.win_start
383                }
384        });
385        if !fits(span, source) || outside {
386            // Another file's label, or one cut from its line: keep it out of
387            // range of the cut text too, so the report makes it a note.
388            span.start = usize::MAX;
389            span.end = usize::MAX;
390            continue;
391        }
392        span.start = map(span.start);
393        span.end = map(span.end).max(span.start);
394    }
395    (out, err)
396}
397
398/// Whether `span` lies in `source`, on char boundaries: the test a report
399/// applies before underlining a label (otherwise it is a note).
400fn fits(span: &span::Span, source: &str) -> bool {
401    span.start <= span.end
402        && span.end <= source.len()
403        && source.is_char_boundary(span.start)
404        && source.is_char_boundary(span.end)
405}
406
407fn floor_char(s: &str, mut i: usize) -> usize {
408    while !s.is_char_boundary(i) {
409        i -= 1;
410    }
411    i
412}
413
414fn ceil_char(s: &str, mut i: usize) -> usize {
415    while !s.is_char_boundary(i) {
416        i += 1;
417    }
418    i
419}
420
421/// #1666: whether diagnostics on stderr are coloured: only when stderr is a
422/// terminal and `NO_COLOR` is unset or empty (<https://no-color.org>). Colour
423/// piped into a file or a CI log is noise, and ariadne colours per character,
424/// so it multiplied the output's size by about twenty.
425///
426/// #1777: `FORCE_COLOR` (<https://force-color.org>) or `CLICOLOR_FORCE`
427/// (<https://bixense.com/clicolors/>), set to anything but the empty string or
428/// `0`, turns colour on without a terminal, for `less -R` or a CI log that
429/// renders ANSI. `0` means "don't force", not "force off": `NO_COLOR` is the
430/// off switch, and it wins over both. That is clap's order for `NO_COLOR` and
431/// `CLICOLOR_FORCE`; clap doesn't read `FORCE_COLOR`, so only
432/// `CLICOLOR_FORCE` also colours clap's own help and errors.
433pub fn stderr_color() -> bool {
434    use std::io::IsTerminal;
435    color_allowed(std::io::stderr().is_terminal(), |name| {
436        std::env::var_os(name)
437    })
438}
439
440/// [`stderr_color`]'s decision, over whether stderr is a terminal and a
441/// lookup of the environment.
442fn color_allowed(is_terminal: bool, var: impl Fn(&str) -> Option<std::ffi::OsString>) -> bool {
443    let set = |name| var(name).is_some_and(|v| !v.is_empty());
444    let forced = |name| var(name).is_some_and(|v| !v.is_empty() && v != "0");
445    if set("NO_COLOR") {
446        false
447    } else {
448        is_terminal || forced("FORCE_COLOR") || forced("CLICOLOR_FORCE")
449    }
450}
451
452/// Render project-level errors as plain `[category] message` lines — the
453/// fallback for errors with no file attribution. Rich, source-context rendering
454/// lives in the front-end's project-failure renderer (v0.24).
455pub fn print_project_errors(root: &Path, errors: &[CompileError]) {
456    let _ = root;
457    for err in errors {
458        eprintln!("[{}] {}", err.category, err.message);
459        for note in &err.notes {
460            eprintln!("  note: {note}");
461        }
462        // Finding #47: a label's *text* survives even with nowhere to
463        // underline it (there is no single file to render against here).
464        for (_, label) in &err.labels {
465            eprintln!("  label: {label}");
466        }
467    }
468}
469
470/// v0.38 (ADR 0071): one terse line per diagnostic for tooling consumers
471/// (`bynkc check --format short`):
472/// `path:line:col: <severity>[<category>]: <message>`. Line/column are
473/// 1-indexed, computed from the byte span against the source. The VS Code
474/// `bynkc` problem-matcher keys off this exact shape — keep it stable.
475pub fn print_errors_short(errors: &[CompileError], source: &str, filename: &str) {
476    eprint!("{}", render_errors_short(errors, source, filename));
477}
478
479/// The string form of [`print_errors_short`] — one `…[category]: message` line
480/// per error, each newline-terminated. The renderer behind the CLI's `--format
481/// short`, exposed for testing.
482///
483/// Finding #47 doesn't reach this one: `tests/check_format_short.rs` locks
484/// `short` to *exactly* one line per diagnostic (the VS Code problem-matcher's
485/// contract), so notes/labels can't grow extra lines here without breaking a
486/// real machine consumer — unlike [`print_project_errors`]/[`render_project_errors`],
487/// which have no such one-line contract.
488pub fn render_errors_short(errors: &[CompileError], source: &str, filename: &str) -> String {
489    let mut out = String::new();
490    for err in errors {
491        out.push_str(&short_line(filename, source, err));
492        out.push('\n');
493    }
494    out
495}
496
497/// One terse `path:line:col: severity[category]: message` line for a single
498/// error against its source. The front-end's project-failure short renderer
499/// flattens an attributed error to `(label, text, error)` and calls this.
500pub fn short_line(filename: &str, source: &str, err: &CompileError) -> String {
501    let (line, col) = span::line_col(source, err.span.start);
502    format!(
503        "{filename}:{line}:{col}: {}[{}]: {}",
504        severity_word(err),
505        err.category,
506        err.message
507    )
508}
509
510/// `"error"` / `"warning"` for an error's [`Severity`].
511pub fn severity_word(err: &CompileError) -> &'static str {
512    match Severity::for_error(err) {
513        Severity::Error => "error",
514        Severity::Warning => "warning",
515    }
516}
517
518/// Render a list of compile errors as plain `[category] message` lines (with
519/// notes and labels), for test assertion.
520pub fn render_project_errors(errors: &[CompileError]) -> String {
521    let mut out = String::new();
522    for err in errors {
523        out.push_str(&format!("[{}] {}\n", err.category, err.message));
524        for note in &err.notes {
525            out.push_str(&format!("  note: {note}\n"));
526        }
527        for (_, label) in &err.labels {
528            out.push_str(&format!("  label: {label}\n"));
529        }
530    }
531    out
532}
533
534#[cfg(test)]
535mod tests {
536    use super::*;
537    use bynk_syntax::span::Span;
538
539    /// Spans are byte offsets; ariadne 0.6 defaults to character indexing.
540    /// On a line with non-ASCII text before the span, the char-indexed
541    /// underline lands past the target. Pin the byte-indexed placement by
542    /// checking the caret column against the target's display column.
543    #[test]
544    fn underline_is_byte_indexed_on_non_ascii_lines() {
545        // `é` is 2 bytes / 1 display column; `bad` starts at byte 11,
546        // display column 10.
547        let source = "// caféxyz bad\n";
548        let start = source.find("bad").unwrap();
549        let err = CompileError::new(
550            "bynk.test.example",
551            Span::new(start, start + 3),
552            "bad thing",
553        );
554        let rendered = render_errors_plain(&[err], source, "probe.bynk");
555        let source_line = rendered
556            .lines()
557            .find(|l| l.contains("caféxyz"))
558            .expect("snippet line present");
559        let marker_line = rendered
560            .lines()
561            .find(|l| l.contains('┬'))
562            .expect("marker line present");
563        let col_of = |line: &str, target: char| line.chars().take_while(|&c| c != target).count();
564        // The `┬` sits within the underline under `bad` — same display
565        // column as `b`, or one to its right for spans wider than 1.
566        let b_col = col_of(source_line, 'b');
567        let caret_col = col_of(marker_line, '┬');
568        assert!(
569            (b_col..b_col + 3).contains(&caret_col),
570            "caret at display column {caret_col}, expected within `bad` at {b_col}..{}:\n{rendered}",
571            b_col + 3
572        );
573    }
574
575    /// A label whose span lies past the end of the rendered source belongs to
576    /// another file; it must be demoted to a note, not underline unrelated
577    /// text (or panic).
578    #[test]
579    fn out_of_bounds_label_demotes_to_note() {
580        let source = "commons demo\n";
581        let err = CompileError::new("bynk.test.example", Span::new(0, 7), "problem here")
582            .with_label(
583                Span::new(5_000, 5_010),
584                "parameter declared here (in another file)",
585            );
586        let rendered = render_errors_plain(&[err], source, "probe.bynk");
587        assert!(
588            rendered.contains("parameter declared here"),
589            "label text survives as a note:\n{rendered}"
590        );
591    }
592
593    /// A cross-file label whose byte span is *in-bounds* but lands mid-codepoint
594    /// (the file it really belongs to has non-ASCII text) must be demoted, not
595    /// fed to ariadne — a byte offset splitting a codepoint panics its byte→char
596    /// mapping (#716). The rendered source here is all multi-byte, so an odd
597    /// offset is never a char boundary.
598    #[test]
599    fn mid_codepoint_label_demotes_to_note() {
600        let source = "café ☕\n"; // `é` and `☕` are multi-byte
601        let err = CompileError::new("bynk.test.example", Span::new(0, 3), "problem here")
602            .with_label(
603                Span::new(4, 5),
604                "declared here (mid-codepoint, another file)",
605            );
606        // Must not panic, and the label survives as a note rather than a caret.
607        let rendered = render_errors_plain(&[err], source, "probe.bynk");
608        assert!(
609            rendered.contains("declared here (mid-codepoint, another file)"),
610            "a mid-codepoint label must survive as a note:\n{rendered}"
611        );
612    }
613
614    /// #1666: colour needs a terminal, and `NO_COLOR` set to anything but
615    /// the empty string turns it off. #1777: `FORCE_COLOR` or
616    /// `CLICOLOR_FORCE`, set to anything but the empty string or `0`, turns
617    /// it on without one; `NO_COLOR` wins over both.
618    #[test]
619    fn color_needs_a_terminal_or_a_force_and_no_no_color() {
620        use std::ffi::OsString;
621        let allowed = |is_terminal, vars: &[(&str, &str)]| {
622            color_allowed(is_terminal, |name| {
623                vars.iter()
624                    .find(|(n, _)| *n == name)
625                    .map(|(_, v)| OsString::from(v))
626            })
627        };
628        assert!(allowed(true, &[]));
629        assert!(allowed(true, &[("NO_COLOR", "")]));
630        assert!(!allowed(true, &[("NO_COLOR", "1")]));
631        assert!(!allowed(false, &[]));
632        assert!(!allowed(false, &[("NO_COLOR", "")]));
633
634        for force in ["FORCE_COLOR", "CLICOLOR_FORCE"] {
635            assert!(allowed(false, &[(force, "1")]), "{force}=1");
636            assert!(allowed(false, &[(force, "true")]), "{force}=true");
637            assert!(!allowed(false, &[(force, "")]), "{force}= is unset");
638            assert!(!allowed(false, &[(force, "0")]), "{force}=0 doesn't force");
639            assert!(allowed(true, &[(force, "0")]), "{force}=0 doesn't forbid");
640            assert!(allowed(false, &[(force, "1"), ("NO_COLOR", "")]));
641            assert!(!allowed(false, &[(force, "1"), ("NO_COLOR", "1")]));
642            assert!(!allowed(true, &[(force, "1"), ("NO_COLOR", "1")]));
643        }
644    }
645
646    /// #1666: a megabyte line renders as a window around the label, marked
647    /// `…` both ends, with the header naming the label's real column.
648    #[test]
649    fn a_long_line_is_cut_around_the_label() {
650        let pad = "x".repeat(500_000);
651        let source = format!("commons c\n{pad}bad{pad}\n");
652        let start = source.find("bad").unwrap();
653        let err = CompileError::new("bynk.test.example", Span::new(start, start + 3), "bad");
654        let rendered = render_errors_plain(&[err], &source, "probe.bynk");
655        assert!(
656            rendered.len() < 4_000,
657            "{} bytes:\n{rendered}",
658            rendered.len()
659        );
660        assert!(
661            rendered.contains("probe.bynk:2:500001 "),
662            "the header keeps the column in the file:\n{rendered}"
663        );
664        let line = rendered.lines().find(|l| l.contains("xbadx")).unwrap();
665        assert!(line.contains('…') && line.ends_with('…'), "{line}");
666        let caret = rendered.lines().find(|l| l.contains('┬')).unwrap();
667        let col = |l: &str, s: &str| l[..l.find(s).unwrap()].chars().count();
668        assert!((col(line, "bad")..col(line, "bad") + 3).contains(&col(caret, "┬")));
669        // Coloured, the same report is just as small.
670        let err = CompileError::new("bynk.test.example", Span::new(start, start + 3), "bad");
671        assert!(render_errors(&[err], &source, "probe.bynk").len() < 8_000);
672    }
673
674    /// #1666: a cut lands on a char boundary, and a line no longer than
675    /// [`MAX_LINE`] renders as it is.
676    #[test]
677    fn cuts_respect_chars_and_short_lines_are_kept() {
678        let wide = "é".repeat(300); // 600 bytes
679        let short = "y".repeat(MAX_LINE);
680        let source = format!("commons c\n{short}\n{wide}bad{wide}\n");
681        let start = source.find("bad").unwrap();
682        let err = CompileError::new("bynk.test.example", Span::new(start, start + 3), "bad");
683        let rendered = render_errors_plain(&[err], &source, "probe.bynk");
684        assert!(rendered.contains("probe.bynk:3:301 "), "{rendered}");
685        let line = rendered.lines().find(|l| l.contains("ébadé")).unwrap();
686        assert!(line.contains("…éé") && line.contains("éé…"), "{line}");
687
688        let span = Span::new(10, 11);
689        let err = CompileError::new("bynk.test.example", span, "here");
690        let rendered = render_errors_plain(&[err], &source, "probe.bynk");
691        assert!(rendered.contains(&short), "{rendered}");
692    }
693
694    /// #1666: cutting a long line keeps another file's label a note.
695    #[test]
696    fn a_foreign_label_stays_a_note_on_a_cut_line() {
697        let source = format!("commons c\n{}\n", "z".repeat(1_000));
698        let err = CompileError::new("bynk.test.example", Span::new(10, 11), "here")
699            .with_label(Span::new(5_000, 5_010), "declared in another file");
700        let rendered = render_errors_plain(&[err], &source, "probe.bynk");
701        assert!(
702            rendered.contains("Note: declared in another file"),
703            "{rendered}"
704        );
705    }
706
707    /// #1666: labels too far apart for one window keep the primary span's
708    /// neighbourhood; a label cut from it becomes a note rather than a caret
709    /// on the `…`.
710    #[test]
711    fn a_label_cut_from_the_window_becomes_a_note() {
712        let source = format!("commons c\nhere{}there\n", "1 ".repeat(100_000));
713        let there = source.find("there").unwrap();
714        let err = CompileError::new("bynk.test.example", Span::new(10, 14), "primary")
715            .with_label(Span::new(there, there + 5), "far label");
716        let rendered = render_errors_plain(&[err], &source, "probe.bynk");
717        assert!(rendered.len() < 4_000, "{rendered}");
718        assert!(rendered.contains("Note: far label"), "{rendered}");
719        assert!(rendered.contains("probe.bynk:2:1 "), "{rendered}");
720    }
721
722    /// #1666: a CRLF line's `\r` is kept after a cut, outside the window.
723    #[test]
724    fn a_cut_crlf_line_keeps_its_line_ending() {
725        let source = format!("commons c\r\n{}bad\r\n", "w".repeat(1_000));
726        let start = source.find("bad").unwrap();
727        let (shown, _) = cropped(
728            &CompileError::new("bynk.test.example", Span::new(start, start + 3), "bad"),
729            &source,
730        );
731        assert!(shown.starts_with("commons c\r\n…"), "{shown:?}");
732        assert!(shown.ends_with("wbad\r\n"), "{shown:?}");
733    }
734
735    /// #1666 review: a label touching a window edge from outside keeps no
736    /// byte, so it is a note, not a caret on the `…` or on the first kept
737    /// character. The far label forces the primary span's window
738    /// `[primary - CONTEXT, primary + MAX_LINE)`.
739    #[test]
740    fn a_label_touching_the_window_edge_from_outside_is_a_note() {
741        let source = format!("commons c\n{}\n", "q".repeat(20_000));
742        let p = 10 + 1_000;
743        let err = CompileError::new("bynk.test.example", Span::new(p, p + 1), "primary")
744            .with_label(Span::new(p + MAX_LINE, p + MAX_LINE + 3), "after the cut")
745            .with_label(Span::new(p - CONTEXT - 3, p - CONTEXT), "before the cut")
746            .with_label(Span::new(p + 10_000, p + 10_003), "far away");
747        let rendered = render_errors_plain(&[err], &source, "probe.bynk");
748        for (i, label) in ["after the cut", "before the cut", "far away"]
749            .iter()
750            .enumerate()
751        {
752            let note = format!("Note {}: {label}", i + 1);
753            assert!(rendered.contains(&note), "{rendered}");
754        }
755    }
756
757    /// #1666 review: errors over one source, one on a cut line and one on a
758    /// short line, each get the right header; two long lines are cut
759    /// independently.
760    #[test]
761    fn several_errors_and_long_lines_render_independently() {
762        let pad = "x".repeat(5_000);
763        let source = format!("commons c\n{pad}one{pad}\nshort\n{pad}two{pad}\n");
764        let at = |word: &str| source.find(word).unwrap();
765        let err = |word: &str| {
766            CompileError::new("bynk.test.example", Span::new(at(word), at(word) + 3), word)
767        };
768        let errors = [
769            err("one"),
770            CompileError::new(
771                "bynk.test.example",
772                Span::new(at("short"), at("short") + 5),
773                "s",
774            ),
775            err("two"),
776        ];
777        let rendered = render_errors_plain(&errors, &source, "probe.bynk");
778        assert!(rendered.len() < 8_000, "{rendered}");
779        for header in [
780            "probe.bynk:2:5001 ",
781            "probe.bynk:3:1 ",
782            "probe.bynk:4:5001 ",
783        ] {
784            assert!(rendered.contains(header), "{header}:\n{rendered}");
785        }
786        assert!(
787            rendered
788                .lines()
789                .any(|l| l.contains("xonex") && l.ends_with('…'))
790        );
791        assert!(
792            rendered
793                .lines()
794                .any(|l| l.contains("xtwox") && l.ends_with('…'))
795        );
796    }
797
798    /// #1666 review: the coloured report's header names the column in the
799    /// file too.
800    #[test]
801    fn the_coloured_header_keeps_the_column_in_the_file() {
802        let source = format!("commons c\n{}bad\n", "x".repeat(50_000));
803        let start = source.find("bad").unwrap();
804        let err = CompileError::new("bynk.test.example", Span::new(start, start + 3), "bad");
805        let rendered = render_errors(&[err], &source, "probe.bynk");
806        assert!(rendered.contains("probe.bynk:2:50001 "), "{rendered}");
807    }
808
809    /// #1666 review: a header fix touches only the header, not a message
810    /// that quotes the same `path:line:col`.
811    #[test]
812    fn the_header_fix_leaves_the_message_alone() {
813        let source = format!("commons c\n{}bad\n", "x".repeat(1_000));
814        let start = source.find("bad").unwrap();
815        // The cut puts `bad` at column 82 of the shown line.
816        let err = CompileError::new(
817            "bynk.test.example",
818            Span::new(start, start + 3),
819            "see probe.bynk:2:82",
820        );
821        let rendered = render_errors_plain(&[err], &source, "probe.bynk");
822        assert!(
823            rendered.contains("Error: see probe.bynk:2:82"),
824            "{rendered}"
825        );
826        assert!(rendered.contains("[ probe.bynk:2:1001 ]"), "{rendered}");
827    }
828
829    /// #1666 review: a rendered report over CRLF source numbers the cut line
830    /// as in the file.
831    #[test]
832    fn a_cut_crlf_report_keeps_line_numbers() {
833        let source = format!("commons c\r\n\r\n{}bad\r\nnext\r\n", "w".repeat(1_000));
834        let start = source.find("bad").unwrap();
835        let err = CompileError::new("bynk.test.example", Span::new(start, start + 3), "bad");
836        let rendered = render_errors_plain(&[err], &source, "probe.bynk");
837        assert!(rendered.contains("[ probe.bynk:3:1001 ]"), "{rendered}");
838        assert!(
839            rendered
840                .lines()
841                .any(|l| l.starts_with(" 3 │ …") && l.contains("wbad")),
842            "{rendered}"
843        );
844    }
845
846    /// A label in another file is drawn in a snippet of that file; one with
847    /// no text to show, or on one of that file's long lines (#1666, which cuts
848    /// only the primary file), becomes a note.
849    #[test]
850    fn a_label_in_another_file_renders_against_that_file() {
851        let primary = "context app\n\n\nservice api {}\n";
852        let sibling = "context app\nservice api {}\n";
853        let at = |text: &str| text.find("api").unwrap();
854        let err = CompileError::new(
855            "bynk.resolve.duplicate_service",
856            Span::new(at(primary), at(primary) + 3),
857            "already declared",
858        )
859        .with_label(
860            Span::new(at(sibling), at(sibling) + 3),
861            "previously declared here",
862        )
863        .with_label(Span::new(0, 1), "in an injected unit");
864        let homes = [
865            LabelHome::File {
866                filename: "a.bynk",
867                source: sibling,
868            },
869            LabelHome::Unavailable,
870        ];
871        let rendered = render_error_in_plain(&err, primary, "z.bynk", &homes);
872        let (z, a) = rendered.split_once("─[ a.bynk:2:9 ]").expect(&rendered);
873        assert!(z.contains("─[ z.bynk:4:9 ]"), "{rendered}");
874        assert!(!z.contains("previously declared here"), "{rendered}");
875        assert!(a.contains(" 2 │ service api {}"), "{rendered}");
876        assert!(a.contains("previously declared here"), "{rendered}");
877        assert!(rendered.contains("Note: in an injected unit"), "{rendered}");
878
879        let long = format!("context app\n{} service api {{}}\n", "x".repeat(MAX_LINE));
880        let err = CompileError::new("bynk.test", Span::new(0, 7), "msg")
881            .with_label(Span::new(at(&long), at(&long) + 3), "on a long line");
882        let homes = [LabelHome::File {
883            filename: "a.bynk",
884            source: &long,
885        }];
886        let rendered = render_error_in_plain(&err, primary, "z.bynk", &homes);
887        assert!(!rendered.contains("a.bynk"), "{rendered}");
888        assert!(rendered.contains("Note: on a long line"), "{rendered}");
889    }
890
891    /// #1956: a label in another file whose text has non-ASCII before it is
892    /// headed by its own line and column. ariadne 0.6's byte-index mode looked
893    /// the label's char offset up as a byte offset for that header — naming an
894    /// earlier line, or panicking when the offset split a codepoint, as it does
895    /// here (`bynk.bynk`, where first-party labels point, is full of `—`).
896    #[test]
897    fn a_label_in_another_file_after_non_ascii_text_is_headed_by_its_own_position() {
898        let primary = "context app\n\n\nservice api {}\n";
899        let sibling = "context app\n// ———\nservice api {} // é\n";
900        let at = |text: &str| text.find("api").unwrap();
901        let err = CompileError::new("bynk.test", Span::new(at(primary), at(primary) + 3), "msg")
902            .with_label(
903                Span::new(at(sibling), at(sibling) + 3),
904                "previously declared here",
905            );
906        let homes = [LabelHome::File {
907            filename: "a.bynk",
908            source: sibling,
909        }];
910        let rendered = render_error_in_plain(&err, primary, "z.bynk", &homes);
911        let (z, a) = rendered.split_once("─[ a.bynk:3:9 ]").expect(&rendered);
912        assert!(z.contains("─[ z.bynk:4:9 ]"), "{rendered}");
913        assert!(a.contains(" 3 │ service api {} // é"), "{rendered}");
914        assert!(a.contains("previously declared here"), "{rendered}");
915    }
916
917    /// A long primary line is cut (#1666) even when a label lies in another
918    /// file: the report heads with the primary span's column in the file, not
919    /// in the cut text, and the sibling's snippet keeps its own header.
920    #[test]
921    fn a_cut_primary_line_keeps_its_column_beside_a_label_in_another_file() {
922        let primary = format!(
923            "context app\n{} service api {{}}\n",
924            "x".repeat(2 * MAX_LINE)
925        );
926        let sibling = "context app\nservice api {}\n";
927        let at = |text: &str| text.find("api").unwrap();
928        let err = CompileError::new(
929            "bynk.resolve.duplicate_service",
930            Span::new(at(&primary), at(&primary) + 3),
931            "already declared",
932        )
933        .with_label(
934            Span::new(at(sibling), at(sibling) + 3),
935            "previously declared here",
936        );
937        let homes = [LabelHome::File {
938            filename: "a.bynk",
939            source: sibling,
940        }];
941        let rendered = render_error_in_plain(&err, &primary, "z.bynk", &homes);
942        let col = at(&primary) - primary.find('\n').unwrap();
943        let (z, a) = rendered.split_once("─[ a.bynk:2:9 ]").expect(&rendered);
944        assert!(z.contains(&format!("─[ z.bynk:2:{col} ]")), "{rendered}");
945        assert!(z.contains('…'), "the long primary line is cut: {rendered}");
946        assert!(!z.contains("previously declared here"), "{rendered}");
947        assert!(a.contains("previously declared here"), "{rendered}");
948    }
949}