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