254 lines
9.0 KiB
Rust
254 lines
9.0 KiB
Rust
//! Compiler diagnostic reporting with source-location context.
|
||
//!
|
||
//! This module provides [`Diagnostic`], a structured error/warning message that
|
||
//! can optionally include a source span and one or more labelled secondary
|
||
//! spans. Diagnostics are rendered to `stderr` in a rustc-inspired format:
|
||
//!
|
||
//! ```text
|
||
//! Error: undeclared variable `x`
|
||
//! --> src/main.bky:3:5
|
||
//! |
|
||
//! 3 | let y = x + 1;
|
||
//! | ^ undeclared variable
|
||
//! |
|
||
//! ```
|
||
use std::{fmt::Display, path::Path, process::exit};
|
||
|
||
use yansi::Paint;
|
||
|
||
use crate::token::Span;
|
||
|
||
/// The importance level of a [`Diagnostic`].
|
||
///
|
||
/// Variants are ordered from least to most severe so that `<` / `>` comparisons
|
||
/// work intuitively (e.g. `Severity::Warning < Severity::Error`).
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||
pub enum Severity {
|
||
/// Purely informational; never causes the compiler to stop.
|
||
Note,
|
||
/// Something suspicious that may or may not be a problem.
|
||
Warning,
|
||
/// A recoverable problem that prevents successful compilation.
|
||
Error,
|
||
/// An unrecoverable problem; the process will exit immediately after
|
||
/// reporting this diagnostic.
|
||
Critical,
|
||
}
|
||
|
||
impl Display for Severity {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
match self {
|
||
Severity::Note => write!(f, "{}", "Note".bold().cyan()),
|
||
Severity::Warning => write!(f, "{}", "Warning".bold().yellow()),
|
||
Severity::Error => write!(f, "{}", "Error".bold().red()),
|
||
Severity::Critical => write!(f, "{}", "Critical".bold().magenta()),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A single compiler message with optional source-location information.
|
||
///
|
||
/// Build a diagnostic with [`Diagnostic::new`], optionally attach a primary
|
||
/// source location via [`with_span`](Diagnostic::with_span), attach labelled
|
||
/// secondary locations via [`add_label`](Diagnostic::add_label), then call
|
||
/// [`report`](Diagnostic::report) to print it.
|
||
///
|
||
/// If the severity is [`Severity::Critical`], `report` will call
|
||
/// [`process::exit`](std::process::exit) after printing.
|
||
pub struct Diagnostic {
|
||
pub severity: Severity,
|
||
/// Primary source location, if any.
|
||
pub span: Option<Span>,
|
||
pub message: String,
|
||
/// Secondary labelled spans rendered below the primary snippet.
|
||
pub labels: Vec<(Span, String)>,
|
||
}
|
||
|
||
impl Diagnostic {
|
||
/// Create a new diagnostic with the given severity and message.
|
||
///
|
||
/// No source location is attached; use [`with_span`](Self::with_span) to
|
||
/// add one.
|
||
pub fn new(severity: Severity, message: impl ToString) -> Self {
|
||
Self {
|
||
severity,
|
||
span: None,
|
||
message: message.to_string(),
|
||
labels: Vec::new(),
|
||
}
|
||
}
|
||
|
||
/// Attach a primary source span to this diagnostic.
|
||
pub fn with_span(mut self, span: Span) -> Self {
|
||
self.span = Some(span);
|
||
self
|
||
}
|
||
|
||
/// Attach a labelled secondary span.
|
||
///
|
||
/// Labels whose span matches the primary span exactly are merged into the
|
||
/// primary underline as inline text. All other labels are rendered as
|
||
/// separate snippets below the primary one.
|
||
pub fn add_label(mut self, span: Span, message: impl ToString) -> Self {
|
||
self.labels.push((span, message.to_string()));
|
||
self
|
||
}
|
||
|
||
/// Print this diagnostic to `stderr` and, if the severity is
|
||
/// [`Severity::Critical`], terminate the process.
|
||
///
|
||
/// # Arguments
|
||
/// * `file_name` – path shown in the `-->` location line.
|
||
/// * `source` – full source text of the file, used to extract line/col
|
||
/// information and to display the relevant source snippet.
|
||
pub fn report(self, file_name: &Path, source: &str) {
|
||
eprintln!("{}: {}", self.severity, self.message.bold());
|
||
|
||
let Some(primary_span) = self.span else {
|
||
eprintln!(" {} {}", "-->".bright_black(), file_name.display());
|
||
if self.severity == Severity::Critical {
|
||
exit(-1);
|
||
}
|
||
return;
|
||
};
|
||
|
||
// Guard: no source context available (e.g. critical error before any
|
||
// file is read).
|
||
if source.is_empty() || primary_span.start as usize >= source.len() {
|
||
eprintln!(" {} {}", "-->".bright_black(), file_name.display());
|
||
if self.severity == Severity::Critical {
|
||
exit(-1);
|
||
}
|
||
return;
|
||
}
|
||
|
||
let (primary_line, primary_col) = get_line_col(source, primary_span.start);
|
||
|
||
// Partition labels: those on the *exact same span* as the primary are
|
||
// merged into the primary underline as inline text. All others are
|
||
// rendered as separate snippets below the primary.
|
||
let (same_span, other_span): (Vec<_>, Vec<_>) = self
|
||
.labels
|
||
.into_iter()
|
||
.partition(|(s, _)| *s == primary_span);
|
||
|
||
let primary_label: Option<String> = same_span.into_iter().next().map(|(_, m)| m);
|
||
|
||
// Gutter must be wide enough for the highest line number we'll print.
|
||
let max_line = other_span
|
||
.iter()
|
||
.filter(|(s, _)| (s.start as usize) < source.len())
|
||
.map(|(s, _)| get_line_col(source, s.start).0)
|
||
.fold(primary_line, usize::max);
|
||
let gutter_w = count_digits(max_line);
|
||
let pad = " ".repeat(gutter_w);
|
||
|
||
// " --> file:line:col"
|
||
eprintln!(
|
||
"{} {}:{}:{}",
|
||
format!("{pad} -->").bright_black(),
|
||
file_name.display(),
|
||
primary_line,
|
||
primary_col,
|
||
);
|
||
eprintln!("{}", format!("{pad} |").bright_black());
|
||
|
||
// Primary snippet.
|
||
render_snippet(
|
||
source,
|
||
primary_span,
|
||
primary_label.as_deref(),
|
||
gutter_w,
|
||
self.severity,
|
||
);
|
||
|
||
// Additional-context labels (different locations).
|
||
for (span, msg) in &other_span {
|
||
if (span.start as usize) < source.len() {
|
||
render_snippet(source, *span, Some(msg.as_str()), gutter_w, Severity::Note);
|
||
}
|
||
}
|
||
|
||
eprintln!("{}", format!("{pad} |").bright_black());
|
||
|
||
if self.severity == Severity::Critical {
|
||
exit(-1);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Render a single source-line snippet: the numbered source line followed by
|
||
/// a `^^^` underline. When `label` is `Some`, the text is appended after the
|
||
/// carets on the same line.
|
||
fn render_snippet(
|
||
source: &str,
|
||
span: Span,
|
||
label: Option<&str>,
|
||
gutter_w: usize,
|
||
severity: Severity,
|
||
) {
|
||
let (line_num, _) = get_line_col(source, span.start);
|
||
let (line_start, line_content) = get_line_content(source, span.start);
|
||
|
||
let pad = " ".repeat(gutter_w);
|
||
let bar = format!("{}", "|".bright_black());
|
||
let line_num_str = format!("{:>width$}", line_num, width = gutter_w);
|
||
|
||
// "N | source text"
|
||
eprintln!("{} {bar} {line_content}", line_num_str.bright_black());
|
||
|
||
// Caret underline, clamped to the current line.
|
||
let col_offset = span.start as usize - line_start;
|
||
let line_end_byte = line_start + line_content.len();
|
||
let underline_len = (span.end as usize)
|
||
.min(line_end_byte)
|
||
.saturating_sub(span.start as usize)
|
||
.max(1);
|
||
|
||
let spaces = " ".repeat(col_offset);
|
||
let carets = "^".repeat(underline_len);
|
||
let colored_carets = paint_severity(&carets, severity);
|
||
let label_text = label
|
||
.map(|l| format!(" {}", paint_severity(l, severity)))
|
||
.unwrap_or_default();
|
||
|
||
// " | ^^^label"
|
||
eprintln!("{pad} {bar} {spaces}{colored_carets}{label_text}");
|
||
}
|
||
|
||
/// Apply severity-appropriate ANSI colour to a string.
|
||
fn paint_severity(s: &str, severity: Severity) -> String {
|
||
match severity {
|
||
Severity::Note => format!("{}", s.bold().bright_cyan()),
|
||
Severity::Warning => format!("{}", s.bold().bright_yellow()),
|
||
Severity::Error | Severity::Critical => format!("{}", s.bold().bright_red()),
|
||
}
|
||
}
|
||
|
||
/// Returns the number of decimal digits in `n` (minimum 1).
|
||
fn count_digits(n: usize) -> usize {
|
||
format!("{n}").len()
|
||
}
|
||
|
||
/// Returns `(line_start_byte, line_content)` for the line that contains
|
||
/// `position`. The returned content does *not* include the trailing newline.
|
||
fn get_line_content(source: &str, position: u32) -> (usize, &str) {
|
||
let pos = position as usize;
|
||
let line_start = source[..pos].rfind('\n').map(|i| i + 1).unwrap_or(0);
|
||
let rest = &source[line_start..];
|
||
let line_len = rest.find('\n').unwrap_or(rest.len());
|
||
(line_start, &rest[..line_len])
|
||
}
|
||
|
||
/// Returns the 1-based `(line, column)` for a byte `position` within `source`.
|
||
///
|
||
/// Both line and column are counted from 1. The column is measured in Unicode
|
||
/// scalar values (characters), not bytes.
|
||
fn get_line_col(source: &str, position: u32) -> (usize, usize) {
|
||
let prefix = &source[..position as usize];
|
||
let line = prefix.bytes().filter(|&b| b == b'\n').count() + 1;
|
||
let line_start_byte = prefix.rfind('\n').map(|i| i + 1).unwrap_or(0);
|
||
let col = prefix[line_start_byte..].chars().count() + 1;
|
||
(line, col)
|
||
}
|