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