Skip to main content

harn_parser/
diagnostic.rs

1use std::io::IsTerminal;
2
3use harn_lexer::Span;
4use yansi::{Color, Paint};
5
6use crate::ParserError;
7
8/// Compute the Levenshtein edit distance between two strings.
9pub fn edit_distance(a: &str, b: &str) -> usize {
10    let a_chars: Vec<char> = a.chars().collect();
11    let b_chars: Vec<char> = b.chars().collect();
12    let n = b_chars.len();
13    let mut prev = (0..=n).collect::<Vec<_>>();
14    let mut curr = vec![0; n + 1];
15    for (i, ac) in a_chars.iter().enumerate() {
16        curr[0] = i + 1;
17        for (j, bc) in b_chars.iter().enumerate() {
18            let cost = if ac == bc { 0 } else { 1 };
19            curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(prev[j] + cost);
20        }
21        std::mem::swap(&mut prev, &mut curr);
22    }
23    prev[n]
24}
25
26/// Find the closest match to `name` among `candidates`, within `max_dist` edits.
27pub fn find_closest_match<'a>(
28    name: &str,
29    candidates: impl Iterator<Item = &'a str>,
30    max_dist: usize,
31) -> Option<&'a str> {
32    candidates
33        .filter(|c| c.len().abs_diff(name.len()) <= max_dist)
34        .min_by_key(|c| edit_distance(name, c))
35        .filter(|c| edit_distance(name, c) <= max_dist && *c != name)
36}
37
38/// Render a Rust-style diagnostic message.
39///
40/// Example output:
41/// ```text
42/// error: undefined variable `x`
43///   --> example.harn:5:12
44///    |
45///  5 |     let y = x + 1
46///    |             ^ not found in this scope
47/// ```
48pub fn render_diagnostic(
49    source: &str,
50    filename: &str,
51    span: &Span,
52    severity: &str,
53    message: &str,
54    label: Option<&str>,
55    help: Option<&str>,
56) -> String {
57    let mut out = String::new();
58    let severity_color = severity_color(severity);
59    let gutter = style_fragment("|", Color::Blue, false);
60    let arrow = style_fragment("-->", Color::Blue, true);
61    let help_prefix = style_fragment("help", Color::Cyan, true);
62    let note_prefix = style_fragment("note", Color::Magenta, true);
63
64    // Header: severity + message
65    out.push_str(&style_fragment(severity, severity_color, true));
66    out.push_str(": ");
67    out.push_str(message);
68    out.push('\n');
69
70    // Location line
71    let line_num = span.line;
72    let col_num = span.column;
73
74    let gutter_width = line_num.to_string().len();
75
76    out.push_str(&format!(
77        "{:>width$}{arrow} {filename}:{line_num}:{col_num}\n",
78        " ",
79        width = gutter_width + 1,
80    ));
81
82    // Blank gutter
83    out.push_str(&format!(
84        "{:>width$} {gutter}\n",
85        " ",
86        width = gutter_width + 1,
87    ));
88
89    // Source line
90    let source_line_opt = source.lines().nth(line_num.wrapping_sub(1));
91    if let Some(source_line) = source_line_opt.filter(|_| line_num > 0) {
92        out.push_str(&format!(
93            "{:>width$} {gutter} {source_line}\n",
94            line_num,
95            width = gutter_width + 1,
96        ));
97
98        // Caret line
99        if let Some(label_text) = label {
100            // Calculate span display width using character counts, not byte offsets
101            let span_len = if span.end > span.start && span.start <= source.len() {
102                let span_text = &source[span.start.min(source.len())..span.end.min(source.len())];
103                span_text.chars().count().max(1)
104            } else {
105                1
106            };
107            let col_num = col_num.max(1); // ensure at least 1
108            let padding = " ".repeat(col_num - 1);
109            let carets = style_fragment(&"^".repeat(span_len), severity_color, true);
110            out.push_str(&format!(
111                "{:>width$} {gutter} {padding}{carets} {label_text}\n",
112                " ",
113                width = gutter_width + 1,
114            ));
115        }
116    }
117
118    // Help line
119    if let Some(help_text) = help {
120        out.push_str(&format!(
121            "{:>width$} = {help_prefix}: {help_text}\n",
122            " ",
123            width = gutter_width + 1,
124        ));
125    }
126
127    if let Some(note_text) = fun_note(severity) {
128        out.push_str(&format!(
129            "{:>width$} = {note_prefix}: {note_text}\n",
130            " ",
131            width = gutter_width + 1,
132        ));
133    }
134
135    out
136}
137
138fn severity_color(severity: &str) -> Color {
139    match severity {
140        "error" => Color::Red,
141        "warning" => Color::Yellow,
142        "note" => Color::Magenta,
143        _ => Color::Cyan,
144    }
145}
146
147fn style_fragment(text: &str, color: Color, bold: bool) -> String {
148    if !colors_enabled() {
149        return text.to_string();
150    }
151
152    let mut paint = Paint::new(text).fg(color);
153    if bold {
154        paint = paint.bold();
155    }
156    paint.to_string()
157}
158
159fn colors_enabled() -> bool {
160    std::env::var_os("NO_COLOR").is_none() && std::io::stderr().is_terminal()
161}
162
163fn fun_note(severity: &str) -> Option<&'static str> {
164    if std::env::var("HARN_FUN").ok().as_deref() != Some("1") {
165        return None;
166    }
167
168    Some(match severity {
169        "error" => "the compiler stepped on a rake here.",
170        "warning" => "this still runs, but it has strong 'double-check me' energy.",
171        _ => "a tiny gremlin has left a note in the margins.",
172    })
173}
174
175pub fn parser_error_message(err: &ParserError) -> String {
176    match err {
177        ParserError::Unexpected { got, expected, .. } => {
178            format!("expected {expected}, found {got}")
179        }
180        ParserError::UnexpectedEof { expected, .. } => {
181            format!("unexpected end of file, expected {expected}")
182        }
183    }
184}
185
186pub fn parser_error_label(err: &ParserError) -> &'static str {
187    match err {
188        ParserError::Unexpected { got, .. } if got == "Newline" => "line break not allowed here",
189        ParserError::Unexpected { .. } => "unexpected token",
190        ParserError::UnexpectedEof { .. } => "file ends here",
191    }
192}
193
194pub fn parser_error_help(err: &ParserError) -> Option<&'static str> {
195    match err {
196        ParserError::UnexpectedEof { expected, .. } | ParserError::Unexpected { expected, .. } => {
197            match expected.as_str() {
198                "}" => Some("add a closing `}` to finish this block"),
199                ")" => Some("add a closing `)` to finish this expression or parameter list"),
200                "]" => Some("add a closing `]` to finish this list or subscript"),
201                "fn, struct, enum, or pipeline after pub" => {
202                    Some("use `pub fn`, `pub pipeline`, `pub enum`, or `pub struct`")
203                }
204                _ => None,
205            }
206        }
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    /// Ensure ANSI colors are off so plain-text assertions work regardless
215    /// of whether the test runner's stderr is a TTY.
216    fn disable_colors() {
217        std::env::set_var("NO_COLOR", "1");
218    }
219
220    #[test]
221    fn test_basic_diagnostic() {
222        disable_colors();
223        let source = "pipeline default(task) {\n    let y = x + 1\n}";
224        let span = Span {
225            start: 28,
226            end: 29,
227            line: 2,
228            column: 13,
229            end_line: 2,
230        };
231        let output = render_diagnostic(
232            source,
233            "example.harn",
234            &span,
235            "error",
236            "undefined variable `x`",
237            Some("not found in this scope"),
238            None,
239        );
240        assert!(output.contains("error: undefined variable `x`"));
241        assert!(output.contains("--> example.harn:2:13"));
242        assert!(output.contains("let y = x + 1"));
243        assert!(output.contains("^ not found in this scope"));
244    }
245
246    #[test]
247    fn test_diagnostic_with_help() {
248        disable_colors();
249        let source = "let y = xx + 1";
250        let span = Span {
251            start: 8,
252            end: 10,
253            line: 1,
254            column: 9,
255            end_line: 1,
256        };
257        let output = render_diagnostic(
258            source,
259            "test.harn",
260            &span,
261            "error",
262            "undefined variable `xx`",
263            Some("not found in this scope"),
264            Some("did you mean `x`?"),
265        );
266        assert!(output.contains("help: did you mean `x`?"));
267    }
268
269    #[test]
270    fn test_multiline_source() {
271        disable_colors();
272        let source = "line1\nline2\nline3";
273        let span = Span::with_offsets(6, 11, 2, 1); // "line2"
274        let result = render_diagnostic(
275            source,
276            "test.harn",
277            &span,
278            "error",
279            "bad line",
280            Some("here"),
281            None,
282        );
283        assert!(result.contains("line2"));
284        assert!(result.contains("^^^^^"));
285    }
286
287    #[test]
288    fn test_single_char_span() {
289        disable_colors();
290        let source = "let x = 42";
291        let span = Span::with_offsets(4, 5, 1, 5); // "x"
292        let result = render_diagnostic(
293            source,
294            "test.harn",
295            &span,
296            "warning",
297            "unused",
298            Some("never used"),
299            None,
300        );
301        assert!(result.contains("^"));
302        assert!(result.contains("never used"));
303    }
304
305    #[test]
306    fn test_with_help() {
307        disable_colors();
308        let source = "let y = reponse";
309        let span = Span::with_offsets(8, 15, 1, 9);
310        let result = render_diagnostic(
311            source,
312            "test.harn",
313            &span,
314            "error",
315            "undefined",
316            None,
317            Some("did you mean `response`?"),
318        );
319        assert!(result.contains("help:"));
320        assert!(result.contains("response"));
321    }
322
323    #[test]
324    fn test_parser_error_helpers_for_eof() {
325        disable_colors();
326        let err = ParserError::UnexpectedEof {
327            expected: "}".into(),
328            span: Span::with_offsets(10, 10, 3, 1),
329        };
330        assert_eq!(
331            parser_error_message(&err),
332            "unexpected end of file, expected }"
333        );
334        assert_eq!(parser_error_label(&err), "file ends here");
335        assert_eq!(
336            parser_error_help(&err),
337            Some("add a closing `}` to finish this block")
338        );
339    }
340}