use crate::error::LispError;
use crate::span::Span;
macro_rules! infallible_writeln {
($out:expr, $($t:tt)*) => {{
use ::std::fmt::Write as _;
::std::writeln!($out, $($t)*).expect("writes to a String never fail")
}};
}
macro_rules! infallible_write {
($out:expr, $($t:tt)*) => {{
use ::std::fmt::Write as _;
::std::write!($out, $($t)*).expect("writes to a String never fail")
}};
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct LineCol {
pub line: usize,
pub column: usize,
}
#[must_use]
pub fn line_col(src: &str, byte_offset: usize) -> LineCol {
let cap = byte_offset.min(src.len());
let mut line = 1usize;
let mut column = 1usize;
let mut idx = 0usize;
for c in src.chars() {
if idx >= cap {
break;
}
if c == '\n' {
line += 1;
column = 1;
} else {
column += 1;
}
idx += c.len_utf8();
}
LineCol { line, column }
}
#[must_use]
pub fn line_at(src: &str, byte_offset: usize) -> &str {
let cap = byte_offset.min(src.len());
let start = src[..cap].rfind('\n').map_or(0, |i| i + 1);
let end = src[start..].find('\n').map_or(src.len(), |i| start + i);
&src[start..end]
}
fn mirror_source_prefix_as_pad(line_text: &str, column: usize) -> String {
line_text
.chars()
.take(column.saturating_sub(1))
.map(|c| if c == '\t' { '\t' } else { ' ' })
.collect()
}
#[must_use]
pub fn caret_run(line_text: &str, column: usize, width: usize) -> String {
let mut out = mirror_source_prefix_as_pad(line_text, column);
for _ in 0..width.max(1) {
out.push('^');
}
out
}
#[must_use]
pub fn span_width_chars(src: &str, span: Span) -> usize {
src.get(span.start..span.end)
.map_or(1, |covered| covered.chars().count())
}
#[must_use]
pub fn format_diagnostic(src: &str, err: &LispError, label: Option<&str>) -> String {
let mut out = format!("error: {err}");
let Some(span) = err.span() else {
return out;
};
let pos = span.start;
let LineCol { line, column } = line_col(src, pos);
let line_text = line_at(src, pos);
let line_str = line.to_string();
let gutter = " ".repeat(line_str.len());
let caret = caret_run(line_text, column, span_width_chars(src, span));
out.push('\n');
match label {
Some(label) => infallible_writeln!(out, "{gutter}--> {label}:{line}:{column}"),
None => infallible_writeln!(out, "{gutter}--> line {line}, column {column}"),
}
infallible_writeln!(out, "{gutter} |");
infallible_writeln!(out, "{line_str} | {line_text}");
infallible_write!(out, "{gutter} | {caret}");
out
}
#[cfg(test)]
mod tests {
use super::{
caret_run, format_diagnostic, line_at, line_col, mirror_source_prefix_as_pad,
span_width_chars, LineCol,
};
use crate::error::LispError;
use crate::reader::read;
use crate::span::Span;
#[test]
fn span_width_chars_counts_ascii_as_one_per_byte() {
assert_eq!(span_width_chars("(a (b c", Span::new(3, 7)), 4);
assert_eq!(span_width_chars("(a b)", Span::new(0, 1)), 1);
}
#[test]
fn span_width_chars_counts_chars_not_bytes() {
assert_eq!(span_width_chars("é", Span::new(0, 2)), 1);
assert_eq!(span_width_chars("(éé)", Span::new(1, 5)), 2);
}
#[test]
fn span_width_chars_is_zero_for_an_empty_range() {
assert_eq!(span_width_chars("(a b)", Span::new(2, 2)), 0);
}
#[test]
fn span_width_chars_degrades_to_one_off_char_boundary_or_past_eof() {
assert_eq!(span_width_chars("é", Span::new(0, 1)), 1);
assert_eq!(span_width_chars("abc", Span::new(0, 99)), 1);
assert_eq!(span_width_chars("abc", Span::new(3, 3)), 0);
}
#[test]
fn line_col_at_start_of_input() {
assert_eq!(line_col("abc", 0), LineCol { line: 1, column: 1 });
}
#[test]
fn line_col_advances_columns_on_first_line() {
assert_eq!(line_col("abc", 1), LineCol { line: 1, column: 2 });
assert_eq!(line_col("abc", 2), LineCol { line: 1, column: 3 });
}
#[test]
fn line_col_at_eof_is_one_past_last_char() {
assert_eq!(line_col("abc", 3), LineCol { line: 1, column: 4 });
}
#[test]
fn line_col_clamps_past_eof() {
assert_eq!(line_col("abc", 999), LineCol { line: 1, column: 4 });
assert_eq!(line_col("", 999), LineCol { line: 1, column: 1 });
}
#[test]
fn line_col_advances_line_after_newline() {
assert_eq!(line_col("a\nb", 0), LineCol { line: 1, column: 1 });
assert_eq!(line_col("a\nb", 1), LineCol { line: 1, column: 2 });
assert_eq!(line_col("a\nb", 2), LineCol { line: 2, column: 1 });
assert_eq!(line_col("a\nb", 3), LineCol { line: 2, column: 2 });
}
#[test]
fn line_col_counts_chars_not_bytes_for_multibyte() {
assert_eq!(line_col("é", 2), LineCol { line: 1, column: 2 });
assert_eq!(line_col("\né", 1), LineCol { line: 2, column: 1 });
assert_eq!(line_col("\né", 3), LineCol { line: 2, column: 2 });
}
#[test]
fn line_at_returns_the_containing_line_without_newline() {
let src = "alpha\nbeta\ngamma";
assert_eq!(line_at(src, 0), "alpha");
assert_eq!(line_at(src, 6), "beta"); assert_eq!(line_at(src, 11), "gamma"); assert_eq!(line_at(src, 16), "gamma"); }
#[test]
fn format_diagnostic_renders_unmatched_paren_with_caret_under_offending_byte() {
let src = " )";
let err = read(src).unwrap_err();
let rendered = format_diagnostic(src, &err, Some("x.lisp"));
let expected = "\
error: unmatched closing paren at position 3
--> x.lisp:1:4
|
1 | )
| ^";
assert_eq!(rendered, expected, "got:\n{rendered}");
}
#[test]
fn format_diagnostic_locates_paren_on_a_later_line() {
let src = "(a b)\n(c d)\n )\n";
let err = read(src).unwrap_err();
let rendered = format_diagnostic(src, &err, Some("nested.lisp"));
let expected = "\
error: unmatched closing paren at position 15
--> nested.lisp:3:4
|
3 | )
| ^";
assert_eq!(rendered, expected, "got:\n{rendered}");
}
#[test]
fn format_diagnostic_unmatched_open_underlines_the_unclosed_form() {
let src = "(a (b c";
let err = read(src).unwrap_err();
let rendered = format_diagnostic(src, &err, Some("open.lisp"));
let expected = "\
error: unmatched opening paren at position 3
--> open.lisp:1:4
|
1 | (a (b c
| ^^^^";
assert_eq!(rendered, expected, "got:\n{rendered}");
}
#[test]
fn format_diagnostic_unmatched_open_underline_stops_at_the_last_token() {
let src = "(a b ; tail";
let err = read(src).unwrap_err();
let rendered = format_diagnostic(src, &err, Some("trivia.lisp"));
let expected = "\
error: unmatched opening paren at position 0
--> trivia.lisp:1:1
|
1 | (a b ; tail
| ^^^^";
assert_eq!(rendered, expected, "got:\n{rendered}");
}
#[test]
fn format_diagnostic_unterminated_string_underlines_the_dangling_literal() {
let src = "(a \"bc";
let err = read(src).unwrap_err();
let rendered = format_diagnostic(src, &err, Some("str.lisp"));
let expected = "\
error: unterminated string literal at position 3
--> str.lisp:1:4
|
1 | (a \"bc
| ^^^";
assert_eq!(rendered, expected, "got:\n{rendered}");
}
#[test]
fn format_diagnostic_stray_close_paren_still_renders_exactly_one_caret() {
let src = "(a b) )";
let err = read(src).unwrap_err();
let rendered = format_diagnostic(src, &err, Some("stray.lisp"));
let expected = "\
error: unmatched closing paren at position 6
--> stray.lisp:1:7
|
1 | (a b) )
| ^";
assert_eq!(rendered, expected, "got:\n{rendered}");
}
#[test]
fn format_diagnostic_multiline_unclosed_form_overflows_its_line() {
let src = "(a\n b";
let err = read(src).unwrap_err();
let rendered = format_diagnostic(src, &err, Some("multi.lisp"));
let expected = "\
error: unmatched opening paren at position 0
--> multi.lisp:1:1
|
1 | (a
| ^^^^^";
assert_eq!(rendered, expected, "got:\n{rendered}");
}
#[test]
fn format_diagnostic_omits_label_when_none() {
let err = read(")").unwrap_err();
let rendered = format_diagnostic(")", &err, None);
let expected = "\
error: unmatched closing paren at position 0
--> line 1, column 1
|
1 | )
| ^";
assert_eq!(rendered, expected, "got:\n{rendered}");
}
#[test]
fn format_diagnostic_renders_eof_at_end_of_input() {
let src = "(a b) '";
let err = read(src).unwrap_err();
let rendered = format_diagnostic(src, &err, Some("dangle.lisp"));
let expected = "\
error: unexpected end of input at position 7
--> dangle.lisp:1:8
|
1 | (a b) '
| ^";
assert_eq!(rendered, expected, "got:\n{rendered}");
}
#[test]
fn infallible_writeln_macro_appends_formatted_line_with_trailing_newline() {
let mut out = String::new();
infallible_writeln!(out, "hello {x}", x = 42);
assert_eq!(out, "hello 42\n");
}
#[test]
fn infallible_write_macro_appends_formatted_text_without_newline() {
let mut out = String::new();
infallible_write!(out, "tail {y}", y = "value");
assert_eq!(out, "tail value");
}
#[test]
fn infallible_macros_preserve_format_diagnostic_byte_identity() {
let src = " )";
let err = read(src).unwrap_err();
let rendered = format_diagnostic(src, &err, Some("macros.lisp"));
assert!(rendered.starts_with("error: unmatched closing paren"));
assert!(rendered.contains("\n --> macros.lisp:1:4\n"));
assert!(rendered.ends_with("^"));
assert!(
!rendered.ends_with("^\n"),
"trailing caret line must NOT emit a newline (would drift consumer concat)"
);
}
#[test]
fn format_diagnostic_falls_back_to_single_line_for_positionless_errors() {
let err = LispError::Compile {
form: ":threshold".into(),
message: "expected number".into(),
};
let rendered = format_diagnostic("(defmonitor :threshold #t)", &err, Some("m.lisp"));
assert_eq!(
rendered,
"error: compile error in :threshold: expected number"
);
assert!(
!rendered.contains('\n'),
"single-line render must not introduce newlines"
);
assert!(
!rendered.contains('^'),
"no caret allowed without a position to point at"
);
}
#[test]
fn mirror_source_prefix_as_pad_at_column_one_is_empty() {
assert_eq!(mirror_source_prefix_as_pad("(a b)", 1), "");
assert_eq!(mirror_source_prefix_as_pad("(a b)", 0), "");
}
#[test]
fn mirror_source_prefix_as_pad_replaces_non_tab_chars_with_spaces() {
assert_eq!(mirror_source_prefix_as_pad(" )", 4), " ");
assert_eq!(mirror_source_prefix_as_pad("(a b c)", 5), " ");
assert_eq!(
mirror_source_prefix_as_pad("hello", 6),
" ",
"column past-last-char pads with spaces for every source char",
);
}
#[test]
fn mirror_source_prefix_as_pad_preserves_tabs_verbatim() {
assert_eq!(mirror_source_prefix_as_pad("\t)", 2), "\t");
assert_eq!(mirror_source_prefix_as_pad("\t\t)", 3), "\t\t");
}
#[test]
fn mirror_source_prefix_as_pad_mirrors_mixed_tab_and_space_prefix() {
assert_eq!(mirror_source_prefix_as_pad(" \t)", 4), " \t");
assert_eq!(mirror_source_prefix_as_pad("\t )", 4), "\t ");
assert_eq!(mirror_source_prefix_as_pad("\t (\t)", 5), "\t \t");
}
#[test]
fn mirror_source_prefix_as_pad_replaces_multibyte_chars_with_spaces() {
assert_eq!(mirror_source_prefix_as_pad("é)", 2), " ");
assert_eq!(mirror_source_prefix_as_pad("\téé)", 4), "\t ");
}
#[test]
fn mirror_source_prefix_as_pad_clamps_to_line_length_at_eof_column() {
assert_eq!(mirror_source_prefix_as_pad("(a b) '", 8), " ");
assert_eq!(mirror_source_prefix_as_pad("\tfoo", 5), "\t ");
}
#[test]
fn caret_run_width_one_reproduces_the_single_caret_pad() {
assert_eq!(caret_run(" )", 4, 1), " ^");
assert_eq!(caret_run("(a b)", 1, 1), "^");
}
#[test]
fn caret_run_underlines_a_multi_column_span() {
assert_eq!(caret_run("(+ x foo y)", 6, 3), " ^^^");
}
#[test]
fn caret_run_clamps_zero_width_up_to_one_caret() {
assert_eq!(caret_run("(a b)", 3, 0), " ^");
}
#[test]
fn caret_run_mirrors_tabs_under_a_multi_column_underline() {
assert_eq!(caret_run("\tfoo", 2, 3), "\t^^^");
assert_eq!(caret_run(" \tbar", 4, 3), " \t^^^");
}
#[test]
fn caret_run_counts_pad_in_chars_not_bytes_for_multibyte_prefix() {
assert_eq!(caret_run("é)", 2, 1), " ^");
assert_eq!(caret_run("ééx", 3, 1), " ^");
}
#[test]
fn format_diagnostic_caret_pad_mirrors_tab_indent_for_terminal_alignment() {
let src = "\t)";
let err = read(src).unwrap_err();
let rendered = format_diagnostic(src, &err, Some("tabby.lisp"));
let expected = "\
error: unmatched closing paren at position 1
--> tabby.lisp:1:2
|
1 | \t)
| \t^";
assert_eq!(rendered, expected, "got:\n{rendered}");
}
#[test]
fn format_diagnostic_caret_pad_mirrors_mixed_tab_space_indent() {
let src = " \t (a b";
let err = read(src).unwrap_err();
let rendered = format_diagnostic(src, &err, Some("mixed.lisp"));
let expected = "\
error: unmatched opening paren at position 3
--> mixed.lisp:1:4
|
1 | \t (a b
| \t ^^^^";
assert_eq!(rendered, expected, "got:\n{rendered}");
}
}