dreamstack/compiler/ds-diagnostic/src/lib.rs
enzotar a65094c0d2 compiler: v0.8–v1.0 milestones — generics, traits, async/effects, production hardening
v0.8.0: Generics, Trait System, LSP Foundation (322 tests)
- ds-parser: GenericParam, TraitDecl, ImplBlock, WhereClause, DefaultParam, Destructure
- ds-types: GenericType, TraitRegistry, TypeExpander
- ds-analyzer: AdvancedAnalyzer (unused imports, memo, dep depth, hot paths)
- ds-codegen: CodeGenV2 (generic erasure, for-in/yield, tree shaking, minify)
- ds-layout: FlexLayout (gap, padding, margin, border, position, alignment)
- ds-diagnostic: LspDiagnostic, DiagnosticBatch (LSP format, suppression, dedup)
- ds-incremental: IncrementalV2 (content hash, compile queue, error cache)

v0.9.0: Async/Await, Effect System, Production Hardening (385 tests)
- ds-parser: AsyncFn, EffectDeclV2, TryCatch, PipelineExpr, Decorator
- ds-types: AsyncType (Promise/Future/Effect/Result), AdvancedType (intersection/mapped/conditional/branded)
- ds-analyzer: ProductionAnalyzer (async boundaries, purity, complexity, coverage)
- ds-codegen: CodeGenV3 (async/await, try/catch, pipeline, chunks, CSS, HMR)
- ds-layout: AdvancedLayout (scroll, sticky, flex grow/shrink, shadow, transition)
- ds-diagnostic: DiagnosticPipeline, DiagTag (file index, lint rules, escalation)
- ds-incremental: BuildPipeline (profiles, workers, artifacts, source maps)

v1.0.0: Production-Ready Compiler with Stable API (511 tests)
- ds-parser: ParseError1, PartialAst, VisibilityV2, Namespace, DocComment, Pragma, NumericLit, ParseStats
- ds-types: TypeInference (HM unification), SubtypeChecker, TypeSystemExt (opaque/existential/HKT)
- ds-analyzer: FullAnalyzer (call graph, dead code, tail call, borrow check, vectorize)
- ds-codegen: CodeGenFull (WASM, SSR, hydration, CSS modules, import maps, SIMD)
- ds-layout: Animation, TextLayout, MediaQuery, ColorSpace, Gradient, Filter, LayoutStats
- ds-diagnostic: DiagnosticSuite (SARIF, code frames, budgets, baselines, trending)
- ds-incremental: BuildSystem (remote cache, build graph, plugins, hermetic, signing)
2026-03-11 16:16:42 -07:00

