Skip to main content

shape_diagnostics/render/
terminal.rs

1//! Terminal renderer — produces a plain-text representation of an LSDS
2//! [`crate::Diagnostic`].
3//!
4//! The output shape approximates the existing `ShapeError::SemanticError`
5//! rendering for borrow errors so a shape-runtime CLI consumer (or the
6//! existing `eprintln!` paths) can switch to LSDS without users noticing
7//! a regression.
8//!
9//! Layout (roughly):
10//!
11//! ```text
12//! error[B0013]: cannot pass the same variable to multiple parameters that require non-aliased access
13//!  --> src/main.shape:12:4
14//!   = expected: int (witness: 42)
15//!   = found:    string (witness: "hello")
16//!   = hint: use separate variables or clone one of the arguments
17//!   = note: conflicting argument originates here (src/main.shape:9:2)
18//!   = rule: ADR-006-§1.1
19//! ```
20//!
21//! No ANSI colour for now. A `ColorMode` parameter is the natural
22//! follow-up; deferred to a later session per dispatch scope.
23
24use crate::{Diagnostic, Severity};
25
26/// Render a single [`Diagnostic`] to plain text.
27pub fn render(diag: &Diagnostic) -> String {
28    let mut out = String::new();
29
30    // Header line: `severity[ID]: message`.
31    let severity_word = match diag.severity {
32        Severity::Error => "error",
33        Severity::Warning => "warning",
34        Severity::Info => "info",
35        Severity::Hint => "hint",
36    };
37    out.push_str(severity_word);
38    out.push('[');
39    out.push_str(&diag.diagnostic_id);
40    out.push_str("]: ");
41    out.push_str(&diag.message);
42    out.push('\n');
43
44    // Location pointer.
45    out.push_str(" --> ");
46    out.push_str(&format_location(&diag.location));
47    out.push('\n');
48
49    // Expected/found.
50    if let Some(expected) = &diag.expected {
51        out.push_str("  = expected: ");
52        out.push_str(&format_witness(expected));
53        out.push('\n');
54    }
55    if let Some(found) = &diag.found {
56        out.push_str("  = found:    ");
57        out.push_str(&format_witness(found));
58        out.push('\n');
59    }
60
61    // Suggested fixes (label + confidence; diff intentionally omitted in
62    // terminal render for compactness).
63    for fix in &diag.fixes {
64        out.push_str("  = fix: ");
65        out.push_str(&fix.label);
66        if fix.confidence > 0.0 {
67            out.push_str(&format!(" (confidence: {:.0}%)", fix.confidence * 100.0));
68        }
69        out.push('\n');
70    }
71
72    // Notes.
73    for note in &diag.notes {
74        out.push_str("  = note: ");
75        out.push_str(&note.message);
76        if let Some(loc) = &note.location {
77            out.push_str(" (");
78            out.push_str(&format_location(loc));
79            out.push(')');
80        }
81        out.push('\n');
82    }
83
84    // Rule citation.
85    if let Some(rule) = &diag.rule {
86        out.push_str("  = rule: ");
87        out.push_str(rule);
88        out.push('\n');
89    }
90
91    out
92}
93
94fn format_location(loc: &crate::Location) -> String {
95    let file = loc.file.as_deref().unwrap_or("<synthetic>");
96    format!("{}:{}:{}", file, loc.line, loc.col)
97}
98
99fn format_witness(w: &crate::TypeWitness) -> String {
100    match &w.witness {
101        Some(v) => format!("{} (witness: {})", w.r#type, v),
102        None => w.r#type.clone(),
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use crate::{
110        ContextWindow, DiagnosticBuilder, DiagnosticNote, Location, Severity, SuggestedFix,
111        TypeWitness,
112    };
113    use serde_json::json;
114
115    fn sample_b0013() -> Diagnostic {
116        DiagnosticBuilder::new(
117            "B0013",
118            Severity::Error,
119            Location::new(Some("src/main.shape".into()), 12, 4, 102, 145),
120            "cannot pass the same variable to multiple parameters that require non-aliased access",
121        )
122        .expected(TypeWitness::new("int", Some(json!(42))))
123        .found(TypeWitness::new("string", Some(json!("hello"))))
124        .with_fix(SuggestedFix::new(
125            "use separate variables or clone one of the arguments",
126            0.85,
127        ))
128        .with_note(DiagnosticNote::new(
129            "conflicting argument originates here",
130            Some(Location::new(Some("src/main.shape".into()), 9, 2, 60, 70)),
131        ))
132        .context_window(ContextWindow::empty())
133        .rule("ADR-006-§1.1")
134        .build()
135    }
136
137    #[test]
138    fn terminal_render_snapshot() {
139        let diag = sample_b0013();
140        let out = render(&diag);
141        // Snapshot: pinning exact text shape so callers can rely on it.
142        let expected = "\
143error[B0013]: cannot pass the same variable to multiple parameters that require non-aliased access
144 --> src/main.shape:12:4
145  = expected: int (witness: 42)
146  = found:    string (witness: \"hello\")
147  = fix: use separate variables or clone one of the arguments (confidence: 85%)
148  = note: conflicting argument originates here (src/main.shape:9:2)
149  = rule: ADR-006-§1.1
150";
151        assert_eq!(out, expected);
152    }
153
154    #[test]
155    fn terminal_render_minimal_diagnostic() {
156        // No expected/found/fixes/notes/rule — renders header + location only.
157        let diag = DiagnosticBuilder::new(
158            "E0100",
159            Severity::Error,
160            Location::new(Some("test.shape".into()), 3, 1, 10, 20),
161            "type mismatch",
162        )
163        .build();
164        let out = render(&diag);
165        assert_eq!(
166            out,
167            "error[E0100]: type mismatch\n --> test.shape:3:1\n"
168        );
169    }
170
171    #[test]
172    fn terminal_render_synthetic_location() {
173        let diag = DiagnosticBuilder::new(
174            "E0001",
175            Severity::Warning,
176            Location::synthetic(),
177            "config notice",
178        )
179        .build();
180        let out = render(&diag);
181        assert!(out.starts_with("warning[E0001]: config notice"));
182        assert!(out.contains("<synthetic>:0:0"));
183    }
184}