use crate::error::{ErrorKind, ParseError};
use crate::types::Span;
use std::fmt::Write;
use super::context::ErrorContext;
struct Mark {
span: Span,
glyph: char,
label: Option<String>,
}
pub struct ErrorFormatter<'a> {
context: &'a ErrorContext<'a>,
}
impl<'a> ErrorFormatter<'a> {
#[must_use]
pub const fn new(context: &'a ErrorContext<'a>) -> Self {
Self { context }
}
#[must_use]
pub fn format(&self, error: &ParseError) -> String {
let mut output = String::new();
let marks = Self::collect_marks(error);
let lines = self.lines_to_show(error, &marks);
let line_num_width = lines.last().map_or(1, |l| l + 1).to_string().len().max(2);
self.format_header(error, line_num_width, &mut output);
self.format_snippet(&marks, &lines, line_num_width, &mut output);
Self::format_notes(error, line_num_width, &mut output);
output
}
fn collect_marks(error: &ParseError) -> Vec<Mark> {
let mut marks = vec![Mark {
span: error.span,
glyph: '^',
label: None,
}];
if let ErrorKind::UnclosedDelimiter { opening_span, .. } = &error.kind {
marks.push(Mark {
span: *opening_span,
glyph: '-',
label: Some("unclosed delimiter opened here".to_string()),
});
}
marks.sort_by_key(|m| m.span.start);
marks
}
fn lines_to_show(&self, error: &ParseError, marks: &[Mark]) -> Vec<usize> {
let last_line = self.context.source.lines().count().saturating_sub(1);
let primary = self.context.position(error.span.start).line - 1;
let mut lines: Vec<usize> = marks
.iter()
.map(|m| self.context.position(m.span.start).line - 1)
.chain([primary.saturating_sub(1), (primary + 1).min(last_line)])
.collect();
lines.sort_unstable();
lines.dedup();
lines
}
fn format_header(&self, error: &ParseError, line_num_width: usize, out: &mut String) {
let pos = self.context.position(error.span.start);
write!(out, "Error").unwrap();
if let Some(code) = error.code() {
write!(out, "[{code}]").unwrap();
}
writeln!(out, ": {}", error.message()).unwrap();
write!(out, "{:>width$} ", "", width = line_num_width).unwrap();
let col = pos.column + 1; if let Some(filename) = self.context.filename {
writeln!(out, "┌─ {}:{}:{}", filename, pos.line, col).unwrap();
} else {
writeln!(out, "┌─ line {}:{}", pos.line, col).unwrap();
}
}
fn format_snippet(
&self,
marks: &[Mark],
lines: &[usize],
line_num_width: usize,
out: &mut String,
) {
writeln!(out, "{:>line_num_width$} │", "").unwrap();
let mut prev: Option<usize> = None;
for &line_idx in lines {
if prev.is_some_and(|p| line_idx > p + 1) {
writeln!(out, "{:>line_num_width$} ·", "").unwrap();
}
prev = Some(line_idx);
let line_start = self.context.line_start(line_idx);
let (line_num, line_text) = self.context.line_at(line_start);
writeln!(out, "{line_num:>line_num_width$} │ {line_text}").unwrap();
for mark in marks {
if self.context.position(mark.span.start).line - 1 != line_idx {
continue;
}
let (col, len) = visual_span(line_text, line_start, mark.span);
write!(out, "{:>line_num_width$} │ {:col$}", "", "").unwrap();
for _ in 0..len {
out.push(mark.glyph);
}
if let Some(label) = &mark.label {
write!(out, " {label}").unwrap();
}
writeln!(out).unwrap();
}
}
}
fn format_notes(error: &ParseError, line_num_width: usize, out: &mut String) {
let indent = " ".repeat(line_num_width + 1);
match &error.kind {
ErrorKind::UnexpectedToken { expected, found } => {
if let [single] = expected.as_slice()
&& let Some((note, help)) = unexpected_token_hint(single, found)
{
writeln!(out, "{indent}= note: {note}").unwrap();
writeln!(out, "{indent}= help: {help}").unwrap();
}
}
ErrorKind::InvalidSyntax {
hint: Some(hint), ..
} => {
writeln!(out, "{indent}= help: {hint}").unwrap();
}
ErrorKind::UnclosedDelimiter { delimiter, .. } => {
let (_, close_tok) = Self::delimiter_pair(*delimiter);
writeln!(out, "{indent}= help: add closing {close_tok}").unwrap();
}
ErrorKind::ChainedComparison { .. } => {
writeln!(
out,
"{indent}= note: comparison operators cannot be chained in Nix"
)
.unwrap();
writeln!(out, "{indent}= help: use parentheses: (a < b) && (b < c)").unwrap();
}
ErrorKind::MissingToken { token, after } => {
writeln!(out, "{indent}= note: {token} is required after {after}").unwrap();
}
ErrorKind::InvalidSyntax { hint: None, .. } => {}
}
}
}
fn visual_span(line_text: &str, line_start: usize, span: Span) -> (usize, usize) {
let byte_col = (span.start as usize).saturating_sub(line_start);
let byte_len = (span.end - span.start).max(1) as usize;
let clamped_col = byte_col.min(line_text.len());
let col = line_text[..clamped_col].chars().count();
let len = line_text[clamped_col..]
.chars()
.take(byte_len)
.count()
.max(1);
(col, len)
}
fn unexpected_token_hint(expected: &str, found: &str) -> Option<(&'static str, &'static str)> {
Some(match expected {
"';'" => (
"missing semicolon after definition",
"add a semicolon at the end of the previous line",
),
"'}'" if found == "'in'" => (
"'in' is only valid inside 'let ... in ...' expressions",
"did you mean to start with 'let' instead of '{'?",
),
"'&&'" => (
"single '&' is not a valid operator in Nix",
"did you mean '&&' (logical and)?",
),
"'then'" => (
"if expressions require: if <condition> then <expr> else <expr>",
"add 'then' after the condition",
),
"'else'" => (
"if expressions require: if <condition> then <expr> else <expr>",
"add 'else' followed by the alternative expression",
),
"'in'" => (
"'in' is required to complete the let expression",
"add 'in' followed by the expression body",
),
"'='" => (
"attribute paths must be followed by '= <value>;'",
"add '= ...' to assign a value",
),
_ => return None,
})
}
impl ErrorFormatter<'_> {
const fn delimiter_pair(opening: char) -> (&'static str, &'static str) {
match opening {
'{' => ("'{'", "'}'"),
'[' => ("'['", "']'"),
'(' => ("'('", "')'"),
'\'' => ("''", "''"),
'"' => ("'\"'", "'\"'"),
_ => ("delimiter", "delimiter"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parse;
fn render(src: &str) -> String {
let err = parse(src).unwrap_err();
let ctx = ErrorContext::new(src, Some("t.nix"));
ErrorFormatter::new(&ctx).format(&err)
}
#[test]
fn unclosed_delimiter_shows_opener_in_snippet() {
let out = render("(1 + 2");
assert!(out.contains("t.nix:1:7"), "1-based column: {out}");
assert!(
out.contains("- unclosed delimiter opened here"),
"secondary mark: {out}"
);
}
#[test]
fn unclosed_opener_on_other_line_is_rendered() {
let out = render("{\n x = 1;\n");
assert!(out.contains("1 │ {"), "opener line shown: {out}");
assert!(out.contains("opened here"), "{out}");
}
#[test]
fn indented_string_delimiter_rendered_as_double_quote() {
let out = render("''\nhello\n");
assert!(out.contains("unclosed indented string"), "{out}");
assert!(!out.contains("'''"), "must not render triple quote: {out}");
}
}