684 lines
29 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 ───────────────────────────────────────────────
// ─── v0.7: Diagnostic Extensions ───
#[derive(Debug, Clone, PartialEq)]
pub struct DiagnosticExt {
pub message: String,
pub severity: SeverityV2,
pub code: Option<String>,
pub fix: Option<FixSuggestionV2>,
pub related: Vec<RelatedInfoV2>,
pub snippet: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum SeverityV2 { Error, Warning, Info, Hint }
#[derive(Debug, Clone, PartialEq)]
pub struct FixSuggestionV2 { pub message: String, pub replacement: String }
#[derive(Debug, Clone, PartialEq)]
pub struct RelatedInfoV2 { pub message: String, pub file: String, pub line: u32 }
impl DiagnosticExt {
pub fn error(msg: &str) -> Self { DiagnosticExt { message: msg.to_string(), severity: SeverityV2::Error, code: None, fix: None, related: Vec::new(), snippet: None } }
pub fn warning(msg: &str) -> Self { DiagnosticExt { message: msg.to_string(), severity: SeverityV2::Warning, code: None, fix: None, related: Vec::new(), snippet: None } }
pub fn with_code(mut self, code: &str) -> Self { self.code = Some(code.to_string()); self }
pub fn with_fix(mut self, msg: &str, replacement: &str) -> Self { self.fix = Some(FixSuggestionV2 { message: msg.to_string(), replacement: replacement.to_string() }); self }
pub fn with_related(mut self, msg: &str, file: &str, line: u32) -> Self { self.related.push(RelatedInfoV2 { message: msg.to_string(), file: file.to_string(), line }); self }
pub fn with_snippet(mut self, s: &str) -> Self { self.snippet = Some(s.to_string()); self }
pub fn is_error(&self) -> bool { matches!(self.severity, SeverityV2::Error) }
pub fn to_json(&self) -> String { format!("{{\"severity\":\"{:?}\",\"message\":\"{}\"}}", self.severity, self.message) }
}
pub struct DiagnosticGroup { diagnostics: Vec<DiagnosticExt> }
impl DiagnosticGroup {
pub fn new() -> Self { DiagnosticGroup { diagnostics: Vec::new() } }
pub fn push(&mut self, d: DiagnosticExt) { self.diagnostics.push(d); }
pub fn error_count(&self) -> usize { self.diagnostics.iter().filter(|d| d.is_error()).count() }
pub fn warning_count(&self) -> usize { self.diagnostics.iter().filter(|d| matches!(d.severity, SeverityV2::Warning)).count() }
pub fn summary(&self) -> String { format!("{} errors, {} warnings", self.error_count(), self.warning_count()) }
pub fn len(&self) -> usize { self.diagnostics.len() }
pub fn is_empty(&self) -> bool { self.diagnostics.is_empty() }
}
impl Default for DiagnosticGroup { fn default() -> Self { Self::new() } }
// ─── v0.8: LSP & Advanced Diagnostics ───
#[derive(Debug, Clone, PartialEq)]
pub struct LspDiagnostic { pub file: String, pub start_line: u32, pub start_col: u32, pub end_line: u32, pub end_col: u32, pub message: String, pub severity: u8, pub code: Option<String> }
impl LspDiagnostic {
pub fn new(file: &str, start: (u32, u32), end: (u32, u32), msg: &str, sev: u8) -> Self {
LspDiagnostic { file: file.to_string(), start_line: start.0, start_col: start.1, end_line: end.0, end_col: end.1, message: msg.to_string(), severity: sev, code: None }
}
pub fn with_code(mut self, c: &str) -> Self { self.code = Some(c.to_string()); self }
pub fn to_json(&self) -> String { format!("{{\"range\":{{\"start\":{{\"line\":{},\"character\":{}}},\"end\":{{\"line\":{},\"character\":{}}}}},\"message\":\"{}\",\"severity\":{}}}", self.start_line, self.start_col, self.end_line, self.end_col, self.message, self.severity) }
}
pub struct DiagnosticBatch { items: Vec<LspDiagnostic>, suppressed: Vec<String> }
impl DiagnosticBatch {
pub fn new() -> Self { DiagnosticBatch { items: Vec::new(), suppressed: Vec::new() } }
pub fn push(&mut self, d: LspDiagnostic) { if !self.suppressed.contains(&d.message) { self.items.push(d); } }
pub fn suppress(&mut self, msg: &str) { self.suppressed.push(msg.to_string()); }
pub fn dedup(&mut self) { self.items.dedup_by(|a, b| a.message == b.message && a.file == b.file && a.start_line == b.start_line); }
pub fn sort_by_severity(&mut self) { self.items.sort_by_key(|d| d.severity); }
pub fn len(&self) -> usize { self.items.len() }
pub fn is_empty(&self) -> bool { self.items.is_empty() }
pub fn errors(&self) -> usize { self.items.iter().filter(|d| d.severity == 1).count() }
pub fn warnings(&self) -> usize { self.items.iter().filter(|d| d.severity == 2).count() }
}
impl Default for DiagnosticBatch { fn default() -> Self { Self::new() } }
// ─── v0.9: Production Diagnostics ───
#[derive(Debug, Clone, PartialEq)]
pub enum DiagTag { Unnecessary, Deprecated, Custom(String) }
pub struct DiagnosticPipeline {
file_index: Vec<(u32, String)>,
lint_rules: Vec<(String, bool)>, // (rule_id, enabled)
history: Vec<(String, u32)>, // (message, count)
escalate_warnings: bool,
}
impl DiagnosticPipeline {
pub fn new() -> Self { DiagnosticPipeline { file_index: Vec::new(), lint_rules: Vec::new(), history: Vec::new(), escalate_warnings: false } }
pub fn register_file(&mut self, id: u32, path: &str) { self.file_index.push((id, path.to_string())); }
pub fn resolve_file(&self, id: u32) -> Option<String> { self.file_index.iter().find(|(i, _)| *i == id).map(|(_, p)| p.clone()) }
pub fn set_lint_rule(&mut self, rule: &str, enabled: bool) { self.lint_rules.push((rule.to_string(), enabled)); }
pub fn is_lint_enabled(&self, rule: &str) -> bool { self.lint_rules.iter().rev().find(|(r, _)| r == rule).map(|(_, e)| *e).unwrap_or(true) }
pub fn set_escalate(&mut self, v: bool) { self.escalate_warnings = v; }
pub fn effective_severity(&self, is_warning: bool) -> u8 { if is_warning && self.escalate_warnings { 1 } else if is_warning { 2 } else { 1 } }
pub fn record(&mut self, msg: &str) { if let Some(e) = self.history.iter_mut().find(|(m, _)| m == msg) { e.1 += 1; } else { self.history.push((msg.to_string(), 1)); } }
pub fn history_count(&self, msg: &str) -> u32 { self.history.iter().find(|(m, _)| m == msg).map(|(_, c)| *c).unwrap_or(0) }
pub fn file_count(&self) -> usize { self.file_index.len() }
pub fn merge_spans(start1: u32, end1: u32, start2: u32, end2: u32) -> (u32, u32) { (start1.min(start2), end1.max(end2)) }
}
impl Default for DiagnosticPipeline { fn default() -> Self { Self::new() } }
// ─── v1.0: Full Diagnostic Suite ───
pub struct DiagnosticSuite {
categories: Vec<(String, Vec<String>)>,
budget: Option<u32>,
emitted: u32,
rate_limits: Vec<(String, u32, u32)>, // (msg, max, current)
fingerprints: Vec<(String, String)>,
baseline: Vec<String>,
trend: Vec<(String, u32)>, // (timestamp, error_count)
fixes_applied: u32,
fixes_total: u32,
}
impl DiagnosticSuite {
pub fn new() -> Self { DiagnosticSuite { categories: Vec::new(), budget: None, emitted: 0, rate_limits: Vec::new(), fingerprints: Vec::new(), baseline: Vec::new(), trend: Vec::new(), fixes_applied: 0, fixes_total: 0 } }
pub fn add_category(&mut self, cat: &str, codes: Vec<&str>) { self.categories.push((cat.to_string(), codes.into_iter().map(str::to_string).collect())); }
pub fn set_budget(&mut self, max: u32) { self.budget = Some(max); }
pub fn can_emit(&self) -> bool { self.budget.map(|b| self.emitted < b).unwrap_or(true) }
pub fn emit(&mut self) -> bool { if self.can_emit() { self.emitted += 1; true } else { false } }
pub fn add_baseline(&mut self, fp: &str) { self.baseline.push(fp.to_string()); }
pub fn is_baseline(&self, fp: &str) -> bool { self.baseline.contains(&fp.to_string()) }
pub fn fingerprint(file: &str, line: u32, code: &str) -> String { format!("{}:{}:{}", file, line, code) }
pub fn record_trend(&mut self, ts: &str, count: u32) { self.trend.push((ts.to_string(), count)); }
pub fn latest_trend(&self) -> Option<u32> { self.trend.last().map(|(_, c)| *c) }
pub fn record_fix(&mut self, applied: bool) { self.fixes_total += 1; if applied { self.fixes_applied += 1; } }
pub fn fix_rate(&self) -> f64 { if self.fixes_total == 0 { 0.0 } else { self.fixes_applied as f64 / self.fixes_total as f64 * 100.0 } }
pub fn to_sarif(file: &str, line: u32, msg: &str) -> String { format!("{{\"runs\":[{{\"results\":[{{\"message\":{{\"text\":\"{}\"}},\"locations\":[{{\"physicalLocation\":{{\"artifactLocation\":{{\"uri\":\"{}\"}},\"region\":{{\"startLine\":{}}}}}}}]}}]}}]}}", msg, file, line) }
pub fn to_codeframe(line: u32, col: u32, source: &str, msg: &str) -> String { format!(" {} | {}\n {} | {}^ {}", line, source, "", " ".repeat(col as usize), msg) }
pub fn to_html(msg: &str, severity: &str) -> String { format!("<div class=\"diag {}\"><span>{}</span></div>", severity, msg) }
pub fn to_markdown(msg: &str, file: &str, line: u32) -> String { format!("- **{}:{}** — {}", file, line, msg) }
pub fn category_count(&self) -> usize { self.categories.len() }
pub fn report(&self) -> String { format!("emitted:{} budget:{:?} fixes:{}/{}", self.emitted, self.budget, self.fixes_applied, self.fixes_total) }
}
impl Default for DiagnosticSuite { fn default() -> Self { Self::new() } }
#[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"));
}
// ─── v0.7 Tests ───
#[test]
fn test_diag_error() { let d = DiagnosticExt::error("bad"); assert!(d.is_error()); }
#[test]
fn test_diag_warning() { let d = DiagnosticExt::warning("warn"); assert!(!d.is_error()); }
#[test]
fn test_diag_code() { let d = DiagnosticExt::error("e").with_code("E001"); assert_eq!(d.code, Some("E001".into())); }
#[test]
fn test_diag_fix() { let d = DiagnosticExt::error("e").with_fix("add semicolon", ";"); assert!(d.fix.is_some()); }
#[test]
fn test_diag_related() { let d = DiagnosticExt::error("e").with_related("see also", "main.ds", 10); assert_eq!(d.related.len(), 1); }
#[test]
fn test_diag_json() { let d = DiagnosticExt::error("bad"); let j = d.to_json(); assert!(j.contains("Error")); assert!(j.contains("bad")); }
#[test]
fn test_diag_group() { let mut g = DiagnosticGroup::new(); g.push(DiagnosticExt::error("e1")); g.push(DiagnosticExt::warning("w1")); assert_eq!(g.error_count(), 1); assert_eq!(g.warning_count(), 1); }
#[test]
fn test_diag_summary() { let mut g = DiagnosticGroup::new(); g.push(DiagnosticExt::error("e")); assert_eq!(g.summary(), "1 errors, 0 warnings"); }
#[test]
fn test_diag_snippet() { let d = DiagnosticExt::error("e").with_snippet("let x = 1;"); assert_eq!(d.snippet, Some("let x = 1;".into())); }
// ─── v0.8 Tests ───
#[test]
fn test_lsp_diagnostic() { let d = LspDiagnostic::new("main.ds", (1, 5), (1, 10), "error", 1); assert_eq!(d.start_line, 1); assert_eq!(d.severity, 1); }
#[test]
fn test_lsp_json() { let d = LspDiagnostic::new("a.ds", (0, 0), (0, 5), "bad", 1); let j = d.to_json(); assert!(j.contains("\"message\":\"bad\"")); }
#[test]
fn test_lsp_code() { let d = LspDiagnostic::new("a.ds", (0,0), (0,1), "e", 1).with_code("E001"); assert_eq!(d.code, Some("E001".into())); }
#[test]
fn test_batch_push() { let mut b = DiagnosticBatch::new(); b.push(LspDiagnostic::new("a.ds", (0,0), (0,1), "e", 1)); assert_eq!(b.len(), 1); }
#[test]
fn test_batch_suppress() { let mut b = DiagnosticBatch::new(); b.suppress("ignore"); b.push(LspDiagnostic::new("a.ds", (0,0), (0,1), "ignore", 1)); assert_eq!(b.len(), 0); }
#[test]
fn test_batch_dedup() { let mut b = DiagnosticBatch::new(); b.push(LspDiagnostic::new("a.ds", (1,0), (1,5), "dup", 1)); b.push(LspDiagnostic::new("a.ds", (1,0), (1,5), "dup", 1)); b.dedup(); assert_eq!(b.len(), 1); }
#[test]
fn test_batch_sort() { let mut b = DiagnosticBatch::new(); b.push(LspDiagnostic::new("a.ds", (0,0), (0,1), "w", 2)); b.push(LspDiagnostic::new("a.ds", (0,0), (0,1), "e", 1)); b.sort_by_severity(); assert_eq!(b.errors(), 1); }
#[test]
fn test_batch_counts() { let mut b = DiagnosticBatch::new(); b.push(LspDiagnostic::new("a.ds", (0,0), (0,1), "e", 1)); b.push(LspDiagnostic::new("a.ds", (0,0), (0,1), "w", 2)); assert_eq!(b.errors(), 1); assert_eq!(b.warnings(), 1); }
#[test]
fn test_batch_empty() { let b = DiagnosticBatch::new(); assert!(b.is_empty()); }
// ─── v0.9 Tests ───
#[test]
fn test_file_index() { let mut p = DiagnosticPipeline::new(); p.register_file(1, "main.ds"); assert_eq!(p.resolve_file(1), Some("main.ds".into())); assert_eq!(p.resolve_file(2), None); }
#[test]
fn test_lint_rules() { let mut p = DiagnosticPipeline::new(); p.set_lint_rule("no-unused", false); assert!(!p.is_lint_enabled("no-unused")); assert!(p.is_lint_enabled("other")); }
#[test]
fn test_escalation() { let mut p = DiagnosticPipeline::new(); p.set_escalate(true); assert_eq!(p.effective_severity(true), 1); assert_eq!(p.effective_severity(false), 1); }
#[test]
fn test_no_escalation() { let p = DiagnosticPipeline::new(); assert_eq!(p.effective_severity(true), 2); }
#[test]
fn test_history() { let mut p = DiagnosticPipeline::new(); p.record("err"); p.record("err"); p.record("warn"); assert_eq!(p.history_count("err"), 2); assert_eq!(p.history_count("warn"), 1); }
#[test]
fn test_span_merge() { assert_eq!(DiagnosticPipeline::merge_spans(5, 10, 3, 8), (3, 10)); }
#[test]
fn test_file_count() { let mut p = DiagnosticPipeline::new(); p.register_file(1, "a.ds"); p.register_file(2, "b.ds"); assert_eq!(p.file_count(), 2); }
#[test]
fn test_diag_tag() { let t = DiagTag::Deprecated; assert_eq!(t, DiagTag::Deprecated); }
#[test]
fn test_custom_tag() { let t = DiagTag::Custom("experimental".into()); if let DiagTag::Custom(s) = t { assert_eq!(s, "experimental"); } else { panic!(); } }
// ─── v1.0 Tests ───
#[test]
fn test_budget() { let mut s = DiagnosticSuite::new(); s.set_budget(2); assert!(s.emit()); assert!(s.emit()); assert!(!s.emit()); }
#[test]
fn test_no_budget() { let mut s = DiagnosticSuite::new(); assert!(s.emit()); }
#[test]
fn test_baseline() { let mut s = DiagnosticSuite::new(); s.add_baseline("a:1:E001"); assert!(s.is_baseline("a:1:E001")); assert!(!s.is_baseline("b:2:E002")); }
#[test]
fn test_fingerprint() { let fp = DiagnosticSuite::fingerprint("main.ds", 10, "E001"); assert_eq!(fp, "main.ds:10:E001"); }
#[test]
fn test_trend() { let mut s = DiagnosticSuite::new(); s.record_trend("t1", 5); s.record_trend("t2", 3); assert_eq!(s.latest_trend(), Some(3)); }
#[test]
fn test_fix_rate() { let mut s = DiagnosticSuite::new(); s.record_fix(true); s.record_fix(false); assert!((s.fix_rate() - 50.0).abs() < 0.01); }
#[test]
fn test_fix_rate_zero() { let s = DiagnosticSuite::new(); assert_eq!(s.fix_rate(), 0.0); }
#[test]
fn test_sarif() { let s = DiagnosticSuite::to_sarif("a.ds", 1, "bad"); assert!(s.contains("\"text\":\"bad\"")); }
#[test]
fn test_codeframe() { let cf = DiagnosticSuite::to_codeframe(5, 3, "let x = 1;", "unexpected"); assert!(cf.contains("let x = 1;")); }
#[test]
fn test_html_output() { let h = DiagnosticSuite::to_html("error msg", "error"); assert!(h.contains("class=\"diag error\"")); }
#[test]
fn test_markdown_output() { let m = DiagnosticSuite::to_markdown("bad syntax", "main.ds", 10); assert!(m.contains("**main.ds:10**")); }
#[test]
fn test_category() { let mut s = DiagnosticSuite::new(); s.add_category("syntax", vec!["E001", "E002"]); assert_eq!(s.category_count(), 1); }
#[test]
fn test_report_v1() { let s = DiagnosticSuite::new(); let r = s.report(); assert!(r.contains("emitted:0")); }
#[test]
fn test_empty_trend() { let s = DiagnosticSuite::new(); assert_eq!(s.latest_trend(), None); }
#[test]
fn test_budget_exact() { let mut s = DiagnosticSuite::new(); s.set_budget(1); assert!(s.emit()); assert!(!s.can_emit()); }
#[test]
fn test_no_baseline() { let s = DiagnosticSuite::new(); assert!(!s.is_baseline("x")); }
#[test]
fn test_all_fixes() { let mut s = DiagnosticSuite::new(); s.record_fix(true); s.record_fix(true); assert_eq!(s.fix_rate(), 100.0); }
#[test]
fn test_multi_categories() { let mut s = DiagnosticSuite::new(); s.add_category("a", vec![]); s.add_category("b", vec![]); assert_eq!(s.category_count(), 2); }
}