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