diagnostics.rs raw

//! Diagnostics with source excerpts.
//!
//! The compiler reports every error with the span it applies to; rendering
//! turns spans into `file:line:col` plus a caret-underlined excerpt, in the
//! style Rust programmers already read fluently.

use std::fmt;

use crate::sexpr::Span;

#[derive(Debug, Clone, PartialEq)]
pub struct Diagnostic {
    pub message: String,
    pub span: Span,
}

impl Diagnostic {
    pub fn new(message: impl Into<String>, span: Span) -> Self {
        Self {
            message: message.into(),
            span,
        }
    }
}

impl fmt::Display for Diagnostic {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.message)
    }
}

/// Render diagnostics against their source, one block per diagnostic.
pub fn render(diagnostics: &[Diagnostic], source: &str, filename: &str) -> String {
    let mut out = String::new();
    for (i, diag) in diagnostics.iter().enumerate() {
        if i > 0 {
            out.push('\n');
        }
        render_one(&mut out, diag, source, filename);
    }
    out
}

fn render_one(out: &mut String, diag: &Diagnostic, source: &str, filename: &str) {
    let (line_no, col, line_text, line_start) = locate(source, diag.span.start);
    let gutter = line_no.to_string();
    let pad = " ".repeat(gutter.len());
    out.push_str(&format!("error: {}\n", diag.message));
    out.push_str(&format!("  --> {filename}:{line_no}:{col}\n"));
    out.push_str(&format!("{pad} |\n"));
    out.push_str(&format!("{gutter} | {line_text}\n"));
    // Caret run covering the span's portion of this line.
    let caret_start = diag.span.start - line_start;
    let caret_end = (diag.span.end - line_start)
        .min(line_text.len())
        .max(caret_start + 1);
    let mut carets = String::new();
    for (idx, _) in line_text.char_indices() {
        if idx < caret_start {
            carets.push(' ');
        } else if idx < caret_end {
            carets.push('^');
        }
    }
    if carets.trim().is_empty() {
        carets = format!("{}^", " ".repeat(caret_start.min(line_text.len())));
    }
    out.push_str(&format!("{pad} | {carets}\n"));
}

/// 1-based line, 1-based column, the line's text, and the line's byte start.
fn locate(source: &str, offset: usize) -> (usize, usize, &str, usize) {
    let offset = offset.min(source.len());
    let line_start = source[..offset].rfind('\n').map(|i| i + 1).unwrap_or(0);
    let line_no = source[..line_start].matches('\n').count() + 1;
    let line_end = source[line_start..]
        .find('\n')
        .map(|i| line_start + i)
        .unwrap_or(source.len());
    let col = source[line_start..offset].chars().count() + 1;
    (line_no, col, &source[line_start..line_end], line_start)
}