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