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/// Map an ambient clock-capability builtin to its `harness.clock.*`
105/// replacement. Returns the new identifier text (including the receiver
106/// path) so the `bindings/thread-harness-clock` repair can replace the
107/// call-site identifier in place. The mapping is the source of truth for
108/// the E4.3 → E4.6 migration; downstream replatform agents query it via
109/// [`Code::repair_template`].
110pub fn harness_clock_replacement(name: &str) -> Option<&'static str> {
111    match name {
112        "now_ms" => Some("harness.clock.now_ms"),
113        "monotonic_ms" => Some("harness.clock.monotonic_ms"),
114        "sleep_ms" => Some("harness.clock.sleep_ms"),
115        "timestamp" => Some("harness.clock.timestamp"),
116        "elapsed" => Some("harness.clock.elapsed"),
117        _ => None,
118    }
119}
120
121/// Map an ambient stdio-capability builtin to its `harness.stdio.*`
122/// replacement so the `bindings/thread-harness-stdio` repair can replace
123/// the call when a harness binding is already in scope.
124pub fn harness_stdio_replacement(name: &str) -> Option<&'static str> {
125    match name {
126        "print" => Some("harness.stdio.print"),
127        "println" => Some("harness.stdio.println"),
128        "eprint" => Some("harness.stdio.eprint"),
129        "eprintln" => Some("harness.stdio.eprintln"),
130        "read_line" => Some("harness.stdio.read_line"),
131        "prompt_user" => Some("harness.stdio.prompt"),
132        _ => None,
133    }
134}
135
136/// Render a Rust-style diagnostic message.
137///
138/// Example output:
139/// ```text
140/// error: undefined variable `x`
141///   --> example.harn:5:12
142///    |
143///  5 |     let y = x + 1
144///    |             ^ not found in this scope
145/// ```
146pub fn render_diagnostic(
147    source: &str,
148    filename: &str,
149    span: &Span,
150    severity: &str,
151    message: &str,
152    label: Option<&str>,
153    help: Option<&str>,
154) -> String {
155    render_diagnostic_inner(RenderDiagnostic {
156        source,
157        filename,
158        span,
159        severity,
160        code: None,
161        message,
162        label,
163        help,
164        related: &[],
165        repair: None,
166    })
167}
168
169pub fn render_diagnostic_with_code(
170    source: &str,
171    filename: &str,
172    span: &Span,
173    severity: &str,
174    code: crate::diagnostic_codes::Code,
175    message: &str,
176    label: Option<&str>,
177    help: Option<&str>,
178) -> String {
179    let repair_owned = code.repair_template().map(Repair::from_template);
180    render_diagnostic_inner(RenderDiagnostic {
181        source,
182        filename,
183        span,
184        severity,
185        code: Some(code.as_str()),
186        message,
187        label,
188        help,
189        related: &[],
190        repair: repair_owned.as_ref(),
191    })
192}
193
194pub fn render_diagnostic_with_related(
195    source: &str,
196    filename: &str,
197    span: &Span,
198    severity: &str,
199    message: &str,
200    label: Option<&str>,
201    help: Option<&str>,
202    related: &[RelatedSpanLabel<'_>],
203) -> String {
204    render_diagnostic_inner(RenderDiagnostic {
205        source,
206        filename,
207        span,
208        severity,
209        code: None,
210        message,
211        label,
212        help,
213        related,
214        repair: None,
215    })
216}
217
218struct RenderDiagnostic<'a> {
219    source: &'a str,
220    filename: &'a str,
221    span: &'a Span,
222    severity: &'a str,
223    code: Option<&'a str>,
224    message: &'a str,
225    label: Option<&'a str>,
226    help: Option<&'a str>,
227    related: &'a [RelatedSpanLabel<'a>],
228    repair: Option<&'a Repair>,
229}
230
231fn render_diagnostic_inner(input: RenderDiagnostic<'_>) -> String {
232    let mut out = String::new();
233    let source = input.source;
234    let span = input.span;
235    let severity = input.severity;
236    let message = input.message;
237    let label = input.label;
238    let help = input.help;
239    let related = input.related;
240    let filename = normalize_diagnostic_path(input.filename);
241    let severity_color = severity_color(severity);
242    let gutter = style_fragment("|", Color::Blue, false);
243    let arrow = style_fragment("-->", Color::Blue, true);
244    let help_prefix = style_fragment("help", Color::Cyan, true);
245    let note_prefix = style_fragment("note", Color::Magenta, true);
246
247    out.push_str(&style_fragment(severity, severity_color, true));
248    if let Some(code) = input.code {
249        out.push('[');
250        out.push_str(code);
251        out.push(']');
252    }
253    out.push_str(": ");
254    out.push_str(message);
255    out.push('\n');
256
257    let line_num = span.line;
258    let col_num = span.column;
259
260    let gutter_width = line_num.to_string().len();
261
262    out.push_str(&format!(
263        "{:>width$}{arrow} {filename}:{line_num}:{col_num}\n",
264        " ",
265        width = gutter_width + 1,
266    ));
267
268    out.push_str(&format!(
269        "{:>width$} {gutter}\n",
270        " ",
271        width = gutter_width + 1,
272    ));
273
274    let source_line_opt = source.lines().nth(line_num.wrapping_sub(1));
275    if let Some(source_line) = source_line_opt.filter(|_| line_num > 0) {
276        out.push_str(&format!(
277            "{:>width$} {gutter} {source_line}\n",
278            line_num,
279            width = gutter_width + 1,
280        ));
281
282        if let Some(label_text) = label {
283            // Span width must use char count, not byte offsets, so carets align with the source text.
284            let span_len = if span.end > span.start && span.start <= source.len() {
285                let span_text = &source[span.start.min(source.len())..span.end.min(source.len())];
286                span_text.chars().count().max(1)
287            } else {
288                1
289            };
290            let col_num = col_num.max(1);
291            let padding = " ".repeat(col_num - 1);
292            let carets = style_fragment(&"^".repeat(span_len), severity_color, true);
293            out.push_str(&format!(
294                "{:>width$} {gutter} {padding}{carets} {label_text}\n",
295                " ",
296                width = gutter_width + 1,
297            ));
298        }
299    }
300
301    if let Some(help_text) = help {
302        out.push_str(&format!(
303            "{:>width$} = {help_prefix}: {help_text}\n",
304            " ",
305            width = gutter_width + 1,
306        ));
307    }
308
309    if let Some(repair) = input.repair {
310        let repair_prefix = style_fragment("repair", Color::Cyan, true);
311        out.push_str(&format!(
312            "{:>width$} = {repair_prefix}: {} [{}] — {}\n",
313            " ",
314            repair.id,
315            repair.safety,
316            repair.summary,
317            width = gutter_width + 1,
318        ));
319    }
320
321    for item in related {
322        out.push_str(&format!(
323            "{:>width$} = {note_prefix}: {}\n",
324            " ",
325            item.label,
326            width = gutter_width + 1,
327        ));
328        render_related_span(
329            &mut out,
330            source,
331            &filename,
332            item.span,
333            item.label,
334            gutter_width,
335        );
336    }
337
338    if let Some(note_text) = fun_note(severity) {
339        out.push_str(&format!(
340            "{:>width$} = {note_prefix}: {note_text}\n",
341            " ",
342            width = gutter_width + 1,
343        ));
344    }
345
346    out
347}
348
349pub fn render_type_diagnostic(
350    source: &str,
351    filename: &str,
352    diag: &crate::typechecker::TypeDiagnostic,
353) -> String {
354    let severity = match diag.severity {
355        crate::typechecker::DiagnosticSeverity::Error => "error",
356        crate::typechecker::DiagnosticSeverity::Warning => "warning",
357    };
358    let related = diag
359        .related
360        .iter()
361        .map(|related| RelatedSpanLabel {
362            span: &related.span,
363            label: &related.message,
364        })
365        .collect::<Vec<_>>();
366    let primary_label = type_diagnostic_primary_label(diag);
367    match &diag.span {
368        Some(span) => render_diagnostic_inner(RenderDiagnostic {
369            source,
370            filename,
371            span,
372            severity,
373            code: Some(diag.code.as_str()),
374            message: &diag.message,
375            label: primary_label.as_deref(),
376            help: diag.help.as_deref(),
377            related: &related,
378            repair: diag.repair.as_ref(),
379        }),
380        None => match diag.repair.as_ref() {
381            Some(repair) => format!(
382                "{severity}[{}]: {}\n  = repair: {} [{}] — {}\n",
383                diag.code, diag.message, repair.id, repair.safety, repair.summary,
384            ),
385            None => format!("{severity}[{}]: {}\n", diag.code, diag.message),
386        },
387    }
388}
389
390pub fn lexer_error_code(err: &harn_lexer::LexerError) -> crate::diagnostic_codes::Code {
391    match err {
392        harn_lexer::LexerError::UnexpectedCharacter(_, _) => {
393            crate::diagnostic_codes::Code::ParserUnexpectedCharacter
394        }
395        harn_lexer::LexerError::UnterminatedString(_) => {
396            crate::diagnostic_codes::Code::ParserUnterminatedString
397        }
398        harn_lexer::LexerError::UnterminatedBlockComment(_) => {
399            crate::diagnostic_codes::Code::ParserUnterminatedBlockComment
400        }
401    }
402}
403
404pub fn parser_error_code(err: &crate::parser::ParserError) -> crate::diagnostic_codes::Code {
405    match err {
406        crate::parser::ParserError::Unexpected { .. } => {
407            crate::diagnostic_codes::Code::ParserUnexpectedToken
408        }
409        crate::parser::ParserError::UnexpectedEof { .. } => {
410            crate::diagnostic_codes::Code::ParserUnexpectedEof
411        }
412    }
413}
414
415fn type_diagnostic_primary_label(diag: &crate::typechecker::TypeDiagnostic) -> Option<String> {
416    match &diag.details {
417        Some(crate::typechecker::DiagnosticDetails::LintRule { rule }) => {
418            Some(format!("lint[{rule}]"))
419        }
420        Some(crate::typechecker::DiagnosticDetails::TypeMismatch) => {
421            Some("found this type".to_string())
422        }
423        _ => None,
424    }
425}
426
427fn render_related_span(
428    out: &mut String,
429    source: &str,
430    filename: &str,
431    span: &Span,
432    label: &str,
433    primary_gutter_width: usize,
434) {
435    let filename = normalize_diagnostic_path(filename);
436    let severity_color = Color::Magenta;
437    let gutter = style_fragment("|", Color::Blue, false);
438    let arrow = style_fragment("-->", Color::Blue, true);
439    let line_num = span.line;
440    let col_num = span.column;
441    let gutter_width = primary_gutter_width.max(line_num.to_string().len());
442
443    out.push_str(&format!(
444        "{:>width$}{arrow} {filename}:{line_num}:{col_num}\n",
445        " ",
446        width = gutter_width + 1,
447    ));
448    out.push_str(&format!(
449        "{:>width$} {gutter}\n",
450        " ",
451        width = gutter_width + 1,
452    ));
453
454    if let Some(source_line) = source
455        .lines()
456        .nth(line_num.wrapping_sub(1))
457        .filter(|_| line_num > 0)
458    {
459        out.push_str(&format!(
460            "{:>width$} {gutter} {source_line}\n",
461            line_num,
462            width = gutter_width + 1,
463        ));
464        let span_len = if span.end > span.start && span.start <= source.len() {
465            let span_text = &source[span.start.min(source.len())..span.end.min(source.len())];
466            span_text.chars().count().max(1)
467        } else {
468            1
469        };
470        let padding = " ".repeat(col_num.max(1) - 1);
471        let carets = style_fragment(&"^".repeat(span_len), severity_color, true);
472        out.push_str(&format!(
473            "{:>width$} {gutter} {padding}{carets} {label}\n",
474            " ",
475            width = gutter_width + 1,
476        ));
477    }
478}
479
480fn severity_color(severity: &str) -> Color {
481    match severity {
482        "error" => Color::Red,
483        "warning" => Color::Yellow,
484        "note" => Color::Magenta,
485        _ => Color::Cyan,
486    }
487}
488
489fn style_fragment(text: &str, color: Color, bold: bool) -> String {
490    if !colors_enabled() {
491        return text.to_string();
492    }
493
494    let mut paint = Paint::new(text).fg(color);
495    if bold {
496        paint = paint.bold();
497    }
498    paint.to_string()
499}
500
501fn colors_enabled() -> bool {
502    std::env::var_os("NO_COLOR").is_none() && std::io::stderr().is_terminal()
503}
504
505fn fun_note(severity: &str) -> Option<&'static str> {
506    if std::env::var("HARN_FUN").ok().as_deref() != Some("1") {
507        return None;
508    }
509
510    Some(match severity {
511        "error" => "the compiler stepped on a rake here.",
512        "warning" => "this still runs, but it has strong 'double-check me' energy.",
513        _ => "a tiny gremlin has left a note in the margins.",
514    })
515}
516
517pub fn parser_error_message(err: &ParserError) -> String {
518    match err {
519        ParserError::Unexpected { got, expected, .. } => {
520            format!("expected {expected}, found {got}")
521        }
522        ParserError::UnexpectedEof { expected, .. } => {
523            format!("unexpected end of file, expected {expected}")
524        }
525    }
526}
527
528pub fn parser_error_label(err: &ParserError) -> &'static str {
529    match err {
530        ParserError::Unexpected { got, .. } if got == "Newline" => "line break not allowed here",
531        ParserError::Unexpected { .. } => "unexpected token",
532        ParserError::UnexpectedEof { .. } => "file ends here",
533    }
534}
535
536pub fn parser_error_help(err: &ParserError) -> Option<&'static str> {
537    match err {
538        ParserError::UnexpectedEof { expected, .. } | ParserError::Unexpected { expected, .. } => {
539            match expected.as_str() {
540                "}" => Some("add a closing `}` to finish this block"),
541                ")" => Some("add a closing `)` to finish this expression or parameter list"),
542                "]" => Some("add a closing `]` to finish this list or subscript"),
543                "fn, struct, enum, or pipeline after pub" => {
544                    Some("use `pub fn`, `pub pipeline`, `pub enum`, or `pub struct`")
545                }
546                _ => None,
547            }
548        }
549    }
550}
551
552#[cfg(test)]
553mod tests {
554    use super::*;
555
556    /// Ensure ANSI colors are off so plain-text assertions work regardless
557    /// of whether the test runner's stderr is a TTY.
558    fn disable_colors() {
559        std::env::set_var("NO_COLOR", "1");
560    }
561
562    #[test]
563    fn test_basic_diagnostic() {
564        disable_colors();
565        let source = "pipeline default(task) {\n    let y = x + 1\n}";
566        let span = Span {
567            start: 28,
568            end: 29,
569            line: 2,
570            column: 13,
571            end_line: 2,
572        };
573        let output = render_diagnostic(
574            source,
575            "example.harn",
576            &span,
577            "error",
578            "undefined variable `x`",
579            Some("not found in this scope"),
580            None,
581        );
582        assert!(output.contains("error: undefined variable `x`"));
583        assert!(output.contains("--> example.harn:2:13"));
584        assert!(output.contains("let y = x + 1"));
585        assert!(output.contains("^ not found in this scope"));
586    }
587
588    #[test]
589    fn test_diagnostic_normalizes_filename() {
590        disable_colors();
591        let source = "let value = thing";
592        let span = Span {
593            start: 12,
594            end: 17,
595            line: 1,
596            column: 13,
597            end_line: 1,
598        };
599        let output = render_diagnostic(
600            source,
601            "/workspace/pipelines/mode/../lib/runtime/loop.harn",
602            &span,
603            "error",
604            "bad value",
605            Some("here"),
606            None,
607        );
608        assert!(output.contains("--> /workspace/pipelines/lib/runtime/loop.harn:1:13"));
609        assert!(!output.contains("/../"));
610    }
611
612    #[test]
613    fn test_diagnostic_with_help() {
614        disable_colors();
615        let source = "let y = xx + 1";
616        let span = Span {
617            start: 8,
618            end: 10,
619            line: 1,
620            column: 9,
621            end_line: 1,
622        };
623        let output = render_diagnostic(
624            source,
625            "test.harn",
626            &span,
627            "error",
628            "undefined variable `xx`",
629            Some("not found in this scope"),
630            Some("did you mean `x`?"),
631        );
632        assert!(output.contains("help: did you mean `x`?"));
633    }
634
635    #[test]
636    fn test_multiline_source() {
637        disable_colors();
638        let source = "line1\nline2\nline3";
639        let span = Span::with_offsets(6, 11, 2, 1); // "line2"
640        let result = render_diagnostic(
641            source,
642            "test.harn",
643            &span,
644            "error",
645            "bad line",
646            Some("here"),
647            None,
648        );
649        assert!(result.contains("line2"));
650        assert!(result.contains("^^^^^"));
651    }
652
653    #[test]
654    fn test_single_char_span() {
655        disable_colors();
656        let source = "let x = 42";
657        let span = Span::with_offsets(4, 5, 1, 5); // "x"
658        let result = render_diagnostic(
659            source,
660            "test.harn",
661            &span,
662            "warning",
663            "unused",
664            Some("never used"),
665            None,
666        );
667        assert!(result.contains("^"));
668        assert!(result.contains("never used"));
669    }
670
671    #[test]
672    fn test_with_help() {
673        disable_colors();
674        let source = "let y = reponse";
675        let span = Span::with_offsets(8, 15, 1, 9);
676        let result = render_diagnostic(
677            source,
678            "test.harn",
679            &span,
680            "error",
681            "undefined",
682            None,
683            Some("did you mean `response`?"),
684        );
685        assert!(result.contains("help:"));
686        assert!(result.contains("response"));
687    }
688
689    #[test]
690    fn test_parser_error_helpers_for_eof() {
691        disable_colors();
692        let err = ParserError::UnexpectedEof {
693            expected: "}".into(),
694            span: Span::with_offsets(10, 10, 3, 1),
695        };
696        assert_eq!(
697            parser_error_message(&err),
698            "unexpected end of file, expected }"
699        );
700        assert_eq!(parser_error_label(&err), "file ends here");
701        assert_eq!(
702            parser_error_help(&err),
703            Some("add a closing `}` to finish this block")
704        );
705    }
706}