//! 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, 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 = 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) }