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