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    match &diag.span {
238        Some(span) => render_diagnostic_with_related(
239            source,
240            filename,
241            span,
242            severity,
243            &diag.message,
244            type_diagnostic_primary_label(diag),
245            diag.help.as_deref(),
246            &related,
247        ),
248        None => format!("{severity}: {}\n", diag.message),
249    }
250}
251
252fn type_diagnostic_primary_label(
253    diag: &crate::typechecker::TypeDiagnostic,
254) -> Option<&'static str> {
255    if diag.message.contains("expected ") && diag.message.contains("found ") {
256        Some("found this type")
257    } else {
258        None
259    }
260}
261
262fn render_related_span(
263    out: &mut String,
264    source: &str,
265    filename: &str,
266    span: &Span,
267    label: &str,
268    primary_gutter_width: usize,
269) {
270    let filename = normalize_diagnostic_path(filename);
271    let severity_color = Color::Magenta;
272    let gutter = style_fragment("|", Color::Blue, false);
273    let arrow = style_fragment("-->", Color::Blue, true);
274    let line_num = span.line;
275    let col_num = span.column;
276    let gutter_width = primary_gutter_width.max(line_num.to_string().len());
277
278    out.push_str(&format!(
279        "{:>width$}{arrow} {filename}:{line_num}:{col_num}\n",
280        " ",
281        width = gutter_width + 1,
282    ));
283    out.push_str(&format!(
284        "{:>width$} {gutter}\n",
285        " ",
286        width = gutter_width + 1,
287    ));
288
289    if let Some(source_line) = source
290        .lines()
291        .nth(line_num.wrapping_sub(1))
292        .filter(|_| line_num > 0)
293    {
294        out.push_str(&format!(
295            "{:>width$} {gutter} {source_line}\n",
296            line_num,
297            width = gutter_width + 1,
298        ));
299        let span_len = if span.end > span.start && span.start <= source.len() {
300            let span_text = &source[span.start.min(source.len())..span.end.min(source.len())];
301            span_text.chars().count().max(1)
302        } else {
303            1
304        };
305        let padding = " ".repeat(col_num.max(1) - 1);
306        let carets = style_fragment(&"^".repeat(span_len), severity_color, true);
307        out.push_str(&format!(
308            "{:>width$} {gutter} {padding}{carets} {label}\n",
309            " ",
310            width = gutter_width + 1,
311        ));
312    }
313}
314
315fn severity_color(severity: &str) -> Color {
316    match severity {
317        "error" => Color::Red,
318        "warning" => Color::Yellow,
319        "note" => Color::Magenta,
320        _ => Color::Cyan,
321    }
322}
323
324fn style_fragment(text: &str, color: Color, bold: bool) -> String {
325    if !colors_enabled() {
326        return text.to_string();
327    }
328
329    let mut paint = Paint::new(text).fg(color);
330    if bold {
331        paint = paint.bold();
332    }
333    paint.to_string()
334}
335
336fn colors_enabled() -> bool {
337    std::env::var_os("NO_COLOR").is_none() && std::io::stderr().is_terminal()
338}
339
340fn fun_note(severity: &str) -> Option<&'static str> {
341    if std::env::var("HARN_FUN").ok().as_deref() != Some("1") {
342        return None;
343    }
344
345    Some(match severity {
346        "error" => "the compiler stepped on a rake here.",
347        "warning" => "this still runs, but it has strong 'double-check me' energy.",
348        _ => "a tiny gremlin has left a note in the margins.",
349    })
350}
351
352pub fn parser_error_message(err: &ParserError) -> String {
353    match err {
354        ParserError::Unexpected { got, expected, .. } => {
355            format!("expected {expected}, found {got}")
356        }
357        ParserError::UnexpectedEof { expected, .. } => {
358            format!("unexpected end of file, expected {expected}")
359        }
360    }
361}
362
363pub fn parser_error_label(err: &ParserError) -> &'static str {
364    match err {
365        ParserError::Unexpected { got, .. } if got == "Newline" => "line break not allowed here",
366        ParserError::Unexpected { .. } => "unexpected token",
367        ParserError::UnexpectedEof { .. } => "file ends here",
368    }
369}
370
371pub fn parser_error_help(err: &ParserError) -> Option<&'static str> {
372    match err {
373        ParserError::UnexpectedEof { expected, .. } | ParserError::Unexpected { expected, .. } => {
374            match expected.as_str() {
375                "}" => Some("add a closing `}` to finish this block"),
376                ")" => Some("add a closing `)` to finish this expression or parameter list"),
377                "]" => Some("add a closing `]` to finish this list or subscript"),
378                "fn, struct, enum, or pipeline after pub" => {
379                    Some("use `pub fn`, `pub pipeline`, `pub enum`, or `pub struct`")
380                }
381                _ => None,
382            }
383        }
384    }
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390
391    /// Ensure ANSI colors are off so plain-text assertions work regardless
392    /// of whether the test runner's stderr is a TTY.
393    fn disable_colors() {
394        std::env::set_var("NO_COLOR", "1");
395    }
396
397    #[test]
398    fn test_basic_diagnostic() {
399        disable_colors();
400        let source = "pipeline default(task) {\n    let y = x + 1\n}";
401        let span = Span {
402            start: 28,
403            end: 29,
404            line: 2,
405            column: 13,
406            end_line: 2,
407        };
408        let output = render_diagnostic(
409            source,
410            "example.harn",
411            &span,
412            "error",
413            "undefined variable `x`",
414            Some("not found in this scope"),
415            None,
416        );
417        assert!(output.contains("error: undefined variable `x`"));
418        assert!(output.contains("--> example.harn:2:13"));
419        assert!(output.contains("let y = x + 1"));
420        assert!(output.contains("^ not found in this scope"));
421    }
422
423    #[test]
424    fn test_diagnostic_normalizes_filename() {
425        disable_colors();
426        let source = "let value = thing";
427        let span = Span {
428            start: 12,
429            end: 17,
430            line: 1,
431            column: 13,
432            end_line: 1,
433        };
434        let output = render_diagnostic(
435            source,
436            "/workspace/pipelines/mode/../lib/runtime/loop.harn",
437            &span,
438            "error",
439            "bad value",
440            Some("here"),
441            None,
442        );
443        assert!(output.contains("--> /workspace/pipelines/lib/runtime/loop.harn:1:13"));
444        assert!(!output.contains("/../"));
445    }
446
447    #[test]
448    fn test_diagnostic_with_help() {
449        disable_colors();
450        let source = "let y = xx + 1";
451        let span = Span {
452            start: 8,
453            end: 10,
454            line: 1,
455            column: 9,
456            end_line: 1,
457        };
458        let output = render_diagnostic(
459            source,
460            "test.harn",
461            &span,
462            "error",
463            "undefined variable `xx`",
464            Some("not found in this scope"),
465            Some("did you mean `x`?"),
466        );
467        assert!(output.contains("help: did you mean `x`?"));
468    }
469
470    #[test]
471    fn test_multiline_source() {
472        disable_colors();
473        let source = "line1\nline2\nline3";
474        let span = Span::with_offsets(6, 11, 2, 1); // "line2"
475        let result = render_diagnostic(
476            source,
477            "test.harn",
478            &span,
479            "error",
480            "bad line",
481            Some("here"),
482            None,
483        );
484        assert!(result.contains("line2"));
485        assert!(result.contains("^^^^^"));
486    }
487
488    #[test]
489    fn test_single_char_span() {
490        disable_colors();
491        let source = "let x = 42";
492        let span = Span::with_offsets(4, 5, 1, 5); // "x"
493        let result = render_diagnostic(
494            source,
495            "test.harn",
496            &span,
497            "warning",
498            "unused",
499            Some("never used"),
500            None,
501        );
502        assert!(result.contains("^"));
503        assert!(result.contains("never used"));
504    }
505
506    #[test]
507    fn test_with_help() {
508        disable_colors();
509        let source = "let y = reponse";
510        let span = Span::with_offsets(8, 15, 1, 9);
511        let result = render_diagnostic(
512            source,
513            "test.harn",
514            &span,
515            "error",
516            "undefined",
517            None,
518            Some("did you mean `response`?"),
519        );
520        assert!(result.contains("help:"));
521        assert!(result.contains("response"));
522    }
523
524    #[test]
525    fn test_parser_error_helpers_for_eof() {
526        disable_colors();
527        let err = ParserError::UnexpectedEof {
528            expected: "}".into(),
529            span: Span::with_offsets(10, 10, 3, 1),
530        };
531        assert_eq!(
532            parser_error_message(&err),
533            "unexpected end of file, expected }"
534        );
535        assert_eq!(parser_error_label(&err), "file ends here");
536        assert_eq!(
537            parser_error_help(&err),
538            Some("add a closing `}` to finish this block")
539        );
540    }
541}