Skip to main content

hk_parser/
error.rs

1use colored::Colorize;
2use std::io;
3use thiserror::Error;
4
5/// Custom error type for parsing .hk files.
6#[derive(Error, Debug)]
7pub enum HkError {
8    #[error("IO error: {0}")]
9    Io(#[from] io::Error),
10    #[error("Parse error at line {line}, column {column}: {message}")]
11    Parse {
12        line: u32,
13        column: usize,
14        message: String,
15    },
16    #[error("Type mismatch: expected {expected}, found {found}")]
17    TypeMismatch { expected: String, found: String },
18    #[error("Missing field: {0}")]
19    MissingField(String),
20    #[error("Invalid reference: {0}")]
21    InvalidReference(String),
22    #[error("Cyclic reference detected: {0}")]
23    CyclicReference(String),
24    #[error("Key conflict: {0}")]
25    KeyConflict(String),
26}
27
28impl HkError {
29    /// Renders the error as a multi-line, rustc-style string: a boxed
30    /// snippet of the surrounding source (when available) with a `^`
31    /// caret under the exact column, plus a short "hint" for common
32    /// mistakes. Returned as a `String` (rather than printed directly)
33    /// so callers can log it, show it in a UI, write it to a file, or
34    /// just print it — `pretty_print` below is a thin convenience
35    /// wrapper around this that prints to stderr, kept for backwards
36    /// compatibility with existing callers.
37    pub fn render(&self, source: &str) -> String {
38        let mut out = String::new();
39        match self {
40            Self::Parse { line, column, message } => {
41                out.push_str(&format!("{} {}\n", "error:".red().bold(), message.bold()));
42                out.push_str(&format!(
43                    "  {} line {}, column {}\n",
44                    "-->".blue().bold(),
45                    line,
46                    column
47                ));
48
49                render_snippet(&mut out, source, *line, *column);
50
51                if let Some(hint) = hint_for(message) {
52                    out.push_str(&format!("  {} {}\n", "hint:".yellow().bold(), hint.cyan()));
53                }
54            }
55            Self::TypeMismatch { expected, found } => {
56                out.push_str(&format!("{} {}\n", "error:".red().bold(), "type mismatch".bold()));
57                out.push_str(&format!("  expected {}, found {}\n", expected.cyan(), found.red()));
58            }
59            Self::InvalidReference(reference) => {
60                out.push_str(&format!("{} {}\n", "error:".red().bold(), "invalid reference".bold()));
61                out.push_str(&format!("  {}\n", reference.red()));
62                out.push_str(&format!(
63                    "  {} the referenced key must exist and be reachable from the top of the file\n",
64                    "hint:".yellow().bold()
65                ));
66            }
67            Self::CyclicReference(path) => {
68                out.push_str(&format!("{} {}\n", "error:".red().bold(), "cyclic reference".bold()));
69                out.push_str(&format!("  {}\n", path.red()));
70                out.push_str(&format!(
71                    "  {} this key (transitively) refers back to itself through `${{...}}` interpolation\n",
72                    "hint:".yellow().bold()
73                ));
74            }
75            Self::KeyConflict(key) => {
76                out.push_str(&format!("{} {}\n", "error:".red().bold(), "key conflict".bold()));
77                out.push_str(&format!("  duplicate key '{}' in the same map\n", key.red()));
78            }
79            Self::MissingField(field) => {
80                out.push_str(&format!("{} {}\n", "error:".red().bold(), "missing field".bold()));
81                out.push_str(&format!("  '{}' is required but was not found\n", field.red()));
82            }
83            Self::Io(e) => {
84                out.push_str(&format!("{} {}\n", "error:".red().bold(), "I/O error".bold()));
85                out.push_str(&format!("  {}\n", e.to_string().red()));
86            }
87        }
88        out
89    }
90
91    /// Prints `render(source)` to stderr. Kept for backwards compatibility
92    /// with existing callers (e.g. hpm's own CLI) that expect this method
93    /// to exist and print for them.
94    pub fn pretty_print(&self, source: &str) {
95        eprint!("{}", self.render(source));
96    }
97}
98
99/// Appends a boxed source snippet (one line of context before/after the
100/// error line when available, the error line itself, and a caret line)
101/// to `out`. Column is 1-indexed and measured in `char`s, matching how
102/// the parser computes it, so this handles non-ASCII lines correctly —
103/// unlike the pre-3.2 version, which repeated `column` literal spaces
104/// (a byte count) and drifted on any line with multi-byte characters
105/// before the error column.
106fn render_snippet(out: &mut String, source: &str, line: u32, column: usize) {
107    if line == 0 {
108        return;
109    }
110    let idx = (line - 1) as usize;
111    let lines: Vec<&str> = source.lines().collect();
112    let Some(err_line) = lines.get(idx) else {
113        return;
114    };
115
116    // Width of the widest line-number gutter we'll print, so the `|`
117    // separators line up even when going from e.g. line 9 to line 10.
118    let gutter_width = line.to_string().len();
119
120    let print_gutter_line = |num: Option<u32>, content: &str, out: &mut String| match num {
121        Some(n) => out.push_str(&format!(
122            "  {:>width$} {} {}\n",
123            n.to_string().blue().bold(),
124            "|".blue().bold(),
125            content,
126            width = gutter_width
127        )),
128        None => out.push_str(&format!(
129            "  {:>width$} {}\n",
130            "",
131            "|".blue().bold(),
132            width = gutter_width
133        )),
134    };
135
136    if line > 1 {
137        if let Some(prev) = lines.get(idx - 1) {
138            print_gutter_line(Some(line - 1), prev, out);
139        }
140    }
141    print_gutter_line(Some(line), err_line, out);
142
143    // Caret line: one space per *character* (not byte) before the target
144    // column, so it lands under the right character even with non-ASCII
145    // text earlier on the line.
146    let caret_offset = column.saturating_sub(1);
147    let caret_line = format!("{}{}", " ".repeat(caret_offset), "^".red().bold());
148    out.push_str(&format!(
149        "  {:>width$} {} {}\n",
150        "",
151        "|".blue().bold(),
152        caret_line,
153        width = gutter_width
154    ));
155
156    if let Some(next) = lines.get(idx + 1) {
157        print_gutter_line(Some(line + 1), next, out);
158    }
159}
160
161/// Maps a parser error message to a short, actionable hint. Matched
162/// against the exact messages `parse_hk`/`parse_map` actually produce
163/// (see `src/parser.rs`) — earlier revisions of this function matched
164/// nom-style fragments like `tag "=>"` that this hand-written parser
165/// never emits, so no hint ever fired in practice.
166fn hint_for(message: &str) -> Option<&'static str> {
167    if message.contains("Expected key or map header") {
168        Some("every non-blank, non-comment line must start with one or more '-' followed by '>', e.g. \"-> key => value\"")
169    } else if message.contains("Expected '>' after dashes") {
170        Some("dashes must be immediately followed by '>', e.g. \"-> key\" not \"- key\" or \"->key \"")
171    } else if message.contains("Missing key after '>'") || message.contains("Empty key") || message.contains("Empty map key") {
172        Some("write a key name right after '>', e.g. \"-> name => value\"")
173    } else if message.contains("Inconsistent nesting level") {
174        Some("nesting must increase by exactly one dash per level: '->', then '-->', then '--->' — don't skip a level")
175    } else if message.contains("Unclosed array") {
176        Some("every '[' that opens an array needs a matching ']' — check for a missing closing bracket")
177    } else if message.contains("Unclosed section header") {
178        Some("section headers need a closing ']', e.g. \"[metadata]\"")
179    } else if message.contains("Empty section name") {
180        Some("put a name between the brackets, e.g. \"[metadata]\" not \"[]\"")
181    } else if message.contains("Expected section header") {
182        Some("top-level content must start with a \"[section]\" header before any \"-> key => value\" lines")
183    } else if message.contains("Empty value") {
184        Some("there's nothing after '=>' — remove the key or give it a value")
185    } else {
186        None
187    }
188}