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:
2026-03-10 18:42:40 +01:00
parent a82b7e4633
commit 5bf4a494cb
7 changed files with 365 additions and 41 deletions

4
examples/invalid.flx Normal file
View File

@@ -0,0 +1,4 @@
fn main() {
let a : 12 = 3;
return -;
}

7
fluxc/Cargo.lock generated
View File

@@ -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"

View File

@@ -5,3 +5,4 @@ edition = "2024"
[dependencies]
unicode-xid = "0.2"
yansi = "1"

323
fluxc/src/diagnostics.rs Normal file
View 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
}

View File

@@ -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
]
);
}

View File

@@ -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:#?}");

View File

@@ -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,