1use colored::Colorize;
2use std::io;
3use thiserror::Error;
4
5#[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 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 pub fn pretty_print(&self, source: &str) {
95 eprint!("{}", self.render(source));
96 }
97}
98
99fn 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 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 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
161fn 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}