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