Feat: add structured diagnostics with yansi colors
Introduces fluxc/src/diagnostics.rs with Level (Critical, Error, Warning, Note), Label (Primary/Secondary with optional message), and Diagnostic types. Diagnostic::render(src, filename) produces rustc-style output: colored header, --> file:line:col pointer, source line with gutter, and ^ / - underlines aligned to the offending span. Replaces the flat ParseError struct in the parser; all five error sites now emit Diagnostic values with source-pointing labels.
This commit is contained in:
7
fluxc/Cargo.lock
generated
7
fluxc/Cargo.lock
generated
@@ -7,6 +7,7 @@ name = "fluxc"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"unicode-xid",
|
||||
"yansi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -14,3 +15,9 @@ name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "yansi"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
|
||||
|
||||
@@ -5,3 +5,4 @@ edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
unicode-xid = "0.2"
|
||||
yansi = "1"
|
||||
|
||||
323
fluxc/src/diagnostics.rs
Normal file
323
fluxc/src/diagnostics.rs
Normal file
@@ -0,0 +1,323 @@
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use yansi::{Color, Paint as _};
|
||||
|
||||
use crate::token::Span;
|
||||
|
||||
// ── Level ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Level {
|
||||
Critical,
|
||||
Error,
|
||||
Warning,
|
||||
Note,
|
||||
}
|
||||
|
||||
impl Level {
|
||||
fn label_str(self) -> &'static str {
|
||||
match self {
|
||||
Level::Critical => "critical",
|
||||
Level::Error => "error",
|
||||
Level::Warning => "warning",
|
||||
Level::Note => "note",
|
||||
}
|
||||
}
|
||||
|
||||
fn color(self) -> Color {
|
||||
match self {
|
||||
Level::Critical => Color::Magenta,
|
||||
Level::Error => Color::Red,
|
||||
Level::Warning => Color::Yellow,
|
||||
Level::Note => Color::Cyan,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Label ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LabelStyle {
|
||||
Primary,
|
||||
Secondary,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Label {
|
||||
pub span: Span,
|
||||
pub message: Option<String>,
|
||||
pub style: LabelStyle,
|
||||
}
|
||||
|
||||
impl Label {
|
||||
pub fn primary(span: Span) -> Self {
|
||||
Self {
|
||||
span,
|
||||
message: None,
|
||||
style: LabelStyle::Primary,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn secondary(span: Span) -> Self {
|
||||
Self {
|
||||
span,
|
||||
message: None,
|
||||
style: LabelStyle::Secondary,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_message(mut self, msg: impl Into<String>) -> Self {
|
||||
self.message = Some(msg.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
// ── Diagnostic ─────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Diagnostic {
|
||||
pub level: Level,
|
||||
pub message: String,
|
||||
pub labels: Vec<Label>,
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
|
||||
impl Diagnostic {
|
||||
pub fn new(level: Level, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
level,
|
||||
message: message.into(),
|
||||
labels: Vec::new(),
|
||||
notes: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error(message: impl Into<String>) -> Self {
|
||||
Self::new(Level::Error, message)
|
||||
}
|
||||
|
||||
pub fn warning(message: impl Into<String>) -> Self {
|
||||
Self::new(Level::Warning, message)
|
||||
}
|
||||
|
||||
pub fn note(message: impl Into<String>) -> Self {
|
||||
Self::new(Level::Note, message)
|
||||
}
|
||||
|
||||
pub fn critical(message: impl Into<String>) -> Self {
|
||||
Self::new(Level::Critical, message)
|
||||
}
|
||||
|
||||
pub fn with_label(mut self, label: Label) -> Self {
|
||||
self.labels.push(label);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_note(mut self, note: impl Into<String>) -> Self {
|
||||
self.notes.push(note.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Render this diagnostic to a string.
|
||||
///
|
||||
/// `src` is the full source text; `filename` is used in the `-->` pointer.
|
||||
pub fn render(&self, src: &str, filename: &str) -> String {
|
||||
let color = self.level.color();
|
||||
let mut out = String::new();
|
||||
|
||||
// ── Header: `error: message` ──────────────────────────────────────
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"{}: {}",
|
||||
self.level.label_str().fg(color).bold(),
|
||||
self.message.as_str().bold(),
|
||||
);
|
||||
|
||||
if self.labels.is_empty() {
|
||||
for note in &self.notes {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" {} {}: {}",
|
||||
"=".bold().blue(),
|
||||
"note".bold().cyan(),
|
||||
note
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── `-->` file pointer ────────────────────────────────────────────
|
||||
let primary = self
|
||||
.labels
|
||||
.iter()
|
||||
.find(|l| l.style == LabelStyle::Primary)
|
||||
.or_else(|| self.labels.first())
|
||||
.unwrap();
|
||||
let (p_line, p_col) = offset_to_line_col(src, primary.span.start);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" {} {}:{}:{}",
|
||||
"-->".bold().blue(),
|
||||
filename,
|
||||
p_line,
|
||||
p_col,
|
||||
);
|
||||
|
||||
// ── Compute gutter width from max line number ─────────────────────
|
||||
let max_line = self
|
||||
.labels
|
||||
.iter()
|
||||
.map(|l| offset_to_line_col(src, l.span.start).0)
|
||||
.max()
|
||||
.unwrap_or(1);
|
||||
let gutter_w = count_digits(max_line);
|
||||
let blank = " ".repeat(gutter_w);
|
||||
|
||||
let _ = writeln!(out, "{blank} {}", "|".bold().blue());
|
||||
|
||||
// ── Group labels by line, sorted by line number ───────────────────
|
||||
let mut by_line: Vec<(usize, &Label)> = self
|
||||
.labels
|
||||
.iter()
|
||||
.map(|l| (offset_to_line_col(src, l.span.start).0, l))
|
||||
.collect();
|
||||
by_line.sort_by_key(|&(ln, _)| ln);
|
||||
|
||||
let mut i = 0;
|
||||
while i < by_line.len() {
|
||||
let line_num = by_line[i].0;
|
||||
|
||||
let group_len = by_line[i..]
|
||||
.iter()
|
||||
.take_while(|(ln, _)| *ln == line_num)
|
||||
.count();
|
||||
let group = &by_line[i..i + group_len];
|
||||
|
||||
// Source line
|
||||
let line_text = source_line(src, line_num);
|
||||
let num_str = format!("{:>gutter_w$}", line_num);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"{} {} {}",
|
||||
num_str.as_str().bold().blue(),
|
||||
"|".bold().blue(),
|
||||
line_text,
|
||||
);
|
||||
|
||||
// Underlines for each label on this line, sorted left-to-right
|
||||
let mut underlines: Vec<(usize, usize, &Label)> = group
|
||||
.iter()
|
||||
.map(|(_, label)| {
|
||||
let (_, col) = offset_to_line_col(src, label.span.start);
|
||||
let width = underline_width(src, label, line_num);
|
||||
(col, width, *label)
|
||||
})
|
||||
.collect();
|
||||
underlines.sort_by_key(|&(col, _, _)| col);
|
||||
|
||||
for (col, width, label) in underlines {
|
||||
let pad = " ".repeat(col.saturating_sub(1));
|
||||
let (ch, uc) = match label.style {
|
||||
LabelStyle::Primary => ('^', color),
|
||||
LabelStyle::Secondary => ('-', Color::Blue),
|
||||
};
|
||||
let underline = ch.to_string().repeat(width.max(1));
|
||||
if let Some(msg) = &label.message {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"{blank} {} {pad}{} {}",
|
||||
"|".bold().blue(),
|
||||
underline.as_str().fg(uc).bold(),
|
||||
msg.as_str().fg(uc).bold(),
|
||||
);
|
||||
} else {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"{blank} {} {pad}{}",
|
||||
"|".bold().blue(),
|
||||
underline.as_str().fg(uc).bold(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
i += group_len;
|
||||
}
|
||||
|
||||
let _ = writeln!(out, "{blank} {}", "|".bold().blue());
|
||||
|
||||
// ── Notes ─────────────────────────────────────────────────────────
|
||||
for note in &self.notes {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"{blank} {} {}: {}",
|
||||
"=".bold().blue(),
|
||||
"note".bold().cyan(),
|
||||
note,
|
||||
);
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Convert a byte offset into `src` to a `(line, col)` pair (both 1-indexed).
|
||||
fn offset_to_line_col(src: &str, offset: u32) -> (usize, usize) {
|
||||
let offset = (offset as usize).min(src.len());
|
||||
let mut line = 1usize;
|
||||
let mut line_start = 0usize;
|
||||
for (i, c) in src.char_indices() {
|
||||
if i >= offset {
|
||||
break;
|
||||
}
|
||||
if c == '\n' {
|
||||
line += 1;
|
||||
line_start = i + 1; // '\n' is always 1 byte
|
||||
}
|
||||
}
|
||||
let col = src[line_start..offset].chars().count() + 1;
|
||||
(line, col)
|
||||
}
|
||||
|
||||
/// Return the source text for a given 1-indexed line number.
|
||||
fn source_line(src: &str, line_num: usize) -> &str {
|
||||
src.lines()
|
||||
.nth(line_num - 1)
|
||||
.unwrap_or("")
|
||||
.trim_end_matches('\r')
|
||||
}
|
||||
|
||||
/// Compute how many `^` characters should underline `label` on `line_num`.
|
||||
fn underline_width(src: &str, label: &Label, line_num: usize) -> usize {
|
||||
if label.span.end <= label.span.start {
|
||||
return 1; // zero-width span: show at least one caret
|
||||
}
|
||||
let (start_line, start_col) = offset_to_line_col(src, label.span.start);
|
||||
let (end_line, end_col) = offset_to_line_col(src, label.span.end);
|
||||
if start_line == end_line {
|
||||
end_col.saturating_sub(start_col).max(1)
|
||||
} else if line_num == start_line {
|
||||
// Multiline span: underline to end of the first line.
|
||||
let line_text = source_line(src, line_num);
|
||||
line_text
|
||||
.chars()
|
||||
.count()
|
||||
.saturating_sub(start_col - 1)
|
||||
.max(1)
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
fn count_digits(mut n: usize) -> usize {
|
||||
if n == 0 {
|
||||
return 1;
|
||||
}
|
||||
let mut count = 0;
|
||||
while n > 0 {
|
||||
n /= 10;
|
||||
count += 1;
|
||||
}
|
||||
count
|
||||
}
|
||||
@@ -588,8 +588,8 @@ mod tests {
|
||||
assert_eq!(
|
||||
kinds(src),
|
||||
vec![
|
||||
PlusEq, MinusEq, StarEq, SlashEq, PercentEq, AmpEq, PipeEq, CaretEq, ShlEq,
|
||||
ShrEq, Eof
|
||||
PlusEq, MinusEq, StarEq, SlashEq, PercentEq, AmpEq, PipeEq, CaretEq, ShlEq, ShrEq,
|
||||
Eof
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::{env::args, fs};
|
||||
use crate::parser::Parser;
|
||||
|
||||
pub mod ast;
|
||||
pub mod diagnostics;
|
||||
pub mod lexer;
|
||||
pub mod parser;
|
||||
pub mod token;
|
||||
@@ -17,8 +18,8 @@ fn main() {
|
||||
let mut parser = Parser::new(&content);
|
||||
let program = parser.parse_program();
|
||||
|
||||
for err in &parser.errors {
|
||||
eprintln!("parse error: {err}");
|
||||
for diag in &parser.errors {
|
||||
eprint!("{}", diag.render(&content, &path));
|
||||
}
|
||||
|
||||
println!("{program:#?}");
|
||||
|
||||
@@ -1,29 +1,14 @@
|
||||
use std::fmt;
|
||||
|
||||
use crate::{
|
||||
ast::{
|
||||
BinaryOp, Block, CompoundAssignOp, ElseBranch, Expr, ExprKind, FieldDef, FuncDef, Param,
|
||||
Program, Stmt, StmtKind, StructDef, StructField, TopLevelDef, TopLevelDefKind, Type,
|
||||
UnaryOp,
|
||||
},
|
||||
diagnostics::{Diagnostic, Label},
|
||||
lexer::Lexer,
|
||||
token::{Span, Token, TokenKind},
|
||||
};
|
||||
|
||||
// ── Parse error ───────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ParseError {
|
||||
pub span: Span,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl fmt::Display for ParseError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "error at {}: {}", self.span, self.message)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Binding powers ─────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Returns `(left_bp, right_bp)` for infix operators.
|
||||
@@ -139,7 +124,7 @@ fn token_to_binary_op(kind: TokenKind) -> BinaryOp {
|
||||
pub struct Parser<'src> {
|
||||
tokens: Vec<Token<'src>>,
|
||||
pos: usize,
|
||||
pub errors: Vec<ParseError>,
|
||||
pub errors: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
impl<'src> Parser<'src> {
|
||||
@@ -175,11 +160,12 @@ impl<'src> Parser<'src> {
|
||||
if tok.kind == kind {
|
||||
self.advance()
|
||||
} else {
|
||||
self.errors.push(
|
||||
Diagnostic::error(format!("expected {kind}, found {}", tok.kind)).with_label(
|
||||
Label::primary(tok.span).with_message(format!("expected {kind} here")),
|
||||
),
|
||||
);
|
||||
let span = Span::new(tok.span.start, tok.span.start);
|
||||
self.errors.push(ParseError {
|
||||
span,
|
||||
message: format!("expected {}, found {}", kind, tok.kind),
|
||||
});
|
||||
Token::new(kind, span, "")
|
||||
}
|
||||
}
|
||||
@@ -261,10 +247,10 @@ impl<'src> Parser<'src> {
|
||||
|
||||
// Error — insert recovery placeholder
|
||||
_ => {
|
||||
self.errors.push(ParseError {
|
||||
span: tok.span,
|
||||
message: format!("expected type, found {}", tok.kind),
|
||||
});
|
||||
self.errors.push(
|
||||
Diagnostic::error(format!("expected type, found {}", tok.kind))
|
||||
.with_label(Label::primary(tok.span).with_message("expected a type here")),
|
||||
);
|
||||
Type::Error
|
||||
}
|
||||
}
|
||||
@@ -339,10 +325,10 @@ impl<'src> Parser<'src> {
|
||||
| TokenKind::Fn
|
||||
| TokenKind::Struct
|
||||
| TokenKind::Eof => {
|
||||
self.errors.push(ParseError {
|
||||
span: tok.span,
|
||||
message: format!("unexpected {} in statement position", tok.kind),
|
||||
});
|
||||
self.errors.push(
|
||||
Diagnostic::error(format!("unexpected {} in statement position", tok.kind))
|
||||
.with_label(Label::primary(tok.span)),
|
||||
);
|
||||
self.synchronize();
|
||||
Stmt {
|
||||
kind: StmtKind::Error,
|
||||
@@ -553,10 +539,10 @@ impl<'src> Parser<'src> {
|
||||
|
||||
// Error recovery
|
||||
_ => {
|
||||
self.errors.push(ParseError {
|
||||
span: tok.span,
|
||||
message: format!("unexpected token {} in expression", tok.kind),
|
||||
});
|
||||
self.errors.push(
|
||||
Diagnostic::error(format!("unexpected token {} in expression", tok.kind))
|
||||
.with_label(Label::primary(tok.span)),
|
||||
);
|
||||
Expr::new(ExprKind::Error, tok.span)
|
||||
}
|
||||
}
|
||||
@@ -730,10 +716,12 @@ impl<'src> Parser<'src> {
|
||||
TokenKind::Fn => self.parse_func_def(),
|
||||
TokenKind::Struct => self.parse_struct_def(),
|
||||
_ => {
|
||||
self.errors.push(ParseError {
|
||||
span: tok.span,
|
||||
message: format!("expected `fn` or `struct`, found {}", tok.kind),
|
||||
});
|
||||
self.errors.push(
|
||||
Diagnostic::error(format!("expected `fn` or `struct`, found {}", tok.kind))
|
||||
.with_label(
|
||||
Label::primary(tok.span).with_message("expected `fn` or `struct`"),
|
||||
),
|
||||
);
|
||||
self.synchronize_top_level();
|
||||
TopLevelDef {
|
||||
kind: TopLevelDefKind::Error,
|
||||
|
||||
Reference in New Issue
Block a user