feat: Add parsing for expressions.

This commit adds support for parsing expression using the pratt parsing
approach.
This commit is contained in:
2026-03-12 12:14:00 +01:00
parent 9ac8a79151
commit 93f08d1944
5 changed files with 632 additions and 3 deletions

196
src/diagnostic.rs Normal file
View File

@@ -0,0 +1,196 @@
use std::{fmt::Display, path::Path, process::exit};
use yansi::Paint;
use crate::token::Span;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Severity {
Note,
Warning,
Error,
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()),
}
}
}
pub struct Diagnostic {
pub severity: Severity,
pub span: Option<Span>,
pub message: String,
pub labels: Vec<(Span, String)>,
}
impl Diagnostic {
pub fn new(severity: Severity, message: impl ToString) -> Self {
Self {
severity,
span: None,
message: message.to_string(),
labels: Vec::new(),
}
}
pub fn with_span(mut self, span: Span) -> Self {
self.span = Some(span);
self
}
pub fn add_label(mut self, span: Span, message: impl ToString) -> Self {
self.labels.push((span, message.to_string()));
self
}
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}");
}
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()),
}
}
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])
}
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)
}