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
8pub struct RelatedSpanLabel<'a> {
9    pub span: &'a Span,
10    pub label: &'a str,
11}
12
13/// Compute the Levenshtein edit distance between two strings.
14pub fn edit_distance(a: &str, b: &str) -> usize {
15    let a_chars: Vec<char> = a.chars().collect();
16    let b_chars: Vec<char> = b.chars().collect();
17    let n = b_chars.len();
18    let mut prev = (0..=n).collect::<Vec<_>>();
19    let mut curr = vec![0; n + 1];
20    for (i, ac) in a_chars.iter().enumerate() {
21        curr[0] = i + 1;
22        for (j, bc) in b_chars.iter().enumerate() {
23            let cost = if ac == bc { 0 } else { 1 };
24            curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(prev[j] + cost);
25        }
26        std::mem::swap(&mut prev, &mut curr);
27    }
28    prev[n]
29}
30
31/// Find the closest match to `name` among `candidates`, within `max_dist` edits.
32pub fn find_closest_match<'a>(
33    name: &str,
34    candidates: impl Iterator<Item = &'a str>,
35    max_dist: usize,
36) -> Option<&'a str> {
37    candidates
38        .filter(|c| c.len().abs_diff(name.len()) <= max_dist)
39        .min_by_key(|c| edit_distance(name, c))
40        .filter(|c| edit_distance(name, c) <= max_dist && *c != name)
41}
42
43/// Render a Rust-style diagnostic message.
44///
45/// Example output:
46/// ```text
47/// error: undefined variable `x`
48///   --> example.harn:5:12
49///    |
50///  5 |     let y = x + 1
51///    |             ^ not found in this scope
52/// ```
53pub fn render_diagnostic(
54    source: &str,
55    filename: &str,
56    span: &Span,
57    severity: &str,
58    message: &str,
59    label: Option<&str>,
60    help: Option<&str>,
61) -> String {
62    render_diagnostic_with_related(source, filename, span, severity, message, label, help, &[])
63}
64
65pub fn render_diagnostic_with_related(
66    source: &str,
67    filename: &str,
68    span: &Span,
69    severity: &str,
70    message: &str,
71    label: Option<&str>,
72    help: Option<&str>,
73    related: &[RelatedSpanLabel<'_>],
74) -> String {
75    let mut out = String::new();
76    let severity_color = severity_color(severity);
77    let gutter = style_fragment("|", Color::Blue, false);
78    let arrow = style_fragment("-->", Color::Blue, true);
79    let help_prefix = style_fragment("help", Color::Cyan, true);
80    let note_prefix = style_fragment("note", Color::Magenta, true);
81
82    out.push_str(&style_fragment(severity, severity_color, true));
83    out.push_str(": ");
84    out.push_str(message);
85    out.push('\n');
86
87    let line_num = span.line;
88    let col_num = span.column;
89
90    let gutter_width = line_num.to_string().len();
91
92    out.push_str(&format!(
93        "{:>width$}{arrow} {filename}:{line_num}:{col_num}\n",
94        " ",
95        width = gutter_width + 1,
96    ));
97
98    out.push_str(&format!(
99        "{:>width$} {gutter}\n",
100        " ",
101        width = gutter_width + 1,
102    ));
103
104    let source_line_opt = source.lines().nth(line_num.wrapping_sub(1));
105    if let Some(source_line) = source_line_opt.filter(|_| line_num > 0) {
106        out.push_str(&format!(
107            "{:>width$} {gutter} {source_line}\n",
108            line_num,
109            width = gutter_width + 1,
110        ));
111
112        if let Some(label_text) = label {
113            // Span width must use char count, not byte offsets, so carets align with the source text.
114            let span_len = if span.end > span.start && span.start <= source.len() {
115                let span_text = &source[span.start.min(source.len())..span.end.min(source.len())];
116                span_text.chars().count().max(1)
117            } else {
118                1
119            };
120            let col_num = col_num.max(1);
121            let padding = " ".repeat(col_num - 1);
122            let carets = style_fragment(&"^".repeat(span_len), severity_color, true);
123            out.push_str(&format!(
124                "{:>width$} {gutter} {padding}{carets} {label_text}\n",
125                " ",
126                width = gutter_width + 1,
127            ));
128        }
129    }
130
131    if let Some(help_text) = help {
132        out.push_str(&format!(
133            "{:>width$} = {help_prefix}: {help_text}\n",
134            " ",
135            width = gutter_width + 1,
136        ));
137    }
138
139    for item in related {
140        out.push_str(&format!(
141            "{:>width$} = {note_prefix}: {}\n",
142            " ",
143            item.label,
144            width = gutter_width + 1,
145        ));
146        render_related_span(
147            &mut out,
148            source,
149            filename,
150            item.span,
151            item.label,
152            gutter_width,
153        );
154    }
155
156    if let Some(note_text) = fun_note(severity) {
157        out.push_str(&format!(
158            "{:>width$} = {note_prefix}: {note_text}\n",
159            " ",
160            width = gutter_width + 1,
161        ));
162    }
163
164    out
165}
166
167pub fn render_type_diagnostic(
168    source: &str,
169    filename: &str,
170    diag: &crate::typechecker::TypeDiagnostic,
171) -> String {
172    let severity = match diag.severity {
173        crate::typechecker::DiagnosticSeverity::Error => "error",
174        crate::typechecker::DiagnosticSeverity::Warning => "warning",
175    };
176    let related = diag
177        .related
178        .iter()
179        .map(|related| RelatedSpanLabel {
180            span: &related.span,
181            label: &related.message,
182        })
183        .collect::<Vec<_>>();
184    match &diag.span {
185        Some(span) => render_diagnostic_with_related(
186            source,
187            filename,
188            span,
189            severity,
190            &diag.message,
191            type_diagnostic_primary_label(diag),
192            diag.help.as_deref(),
193            &related,
194        ),
195        None => format!("{severity}: {}\n", diag.message),
196    }
197}
198
199fn type_diagnostic_primary_label(
200    diag: &crate::typechecker::TypeDiagnostic,
201) -> Option<&'static str> {
202    if diag.message.contains("expected ") && diag.message.contains("found ") {
203        Some("found this type")
204    } else {
205        None
206    }
207}
208
209fn render_related_span(
210    out: &mut String,
211    source: &str,
212    filename: &str,
213    span: &Span,
214    label: &str,
215    primary_gutter_width: usize,
216) {
217    let severity_color = Color::Magenta;
218    let gutter = style_fragment("|", Color::Blue, false);
219    let arrow = style_fragment("-->", Color::Blue, true);
220    let line_num = span.line;
221    let col_num = span.column;
222    let gutter_width = primary_gutter_width.max(line_num.to_string().len());
223
224    out.push_str(&format!(
225        "{:>width$}{arrow} {filename}:{line_num}:{col_num}\n",
226        " ",
227        width = gutter_width + 1,
228    ));
229    out.push_str(&format!(
230        "{:>width$} {gutter}\n",
231        " ",
232        width = gutter_width + 1,
233    ));
234
235    if let Some(source_line) = source
236        .lines()
237        .nth(line_num.wrapping_sub(1))
238        .filter(|_| line_num > 0)
239    {
240        out.push_str(&format!(
241            "{:>width$} {gutter} {source_line}\n",
242            line_num,
243            width = gutter_width + 1,
244        ));
245        let span_len = if span.end > span.start && span.start <= source.len() {
246            let span_text = &source[span.start.min(source.len())..span.end.min(source.len())];
247            span_text.chars().count().max(1)
248        } else {
249            1
250        };
251        let padding = " ".repeat(col_num.max(1) - 1);
252        let carets = style_fragment(&"^".repeat(span_len), severity_color, true);
253        out.push_str(&format!(
254            "{:>width$} {gutter} {padding}{carets} {label}\n",
255            " ",
256            width = gutter_width + 1,
257        ));
258    }
259}
260
261fn severity_color(severity: &str) -> Color {
262    match severity {
263        "error" => Color::Red,
264        "warning" => Color::Yellow,
265        "note" => Color::Magenta,
266        _ => Color::Cyan,
267    }
268}
269
270fn style_fragment(text: &str, color: Color, bold: bool) -> String {
271    if !colors_enabled() {
272        return text.to_string();
273    }
274
275    let mut paint = Paint::new(text).fg(color);
276    if bold {
277        paint = paint.bold();
278    }
279    paint.to_string()
280}
281
282fn colors_enabled() -> bool {
283    std::env::var_os("NO_COLOR").is_none() && std::io::stderr().is_terminal()
284}
285
286fn fun_note(severity: &str) -> Option<&'static str> {
287    if std::env::var("HARN_FUN").ok().as_deref() != Some("1") {
288        return None;
289    }
290
291    Some(match severity {
292        "error" => "the compiler stepped on a rake here.",
293        "warning" => "this still runs, but it has strong 'double-check me' energy.",
294        _ => "a tiny gremlin has left a note in the margins.",
295    })
296}
297
298pub fn parser_error_message(err: &ParserError) -> String {
299    match err {
300        ParserError::Unexpected { got, expected, .. } => {
301            format!("expected {expected}, found {got}")
302        }
303        ParserError::UnexpectedEof { expected, .. } => {
304            format!("unexpected end of file, expected {expected}")
305        }
306    }
307}
308
309pub fn parser_error_label(err: &ParserError) -> &'static str {
310    match err {
311        ParserError::Unexpected { got, .. } if got == "Newline" => "line break not allowed here",
312        ParserError::Unexpected { .. } => "unexpected token",
313        ParserError::UnexpectedEof { .. } => "file ends here",
314    }
315}
316
317pub fn parser_error_help(err: &ParserError) -> Option<&'static str> {
318    match err {
319        ParserError::UnexpectedEof { expected, .. } | ParserError::Unexpected { expected, .. } => {
320            match expected.as_str() {
321                "}" => Some("add a closing `}` to finish this block"),
322                ")" => Some("add a closing `)` to finish this expression or parameter list"),
323                "]" => Some("add a closing `]` to finish this list or subscript"),
324                "fn, struct, enum, or pipeline after pub" => {
325                    Some("use `pub fn`, `pub pipeline`, `pub enum`, or `pub struct`")
326                }
327                _ => None,
328            }
329        }
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336
337    /// Ensure ANSI colors are off so plain-text assertions work regardless
338    /// of whether the test runner's stderr is a TTY.
339    fn disable_colors() {
340        std::env::set_var("NO_COLOR", "1");
341    }
342
343    #[test]
344    fn test_basic_diagnostic() {
345        disable_colors();
346        let source = "pipeline default(task) {\n    let y = x + 1\n}";
347        let span = Span {
348            start: 28,
349            end: 29,
350            line: 2,
351            column: 13,
352            end_line: 2,
353        };
354        let output = render_diagnostic(
355            source,
356            "example.harn",
357            &span,
358            "error",
359            "undefined variable `x`",
360            Some("not found in this scope"),
361            None,
362        );
363        assert!(output.contains("error: undefined variable `x`"));
364        assert!(output.contains("--> example.harn:2:13"));
365        assert!(output.contains("let y = x + 1"));
366        assert!(output.contains("^ not found in this scope"));
367    }
368
369    #[test]
370    fn test_diagnostic_with_help() {
371        disable_colors();
372        let source = "let y = xx + 1";
373        let span = Span {
374            start: 8,
375            end: 10,
376            line: 1,
377            column: 9,
378            end_line: 1,
379        };
380        let output = render_diagnostic(
381            source,
382            "test.harn",
383            &span,
384            "error",
385            "undefined variable `xx`",
386            Some("not found in this scope"),
387            Some("did you mean `x`?"),
388        );
389        assert!(output.contains("help: did you mean `x`?"));
390    }
391
392    #[test]
393    fn test_multiline_source() {
394        disable_colors();
395        let source = "line1\nline2\nline3";
396        let span = Span::with_offsets(6, 11, 2, 1); // "line2"
397        let result = render_diagnostic(
398            source,
399            "test.harn",
400            &span,
401            "error",
402            "bad line",
403            Some("here"),
404            None,
405        );
406        assert!(result.contains("line2"));
407        assert!(result.contains("^^^^^"));
408    }
409
410    #[test]
411    fn test_single_char_span() {
412        disable_colors();
413        let source = "let x = 42";
414        let span = Span::with_offsets(4, 5, 1, 5); // "x"
415        let result = render_diagnostic(
416            source,
417            "test.harn",
418            &span,
419            "warning",
420            "unused",
421            Some("never used"),
422            None,
423        );
424        assert!(result.contains("^"));
425        assert!(result.contains("never used"));
426    }
427
428    #[test]
429    fn test_with_help() {
430        disable_colors();
431        let source = "let y = reponse";
432        let span = Span::with_offsets(8, 15, 1, 9);
433        let result = render_diagnostic(
434            source,
435            "test.harn",
436            &span,
437            "error",
438            "undefined",
439            None,
440            Some("did you mean `response`?"),
441        );
442        assert!(result.contains("help:"));
443        assert!(result.contains("response"));
444    }
445
446    #[test]
447    fn test_parser_error_helpers_for_eof() {
448        disable_colors();
449        let err = ParserError::UnexpectedEof {
450            expected: "}".into(),
451            span: Span::with_offsets(10, 10, 3, 1),
452        };
453        assert_eq!(
454            parser_error_message(&err),
455            "unexpected end of file, expected }"
456        );
457        assert_eq!(parser_error_label(&err), "file ends here");
458        assert_eq!(
459            parser_error_help(&err),
460            Some("add a closing `}` to finish this block")
461        );
462    }
463}