use colored::Colorize;
use std::io;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum HkError {
#[error("IO error: {0}")]
Io(#[from] io::Error),
#[error("Parse error at line {line}, column {column}: {message}")]
Parse {
line: u32,
column: usize,
message: String,
},
#[error("Type mismatch: expected {expected}, found {found}")]
TypeMismatch { expected: String, found: String },
#[error("Missing field: {0}")]
MissingField(String),
#[error("Invalid reference: {0}")]
InvalidReference(String),
#[error("Cyclic reference detected: {0}")]
CyclicReference(String),
#[error("Key conflict: {0}")]
KeyConflict(String),
}
impl HkError {
pub fn render(&self, source: &str) -> String {
let mut out = String::new();
match self {
Self::Parse { line, column, message } => {
out.push_str(&format!("{} {}\n", "error:".red().bold(), message.bold()));
out.push_str(&format!(
" {} line {}, column {}\n",
"-->".blue().bold(),
line,
column
));
render_snippet(&mut out, source, *line, *column);
if let Some(hint) = hint_for(message) {
out.push_str(&format!(" {} {}\n", "hint:".yellow().bold(), hint.cyan()));
}
}
Self::TypeMismatch { expected, found } => {
out.push_str(&format!("{} {}\n", "error:".red().bold(), "type mismatch".bold()));
out.push_str(&format!(" expected {}, found {}\n", expected.cyan(), found.red()));
}
Self::InvalidReference(reference) => {
out.push_str(&format!("{} {}\n", "error:".red().bold(), "invalid reference".bold()));
out.push_str(&format!(" {}\n", reference.red()));
out.push_str(&format!(
" {} the referenced key must exist and be reachable from the top of the file\n",
"hint:".yellow().bold()
));
}
Self::CyclicReference(path) => {
out.push_str(&format!("{} {}\n", "error:".red().bold(), "cyclic reference".bold()));
out.push_str(&format!(" {}\n", path.red()));
out.push_str(&format!(
" {} this key (transitively) refers back to itself through `${{...}}` interpolation\n",
"hint:".yellow().bold()
));
}
Self::KeyConflict(key) => {
out.push_str(&format!("{} {}\n", "error:".red().bold(), "key conflict".bold()));
out.push_str(&format!(" duplicate key '{}' in the same map\n", key.red()));
}
Self::MissingField(field) => {
out.push_str(&format!("{} {}\n", "error:".red().bold(), "missing field".bold()));
out.push_str(&format!(" '{}' is required but was not found\n", field.red()));
}
Self::Io(e) => {
out.push_str(&format!("{} {}\n", "error:".red().bold(), "I/O error".bold()));
out.push_str(&format!(" {}\n", e.to_string().red()));
}
}
out
}
pub fn pretty_print(&self, source: &str) {
eprint!("{}", self.render(source));
}
}
fn render_snippet(out: &mut String, source: &str, line: u32, column: usize) {
if line == 0 {
return;
}
let idx = (line - 1) as usize;
let lines: Vec<&str> = source.lines().collect();
let Some(err_line) = lines.get(idx) else {
return;
};
let gutter_width = line.to_string().len();
let print_gutter_line = |num: Option<u32>, content: &str, out: &mut String| match num {
Some(n) => out.push_str(&format!(
" {:>width$} {} {}\n",
n.to_string().blue().bold(),
"|".blue().bold(),
content,
width = gutter_width
)),
None => out.push_str(&format!(
" {:>width$} {}\n",
"",
"|".blue().bold(),
width = gutter_width
)),
};
if line > 1 {
if let Some(prev) = lines.get(idx - 1) {
print_gutter_line(Some(line - 1), prev, out);
}
}
print_gutter_line(Some(line), err_line, out);
let caret_offset = column.saturating_sub(1);
let caret_line = format!("{}{}", " ".repeat(caret_offset), "^".red().bold());
out.push_str(&format!(
" {:>width$} {} {}\n",
"",
"|".blue().bold(),
caret_line,
width = gutter_width
));
if let Some(next) = lines.get(idx + 1) {
print_gutter_line(Some(line + 1), next, out);
}
}
fn hint_for(message: &str) -> Option<&'static str> {
if message.contains("Expected key or map header") {
Some("every non-blank, non-comment line must start with one or more '-' followed by '>', e.g. \"-> key => value\"")
} else if message.contains("Expected '>' after dashes") {
Some("dashes must be immediately followed by '>', e.g. \"-> key\" not \"- key\" or \"->key \"")
} else if message.contains("Missing key after '>'") || message.contains("Empty key") || message.contains("Empty map key") {
Some("write a key name right after '>', e.g. \"-> name => value\"")
} else if message.contains("Inconsistent nesting level") {
Some("nesting must increase by exactly one dash per level: '->', then '-->', then '--->' — don't skip a level")
} else if message.contains("Unclosed array") {
Some("every '[' that opens an array needs a matching ']' — check for a missing closing bracket")
} else if message.contains("Unclosed section header") {
Some("section headers need a closing ']', e.g. \"[metadata]\"")
} else if message.contains("Empty section name") {
Some("put a name between the brackets, e.g. \"[metadata]\" not \"[]\"")
} else if message.contains("Expected section header") {
Some("top-level content must start with a \"[section]\" header before any \"-> key => value\" lines")
} else if message.contains("Empty value") {
Some("there's nothing after '=>' — remove the key or give it a value")
} else {
None
}
}