417 lines
13 KiB
Rust
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"));
|
|
}
|
|
}
|
|
|
|
|