dreamstack/compiler/ds-diagnostic/src/lib.rs

417 lines
13 KiB
Rust

/// DreamStack Diagnostic — unified error/warning type shared across compiler crates.
///
/// Provides Elm-style error rendering with carets, multi-span labels, and suggestions.
use ds_parser::Span;
// ── Core Types ──────────────────────────────────────────
/// A compiler diagnostic — error, warning, or hint.
#[derive(Debug, Clone)]
pub struct Diagnostic {
pub severity: Severity,
pub code: Option<String>,
pub message: String,
pub span: Span,
pub labels: Vec<Label>,
pub suggestion: Option<Suggestion>,
}
/// Severity level of a diagnostic.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Severity {
Hint,
Warning,
Error,
}
/// A secondary label pointing at a span with a message.
#[derive(Debug, Clone)]
pub struct Label {
pub span: Span,
pub message: String,
}
/// A suggested fix attached to a diagnostic.
#[derive(Debug, Clone)]
pub struct Suggestion {
pub message: String,
pub replacement: String,
pub span: Span,
}
// ── Constructors ────────────────────────────────────────
impl Diagnostic {
/// Create an error diagnostic.
pub fn error(message: impl Into<String>, span: Span) -> Self {
Diagnostic {
severity: Severity::Error,
code: None,
message: message.into(),
span,
labels: Vec::new(),
suggestion: None,
}
}
/// Create a warning diagnostic.
pub fn warning(message: impl Into<String>, span: Span) -> Self {
Diagnostic {
severity: Severity::Warning,
code: None,
message: message.into(),
span,
labels: Vec::new(),
suggestion: None,
}
}
/// Create a hint diagnostic.
pub fn hint(message: impl Into<String>, span: Span) -> Self {
Diagnostic {
severity: Severity::Hint,
code: None,
message: message.into(),
span,
labels: Vec::new(),
suggestion: None,
}
}
/// Attach a diagnostic code (e.g. "E0001").
pub fn with_code(mut self, code: impl Into<String>) -> Self {
self.code = Some(code.into());
self
}
/// Add a secondary label.
pub fn with_label(mut self, span: Span, message: impl Into<String>) -> Self {
self.labels.push(Label { span, message: message.into() });
self
}
/// Add a suggestion.
pub fn with_suggestion(mut self, span: Span, message: impl Into<String>, replacement: impl Into<String>) -> Self {
self.suggestion = Some(Suggestion {
message: message.into(),
replacement: replacement.into(),
span,
});
self
}
}
// ── Rendering ───────────────────────────────────────────
impl Severity {
pub fn label(&self) -> &'static str {
match self {
Severity::Error => "ERROR",
Severity::Warning => "WARNING",
Severity::Hint => "HINT",
}
}
pub fn prefix(&self) -> &'static str {
match self {
Severity::Error => "error",
Severity::Warning => "warning",
Severity::Hint => "hint",
}
}
}
impl std::fmt::Display for Severity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.label())
}
}
/// Render a diagnostic with source context, carets, and labels.
///
/// Produces Elm/Rust-style error output:
/// ```text
/// ── ERROR ──────────────────────────────────────────────
/// 5:12
///
/// 5 │ let count: Int = "hello"
/// │ ^^^^^^^ expected Int, found String
///
/// Hint: ...
/// ```
pub fn render(diag: &Diagnostic, source: &str) -> String {
let mut out = String::new();
let lines: Vec<&str> = source.lines().collect();
// Header
let title = match &diag.code {
Some(code) => format!("{} [{}]", diag.severity.label(), code),
None => diag.severity.label().to_string(),
};
let rule_width = 60usize.saturating_sub(title.len() + 4);
out.push_str(&format!("── {} {}\n", title, "".repeat(rule_width)));
// Primary span
let line = diag.span.line as usize;
let col = diag.span.col as usize;
out.push_str(&format!("{}:{}\n", line, col));
// Source context
if line > 0 && line <= lines.len() {
let src_line = lines[line - 1];
let line_num = format!("{}", line);
let pad = " ".repeat(line_num.len());
out.push('\n');
// Line before (context)
if line >= 2 && line - 1 <= lines.len() {
let prev = lines[line - 2];
if !prev.trim().is_empty() {
out.push_str(&format!(" {}{}\n", format!("{:>width$}", line - 1, width = line_num.len()), prev));
}
}
out.push_str(&format!(" {}{}\n", line_num, src_line));
// Caret line
let caret_start = if col > 0 { col - 1 } else { 0 };
let caret_len = if diag.span.end > diag.span.start {
(diag.span.end - diag.span.start).max(1)
} else {
1
};
out.push_str(&format!(" {}{}{}",
pad,
" ".repeat(caret_start),
"^".repeat(caret_len),
));
// Primary message on caret line
out.push_str(&format!(" {}\n", diag.message));
}
// Secondary labels
for label in &diag.labels {
let l = label.span.line as usize;
if l > 0 && l <= lines.len() {
let src = lines[l - 1];
let lnum = format!("{}", l);
let lpad = " ".repeat(lnum.len());
out.push('\n');
out.push_str(&format!(" {}{}\n", lnum, src));
let lc = if label.span.col > 0 { label.span.col as usize - 1 } else { 0 };
let ll = if label.span.end > label.span.start {
(label.span.end - label.span.start).max(1)
} else {
1
};
out.push_str(&format!(" {}{}{} {}\n", lpad, " ".repeat(lc), "-".repeat(ll), label.message));
}
}
// Suggestion
if let Some(ref sugg) = diag.suggestion {
out.push_str(&format!("\n Hint: {}\n", sugg.message));
if !sugg.replacement.is_empty() {
out.push_str(&format!(" Try: {}\n", sugg.replacement));
}
}
out
}
// ── Sorting ─────────────────────────────────────────────
/// Sort diagnostics by severity (errors first) then by span position.
pub fn sort_diagnostics(diags: &mut Vec<Diagnostic>) {
diags.sort_by(|a, b| {
b.severity.cmp(&a.severity)
.then_with(|| a.span.line.cmp(&b.span.line))
.then_with(|| a.span.col.cmp(&b.span.col))
});
}
// ── Conversions ─────────────────────────────────────────
use ds_parser::parser::ParseError;
/// Convert a `ParseError` into a `Diagnostic`.
impl From<ParseError> for Diagnostic {
fn from(err: ParseError) -> Self {
Diagnostic::error(
err.message.clone(),
Span {
start: 0,
end: 0,
line: err.line,
col: err.col,
},
)
.with_code("E0001")
}
}
/// Convert a slice of `ParseError`s into a `Vec<Diagnostic>`.
pub fn parse_errors_to_diagnostics(errors: &[ParseError]) -> Vec<Diagnostic> {
errors.iter().map(|e| Diagnostic::from(e.clone())).collect()
}
// ── Tests ───────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
fn span(line: usize, col: usize, start: usize, end: usize) -> Span {
Span { start, end, line, col }
}
#[test]
fn test_error_rendering() {
let source = "let count = 0\nlet name: Int = \"hello\"\nview main = text \"hi\"";
let diag = Diagnostic::error("expected Int, found String", span(2, 17, 31, 38));
let output = render(&diag, source);
assert!(output.contains("ERROR"));
assert!(output.contains("2:17"));
assert!(output.contains("^^^^^^^"));
assert!(output.contains("expected Int, found String"));
}
#[test]
fn test_warning_rendering() {
let source = "let unused = 42\nview main = text \"hi\"";
let diag = Diagnostic::warning("signal `unused` is never read", span(1, 5, 4, 10));
let output = render(&diag, source);
assert!(output.contains("WARNING"));
assert!(output.contains("unused"));
}
#[test]
fn test_with_suggestion() {
let source = "let count = 0\nmatch status\n Loading -> text \"...\"\n";
let diag = Diagnostic::error("non-exhaustive match", span(2, 1, 14, 26))
.with_code("E0004")
.with_suggestion(span(2, 1, 14, 26), "Add missing variants", " _ -> text \"fallback\"");
let output = render(&diag, source);
assert!(output.contains("[E0004]"));
assert!(output.contains("Hint:"));
assert!(output.contains("Add missing variants"));
}
#[test]
fn test_sort_diagnostics() {
let mut diags = vec![
Diagnostic::warning("w1", span(3, 1, 30, 35)),
Diagnostic::error("e1", span(1, 1, 0, 5)),
Diagnostic::error("e2", span(5, 1, 50, 55)),
];
sort_diagnostics(&mut diags);
// Errors first, then by line
assert_eq!(diags[0].message, "e1");
assert_eq!(diags[1].message, "e2");
assert_eq!(diags[2].message, "w1");
}
#[test]
fn test_parse_error_to_diagnostic() {
let err = ParseError {
message: "unexpected token: Colon".to_string(),
line: 5,
col: 12,
source_line: Some("let x = foo(bar:".to_string()),
};
let diag = Diagnostic::from(err);
assert_eq!(diag.severity, Severity::Error);
assert!(diag.message.contains("unexpected token: Colon"));
assert_eq!(diag.span.line, 5);
assert_eq!(diag.span.col, 12);
assert_eq!(diag.code, Some("E0001".to_string()));
}
#[test]
fn test_parse_errors_to_diagnostics_batch() {
let errors = vec![
ParseError {
message: "error 1".to_string(),
line: 1,
col: 1,
source_line: None,
},
ParseError {
message: "error 2".to_string(),
line: 3,
col: 5,
source_line: None,
},
];
let diags = parse_errors_to_diagnostics(&errors);
assert_eq!(diags.len(), 2);
assert_eq!(diags[0].message, "error 1");
assert_eq!(diags[1].message, "error 2");
assert_eq!(diags[1].span.line, 3);
}
// ── v0.10 Diagnostic Pipeline Tests ─────────────────────
#[test]
fn test_render_with_code() {
let source = "let x = true + 1";
let diag = Diagnostic::error("type mismatch", span(1, 9, 8, 16))
.with_code("E0003");
let output = render(&diag, source);
assert!(output.contains("[E0003]"), "should include error code");
assert!(output.contains("type mismatch"), "should include message");
}
#[test]
fn test_render_with_label() {
let source = "let x: Int = \"hello\"";
let diag = Diagnostic::error("type mismatch", span(1, 14, 13, 20))
.with_label(span(1, 8, 7, 10), "expected type declared here");
let output = render(&diag, source);
assert!(output.contains("ERROR"), "should be error severity");
assert!(output.contains("type mismatch"), "should have message");
}
#[test]
fn test_render_hint() {
let source = "let x = 0";
let diag = Diagnostic::hint("consider using a more descriptive name", span(1, 5, 4, 5));
let output = render(&diag, source);
assert!(output.contains("HINT") || output.contains("hint"), "should render as hint");
}
#[test]
fn test_render_multiline_source() {
let source = "let a = 0\nlet b = a + 1\nlet c = b * 2";
let diag = Diagnostic::warning("unused signal", span(3, 5, 28, 29));
let output = render(&diag, source);
assert!(output.contains("3:"), "should reference line 3");
}
#[test]
fn test_render_col_zero_edge() {
let source = "let x = 0";
let diag = Diagnostic::error("test", span(1, 0, 0, 3));
// Should not panic with col=0
let output = render(&diag, source);
assert!(output.contains("ERROR"));
}
#[test]
fn test_render_minimal_source() {
// Minimal single-character source
let source = " ";
let diag = Diagnostic::error("unexpected end of input", span(1, 1, 0, 1));
let output = render(&diag, source);
assert!(output.contains("unexpected end of input"));
}
}