Skip to main content

harn_parser/
diagnostic.rs

1use harn_lexer::Span;
2
3/// Render a Rust-style diagnostic message.
4///
5/// Example output:
6/// ```text
7/// error: undefined variable `x`
8///   --> example.harn:5:12
9///    |
10///  5 |     let y = x + 1
11///    |             ^ not found in this scope
12/// ```
13pub fn render_diagnostic(
14    source: &str,
15    filename: &str,
16    span: &Span,
17    severity: &str,
18    message: &str,
19    label: Option<&str>,
20    help: Option<&str>,
21) -> String {
22    let mut out = String::new();
23
24    // Header: severity + message
25    out.push_str(severity);
26    out.push_str(": ");
27    out.push_str(message);
28    out.push('\n');
29
30    // Location line
31    let line_num = span.line;
32    let col_num = span.column;
33
34    let gutter_width = line_num.to_string().len();
35
36    out.push_str(&format!(
37        "{:>width$}--> {filename}:{line_num}:{col_num}\n",
38        " ",
39        width = gutter_width + 1,
40    ));
41
42    // Blank gutter
43    out.push_str(&format!("{:>width$} |\n", " ", width = gutter_width + 1));
44
45    // Source line
46    let source_line_opt = source.lines().nth(line_num.wrapping_sub(1));
47    if let Some(source_line) = source_line_opt.filter(|_| line_num > 0) {
48        out.push_str(&format!(
49            "{:>width$} | {source_line}\n",
50            line_num,
51            width = gutter_width + 1,
52        ));
53
54        // Caret line
55        if let Some(label_text) = label {
56            // Calculate span display width using character counts, not byte offsets
57            let span_len = if span.end > span.start && span.start <= source.len() {
58                let span_text = &source[span.start.min(source.len())..span.end.min(source.len())];
59                span_text.chars().count().max(1)
60            } else {
61                1
62            };
63            let col_num = col_num.max(1); // ensure at least 1
64            let padding = " ".repeat(col_num - 1);
65            let carets = "^".repeat(span_len);
66            out.push_str(&format!(
67                "{:>width$} | {padding}{carets} {label_text}\n",
68                " ",
69                width = gutter_width + 1,
70            ));
71        }
72    }
73
74    // Help line
75    if let Some(help_text) = help {
76        out.push_str(&format!(
77            "{:>width$} = help: {help_text}\n",
78            " ",
79            width = gutter_width + 1,
80        ));
81    }
82
83    out
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    #[test]
91    fn test_basic_diagnostic() {
92        let source = "pipeline default(task) {\n    let y = x + 1\n}";
93        let span = Span {
94            start: 28,
95            end: 29,
96            line: 2,
97            column: 13,
98            end_line: 2,
99        };
100        let output = render_diagnostic(
101            source,
102            "example.harn",
103            &span,
104            "error",
105            "undefined variable `x`",
106            Some("not found in this scope"),
107            None,
108        );
109        assert!(output.contains("error: undefined variable `x`"));
110        assert!(output.contains("--> example.harn:2:13"));
111        assert!(output.contains("let y = x + 1"));
112        assert!(output.contains("^ not found in this scope"));
113    }
114
115    #[test]
116    fn test_diagnostic_with_help() {
117        let source = "let y = xx + 1";
118        let span = Span {
119            start: 8,
120            end: 10,
121            line: 1,
122            column: 9,
123            end_line: 1,
124        };
125        let output = render_diagnostic(
126            source,
127            "test.harn",
128            &span,
129            "error",
130            "undefined variable `xx`",
131            Some("not found in this scope"),
132            Some("did you mean `x`?"),
133        );
134        assert!(output.contains("help: did you mean `x`?"));
135    }
136
137    #[test]
138    fn test_multiline_source() {
139        let source = "line1\nline2\nline3";
140        let span = Span::with_offsets(6, 11, 2, 1); // "line2"
141        let result = render_diagnostic(
142            source,
143            "test.harn",
144            &span,
145            "error",
146            "bad line",
147            Some("here"),
148            None,
149        );
150        assert!(result.contains("line2"));
151        assert!(result.contains("^^^^^"));
152    }
153
154    #[test]
155    fn test_single_char_span() {
156        let source = "let x = 42";
157        let span = Span::with_offsets(4, 5, 1, 5); // "x"
158        let result = render_diagnostic(
159            source,
160            "test.harn",
161            &span,
162            "warning",
163            "unused",
164            Some("never used"),
165            None,
166        );
167        assert!(result.contains("^"));
168        assert!(result.contains("never used"));
169    }
170
171    #[test]
172    fn test_with_help() {
173        let source = "let y = reponse";
174        let span = Span::with_offsets(8, 15, 1, 9);
175        let result = render_diagnostic(
176            source,
177            "test.harn",
178            &span,
179            "error",
180            "undefined",
181            None,
182            Some("did you mean `response`?"),
183        );
184        assert!(result.contains("help:"));
185        assert!(result.contains("response"));
186    }
187}