Skip to main content

harn_parser/
diagnostic.rs

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