use std::path::Path;
use ariadne::Source;
use bynk_syntax::error::Severity;
use bynk_syntax::{CompileError, span};
pub fn render_errors(errors: &[CompileError], source: &str, filename: &str) -> String {
String::from_utf8_lossy(&render_all(errors, source, filename, true)).into_owned()
}
pub fn render_errors_plain(errors: &[CompileError], source: &str, filename: &str) -> String {
String::from_utf8_lossy(&render_all(errors, source, filename, false)).into_owned()
}
pub fn print_errors(errors: &[CompileError], source: &str, filename: &str) {
use std::io::Write;
let out = render_all(errors, source, filename, stderr_color());
let _ = std::io::stderr().lock().write_all(&out);
}
fn render_all(errors: &[CompileError], source: &str, filename: &str, color: bool) -> Vec<u8> {
fn write<'a>(
err: &'a CompileError,
filename: &'a str,
cache: &mut (&'a str, Source<&str>),
color: bool,
out: &mut Vec<u8>,
) {
let source = cache.1.text();
let report = if color {
err.report_for(filename, source)
} else {
err.report_plain_for(filename, source)
};
report
.write(&mut *cache, out)
.expect("write to Vec<u8> cannot fail");
}
let long = long_lines(source);
let mut cache = (filename, Source::from(source));
let mut index = None;
let mut out = Vec::new();
for err in errors {
let touches_long = std::iter::once(&err.span)
.chain(err.labels.iter().map(|(s, _)| s))
.filter(|s| fits(s, source))
.any(|s| {
long.iter()
.any(|&(start, end)| s.start <= end && s.end >= start)
});
if !touches_long {
write(err, filename, &mut cache, color, &mut out);
continue;
}
let (shown, shown_err) = cropped(err, source);
let mut one = Vec::new();
write(
&shown_err,
filename,
&mut (filename, Source::from(&*shown)),
color,
&mut one,
);
let cut = span::line_col(&shown, shown_err.span.start);
let real = index
.get_or_insert_with(|| span::LineIndex::new(source))
.line_col(source, err.span.start);
if cut != real {
one = fix_header(
&one,
&format!("{filename}:{}:{}", cut.0, cut.1),
&format!("{filename}:{}:{}", real.0, real.1),
);
}
out.extend(one);
}
out
}
fn long_lines(source: &str) -> Vec<(usize, usize)> {
let mut long = Vec::new();
let mut start = 0;
for raw in source.split_inclusive('\n') {
let len = raw.trim_end_matches(['\n', '\r']).len();
if len > MAX_LINE {
long.push((start, start + len));
}
start += raw.len();
}
long
}
fn fix_header(report: &[u8], cut: &str, real: &str) -> Vec<u8> {
let text = String::from_utf8_lossy(report);
let Some(corner) = text.find('╭') else {
return report.to_vec();
};
let start = text[..corner].rfind('\n').map_or(0, |i| i + 1);
let end = text[corner..].find('\n').map_or(text.len(), |i| corner + i);
let header = text[start..end].replacen(cut, real, 1);
format!("{}{header}{}", &text[..start], &text[end..]).into_bytes()
}
pub const MAX_LINE: usize = 400;
const CONTEXT: usize = 80;
struct LineWindow {
end: usize,
win_start: usize,
win_end: usize,
new_text_start: usize,
}
fn cropped(err: &CompileError, source: &str) -> (String, CompileError) {
let spans: Vec<(usize, usize)> = std::iter::once(&err.span)
.chain(err.labels.iter().map(|(s, _)| s))
.filter(|s| fits(s, source))
.map(|s| (s.start, s.end))
.collect();
let mut out = String::with_capacity(source.len().min(64 * 1024));
let mut lines: Vec<LineWindow> = Vec::new();
let mut start = 0;
for raw in source.split_inclusive('\n') {
let line_len = raw.trim_end_matches(['\n', '\r']).len();
let end = start + line_len;
let (win_start, win_end) = if line_len <= MAX_LINE {
(start, end)
} else {
let mut lo = usize::MAX;
let mut hi = 0;
for &(s, e) in &spans {
if e < start || s > end {
continue;
}
let s = s.max(start);
let e = e.min(end).min(s + MAX_LINE);
lo = lo.min(s);
hi = hi.max(e);
}
let (lo, hi) = if lo == usize::MAX {
(start, start + MAX_LINE)
} else {
(
lo.saturating_sub(CONTEXT).max(start),
(hi + CONTEXT).min(end),
)
};
let (lo, hi) = if hi - lo > MAX_LINE + 2 * CONTEXT {
let s = err.span.start.clamp(start, end);
(
s.saturating_sub(CONTEXT).max(start),
(s + MAX_LINE).min(end),
)
} else {
(lo, hi)
};
(floor_char(source, lo), ceil_char(source, hi))
};
if win_start > start {
out.push('…');
}
let new_text_start = out.len();
out.push_str(&source[win_start..win_end]);
if win_end < end {
out.push('…');
}
out.push_str(&raw[line_len..]);
lines.push(LineWindow {
end,
win_start,
win_end,
new_text_start,
});
start += raw.len();
}
let line_of = |offset: usize| lines.get(lines.partition_point(|l| l.end < offset));
let map = |offset: usize| -> usize {
let Some(l) = line_of(offset) else {
return out.len();
};
l.new_text_start + offset.clamp(l.win_start, l.win_end) - l.win_start
};
let mut err = err.clone();
err.span.start = map(err.span.start);
err.span.end = map(err.span.end).max(err.span.start);
for (span, _) in &mut err.labels {
let outside = line_of(span.start).is_some_and(|l| {
(span.start >= l.win_end && l.win_end < l.end)
|| if span.start == span.end {
span.start < l.win_start
} else {
span.end <= l.win_start
}
});
if !fits(span, source) || outside {
span.start = usize::MAX;
span.end = usize::MAX;
continue;
}
span.start = map(span.start);
span.end = map(span.end).max(span.start);
}
(out, err)
}
fn fits(span: &span::Span, source: &str) -> bool {
span.start <= span.end
&& span.end <= source.len()
&& source.is_char_boundary(span.start)
&& source.is_char_boundary(span.end)
}
fn floor_char(s: &str, mut i: usize) -> usize {
while !s.is_char_boundary(i) {
i -= 1;
}
i
}
fn ceil_char(s: &str, mut i: usize) -> usize {
while !s.is_char_boundary(i) {
i += 1;
}
i
}
pub fn stderr_color() -> bool {
use std::io::IsTerminal;
color_allowed(std::io::stderr().is_terminal(), |name| {
std::env::var_os(name)
})
}
fn color_allowed(is_terminal: bool, var: impl Fn(&str) -> Option<std::ffi::OsString>) -> bool {
let set = |name| var(name).is_some_and(|v| !v.is_empty());
let forced = |name| var(name).is_some_and(|v| !v.is_empty() && v != "0");
if set("NO_COLOR") {
false
} else {
is_terminal || forced("FORCE_COLOR") || forced("CLICOLOR_FORCE")
}
}
pub fn print_project_errors(root: &Path, errors: &[CompileError]) {
let _ = root;
for err in errors {
eprintln!("[{}] {}", err.category, err.message);
for note in &err.notes {
eprintln!(" note: {note}");
}
for (_, label) in &err.labels {
eprintln!(" label: {label}");
}
}
}
pub fn print_errors_short(errors: &[CompileError], source: &str, filename: &str) {
eprint!("{}", render_errors_short(errors, source, filename));
}
pub fn render_errors_short(errors: &[CompileError], source: &str, filename: &str) -> String {
let mut out = String::new();
for err in errors {
out.push_str(&short_line(filename, source, err));
out.push('\n');
}
out
}
pub fn short_line(filename: &str, source: &str, err: &CompileError) -> String {
let (line, col) = span::line_col(source, err.span.start);
format!(
"{filename}:{line}:{col}: {}[{}]: {}",
severity_word(err),
err.category,
err.message
)
}
pub fn severity_word(err: &CompileError) -> &'static str {
match Severity::for_error(err) {
Severity::Error => "error",
Severity::Warning => "warning",
}
}
pub fn render_project_errors(errors: &[CompileError]) -> String {
let mut out = String::new();
for err in errors {
out.push_str(&format!("[{}] {}\n", err.category, err.message));
for note in &err.notes {
out.push_str(&format!(" note: {note}\n"));
}
for (_, label) in &err.labels {
out.push_str(&format!(" label: {label}\n"));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use bynk_syntax::span::Span;
#[test]
fn underline_is_byte_indexed_on_non_ascii_lines() {
let source = "-- caféxyz bad\n";
let start = source.find("bad").unwrap();
let err = CompileError::new(
"bynk.test.example",
Span::new(start, start + 3),
"bad thing",
);
let rendered = render_errors_plain(&[err], source, "probe.bynk");
let source_line = rendered
.lines()
.find(|l| l.contains("caféxyz"))
.expect("snippet line present");
let marker_line = rendered
.lines()
.find(|l| l.contains('┬'))
.expect("marker line present");
let col_of = |line: &str, target: char| line.chars().take_while(|&c| c != target).count();
let b_col = col_of(source_line, 'b');
let caret_col = col_of(marker_line, '┬');
assert!(
(b_col..b_col + 3).contains(&caret_col),
"caret at display column {caret_col}, expected within `bad` at {b_col}..{}:\n{rendered}",
b_col + 3
);
}
#[test]
fn out_of_bounds_label_demotes_to_note() {
let source = "commons demo\n";
let err = CompileError::new("bynk.test.example", Span::new(0, 7), "problem here")
.with_label(
Span::new(5_000, 5_010),
"parameter declared here (in another file)",
);
let rendered = render_errors_plain(&[err], source, "probe.bynk");
assert!(
rendered.contains("parameter declared here"),
"label text survives as a note:\n{rendered}"
);
}
#[test]
fn mid_codepoint_label_demotes_to_note() {
let source = "café ☕\n"; let err = CompileError::new("bynk.test.example", Span::new(0, 3), "problem here")
.with_label(
Span::new(4, 5),
"declared here (mid-codepoint, another file)",
);
let rendered = render_errors_plain(&[err], source, "probe.bynk");
assert!(
rendered.contains("declared here (mid-codepoint, another file)"),
"a mid-codepoint label must survive as a note:\n{rendered}"
);
}
#[test]
fn color_needs_a_terminal_or_a_force_and_no_no_color() {
use std::ffi::OsString;
let allowed = |is_terminal, vars: &[(&str, &str)]| {
color_allowed(is_terminal, |name| {
vars.iter()
.find(|(n, _)| *n == name)
.map(|(_, v)| OsString::from(v))
})
};
assert!(allowed(true, &[]));
assert!(allowed(true, &[("NO_COLOR", "")]));
assert!(!allowed(true, &[("NO_COLOR", "1")]));
assert!(!allowed(false, &[]));
assert!(!allowed(false, &[("NO_COLOR", "")]));
for force in ["FORCE_COLOR", "CLICOLOR_FORCE"] {
assert!(allowed(false, &[(force, "1")]), "{force}=1");
assert!(allowed(false, &[(force, "true")]), "{force}=true");
assert!(!allowed(false, &[(force, "")]), "{force}= is unset");
assert!(!allowed(false, &[(force, "0")]), "{force}=0 doesn't force");
assert!(allowed(true, &[(force, "0")]), "{force}=0 doesn't forbid");
assert!(allowed(false, &[(force, "1"), ("NO_COLOR", "")]));
assert!(!allowed(false, &[(force, "1"), ("NO_COLOR", "1")]));
assert!(!allowed(true, &[(force, "1"), ("NO_COLOR", "1")]));
}
}
#[test]
fn a_long_line_is_cut_around_the_label() {
let pad = "x".repeat(500_000);
let source = format!("commons c\n{pad}bad{pad}\n");
let start = source.find("bad").unwrap();
let err = CompileError::new("bynk.test.example", Span::new(start, start + 3), "bad");
let rendered = render_errors_plain(&[err], &source, "probe.bynk");
assert!(
rendered.len() < 4_000,
"{} bytes:\n{rendered}",
rendered.len()
);
assert!(
rendered.contains("probe.bynk:2:500001 "),
"the header keeps the column in the file:\n{rendered}"
);
let line = rendered.lines().find(|l| l.contains("xbadx")).unwrap();
assert!(line.contains('…') && line.ends_with('…'), "{line}");
let caret = rendered.lines().find(|l| l.contains('┬')).unwrap();
let col = |l: &str, s: &str| l[..l.find(s).unwrap()].chars().count();
assert!((col(line, "bad")..col(line, "bad") + 3).contains(&col(caret, "┬")));
let err = CompileError::new("bynk.test.example", Span::new(start, start + 3), "bad");
assert!(render_errors(&[err], &source, "probe.bynk").len() < 8_000);
}
#[test]
fn cuts_respect_chars_and_short_lines_are_kept() {
let wide = "é".repeat(300); let short = "y".repeat(MAX_LINE);
let source = format!("commons c\n{short}\n{wide}bad{wide}\n");
let start = source.find("bad").unwrap();
let err = CompileError::new("bynk.test.example", Span::new(start, start + 3), "bad");
let rendered = render_errors_plain(&[err], &source, "probe.bynk");
assert!(rendered.contains("probe.bynk:3:301 "), "{rendered}");
let line = rendered.lines().find(|l| l.contains("ébadé")).unwrap();
assert!(line.contains("…éé") && line.contains("éé…"), "{line}");
let span = Span::new(10, 11);
let err = CompileError::new("bynk.test.example", span, "here");
let rendered = render_errors_plain(&[err], &source, "probe.bynk");
assert!(rendered.contains(&short), "{rendered}");
}
#[test]
fn a_foreign_label_stays_a_note_on_a_cut_line() {
let source = format!("commons c\n{}\n", "z".repeat(1_000));
let err = CompileError::new("bynk.test.example", Span::new(10, 11), "here")
.with_label(Span::new(5_000, 5_010), "declared in another file");
let rendered = render_errors_plain(&[err], &source, "probe.bynk");
assert!(
rendered.contains("Note: declared in another file"),
"{rendered}"
);
}
#[test]
fn a_label_cut_from_the_window_becomes_a_note() {
let source = format!("commons c\nhere{}there\n", "1 ".repeat(100_000));
let there = source.find("there").unwrap();
let err = CompileError::new("bynk.test.example", Span::new(10, 14), "primary")
.with_label(Span::new(there, there + 5), "far label");
let rendered = render_errors_plain(&[err], &source, "probe.bynk");
assert!(rendered.len() < 4_000, "{rendered}");
assert!(rendered.contains("Note: far label"), "{rendered}");
assert!(rendered.contains("probe.bynk:2:1 "), "{rendered}");
}
#[test]
fn a_cut_crlf_line_keeps_its_line_ending() {
let source = format!("commons c\r\n{}bad\r\n", "w".repeat(1_000));
let start = source.find("bad").unwrap();
let (shown, _) = cropped(
&CompileError::new("bynk.test.example", Span::new(start, start + 3), "bad"),
&source,
);
assert!(shown.starts_with("commons c\r\n…"), "{shown:?}");
assert!(shown.ends_with("wbad\r\n"), "{shown:?}");
}
#[test]
fn a_label_touching_the_window_edge_from_outside_is_a_note() {
let source = format!("commons c\n{}\n", "q".repeat(20_000));
let p = 10 + 1_000;
let err = CompileError::new("bynk.test.example", Span::new(p, p + 1), "primary")
.with_label(Span::new(p + MAX_LINE, p + MAX_LINE + 3), "after the cut")
.with_label(Span::new(p - CONTEXT - 3, p - CONTEXT), "before the cut")
.with_label(Span::new(p + 10_000, p + 10_003), "far away");
let rendered = render_errors_plain(&[err], &source, "probe.bynk");
for (i, label) in ["after the cut", "before the cut", "far away"]
.iter()
.enumerate()
{
let note = format!("Note {}: {label}", i + 1);
assert!(rendered.contains(¬e), "{rendered}");
}
}
#[test]
fn several_errors_and_long_lines_render_independently() {
let pad = "x".repeat(5_000);
let source = format!("commons c\n{pad}one{pad}\nshort\n{pad}two{pad}\n");
let at = |word: &str| source.find(word).unwrap();
let err = |word: &str| {
CompileError::new("bynk.test.example", Span::new(at(word), at(word) + 3), word)
};
let errors = [
err("one"),
CompileError::new(
"bynk.test.example",
Span::new(at("short"), at("short") + 5),
"s",
),
err("two"),
];
let rendered = render_errors_plain(&errors, &source, "probe.bynk");
assert!(rendered.len() < 8_000, "{rendered}");
for header in [
"probe.bynk:2:5001 ",
"probe.bynk:3:1 ",
"probe.bynk:4:5001 ",
] {
assert!(rendered.contains(header), "{header}:\n{rendered}");
}
assert!(
rendered
.lines()
.any(|l| l.contains("xonex") && l.ends_with('…'))
);
assert!(
rendered
.lines()
.any(|l| l.contains("xtwox") && l.ends_with('…'))
);
}
#[test]
fn the_coloured_header_keeps_the_column_in_the_file() {
let source = format!("commons c\n{}bad\n", "x".repeat(50_000));
let start = source.find("bad").unwrap();
let err = CompileError::new("bynk.test.example", Span::new(start, start + 3), "bad");
let rendered = render_errors(&[err], &source, "probe.bynk");
assert!(rendered.contains("probe.bynk:2:50001 "), "{rendered}");
}
#[test]
fn the_header_fix_leaves_the_message_alone() {
let source = format!("commons c\n{}bad\n", "x".repeat(1_000));
let start = source.find("bad").unwrap();
let err = CompileError::new(
"bynk.test.example",
Span::new(start, start + 3),
"see probe.bynk:2:82",
);
let rendered = render_errors_plain(&[err], &source, "probe.bynk");
assert!(
rendered.contains("Error: see probe.bynk:2:82"),
"{rendered}"
);
assert!(rendered.contains("[ probe.bynk:2:1001 ]"), "{rendered}");
}
#[test]
fn a_cut_crlf_report_keeps_line_numbers() {
let source = format!("commons c\r\n\r\n{}bad\r\nnext\r\n", "w".repeat(1_000));
let start = source.find("bad").unwrap();
let err = CompileError::new("bynk.test.example", Span::new(start, start + 3), "bad");
let rendered = render_errors_plain(&[err], &source, "probe.bynk");
assert!(rendered.contains("[ probe.bynk:3:1001 ]"), "{rendered}");
assert!(
rendered
.lines()
.any(|l| l.starts_with(" 3 │ …") && l.contains("wbad")),
"{rendered}"
);
}
}