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