Files
bucky/src/diagnostic.rs

254 lines
9.0 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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)
}