use regex::Regex;
use std::sync::OnceLock;
#[allow(non_snake_case)]
pub fn NON_PRINTABLE_RE() -> &'static Regex {
static R: OnceLock<Regex> = OnceLock::new();
R.get_or_init(|| {
Regex::new(r"[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]").unwrap()
})
}
pub fn repl(matched: &str) -> String {
let cp = matched.chars().next().map(|c| c as u32).unwrap_or(0);
format!("<x{:04x}>", cp)
}
pub fn strtrans(s: &str) -> String {
let tabs = s.replace('\t', ">---");
NON_PRINTABLE_RE()
.replace_all(&tabs, |caps: ®ex::Captures<'_>| repl(&caps[0]))
.into_owned()
}
#[derive(Debug, Clone)]
pub struct RichMark {
pub name: String,
pub line: usize,
pub column: usize,
pub buffer: Option<Vec<char>>,
pub pointer: usize,
pub old_mark: Option<Box<RichMark>>,
pub merged_marks: Vec<RichMark>,
}
impl RichMark {
pub fn new(
name: impl Into<String>,
line: usize,
column: usize,
buffer: Option<Vec<char>>,
pointer: usize,
) -> Self {
Self {
name: name.into(),
line,
column,
buffer,
pointer,
old_mark: None,
merged_marks: Vec::new(),
}
}
pub fn copy(&self) -> RichMark {
self.clone()
}
pub fn get_snippet(&self, indent: usize, max_length: usize) -> Option<String> {
let buf = self.buffer.as_ref()?;
let mut head = String::new();
let mut start = self.pointer;
while start > 0 && !matches!(buf.get(start - 1), Some('\0') | Some('\n')) {
start -= 1;
if self.pointer.saturating_sub(start) > max_length / 2 - 1 {
head = " ... ".to_string();
start += 5;
break;
}
}
let mut tail = String::new();
let mut end = self.pointer;
while end < buf.len() && !matches!(buf.get(end), Some('\0') | Some('\n')) {
end += 1;
if end - self.pointer > max_length / 2 - 1 {
tail = " ... ".to_string();
end -= 5;
break;
}
}
let pre: String = buf[start..self.pointer].iter().collect();
let ch: String = buf.get(self.pointer).copied().unwrap_or('\0').to_string();
let post: String = buf
.get(self.pointer + 1..end.min(buf.len()))
.map(|s| s.iter().collect())
.unwrap_or_default();
let snippet = [strtrans(&pre), strtrans(&ch), strtrans(&post)];
let indent_str = " ".repeat(indent);
let caret_pad = " ".repeat(indent + head.len() + snippet[0].len());
Some(format!(
"{}{}{}{}{}{}\n{}^",
indent_str, head, snippet[0], snippet[1], snippet[2], tail, caret_pad
))
}
pub fn advance_string(&self, diff: usize) -> RichMark {
let mut ret = self.copy();
ret.column += diff;
ret.pointer += diff;
ret
}
pub fn set_old_mark(&mut self, old_mark: RichMark) -> Result<(), &'static str> {
if std::ptr::eq(self as *const _, &old_mark as *const _) {
return Ok(());
}
let mut seen: Vec<(String, usize, usize)> =
vec![(self.name.clone(), self.line, self.column)];
let mut cursor: Option<&RichMark> = Some(&old_mark);
while let Some(m) = cursor {
let id = (m.name.clone(), m.line, m.column);
if seen.contains(&id) {
return Err("Trying to set recursive marks");
}
seen.push(id);
cursor = m.old_mark.as_deref();
}
self.old_mark = Some(Box::new(old_mark));
Ok(())
}
pub fn set_merged_mark(&mut self, merged_mark: RichMark) {
self.merged_marks.push(merged_mark);
}
#[allow(
clippy::wrong_self_convention,
clippy::inherent_to_string_shadow_display
)]
pub fn to_string(&self, indent: usize, head_text: &str, add_snippet: bool) -> String {
self.to_string_marked(indent, head_text, add_snippet)
}
pub fn to_string_marked(&self, indent: usize, head_text: &str, add_snippet: bool) -> String {
let mut where_str = String::new();
let mut cursor: Option<&RichMark> = Some(self);
let mut cur_indent = indent;
let mut processed: Vec<(String, usize, usize)> = Vec::new();
while let Some(mark) = cursor {
let indent_str = " ".repeat(cur_indent);
where_str.push_str(&format!(
"{} {}\"{}\", line {}, column {}",
indent_str,
head_text,
mark.name,
mark.line + 1,
mark.column + 1,
));
if add_snippet {
if let Some(snippet) = mark.get_snippet(cur_indent + 4, 75) {
where_str.push_str(":\n");
where_str.push_str(&snippet);
}
}
if !mark.merged_marks.is_empty() {
where_str.push('\n');
where_str.push_str(&indent_str);
where_str.push_str(" with additionally merged\n");
where_str.push_str(&mark.merged_marks[0].to_string_marked(
cur_indent + 4,
"",
false,
));
for mm in &mark.merged_marks[1..] {
where_str.push('\n');
where_str.push_str(&indent_str);
where_str.push_str(" and\n");
where_str.push_str(&mm.to_string_marked(cur_indent + 4, "", false));
}
}
if add_snippet {
let id = (mark.name.clone(), mark.line, mark.column);
processed.push(id);
if mark.old_mark.is_some() {
where_str.push('\n');
where_str.push_str(&indent_str);
where_str.push_str(" which replaced value\n");
cur_indent += 4;
}
}
cursor = mark.old_mark.as_deref();
if let Some(m) = cursor {
let id = (m.name.clone(), m.line, m.column);
if processed.contains(&id) {
where_str.push_str("\n<recursive mark>");
break;
}
}
}
where_str
}
}
impl std::fmt::Display for RichMark {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.to_string_marked(0, "in ", true))
}
}
impl PartialEq for RichMark {
fn eq(&self, other: &Self) -> bool {
std::ptr::eq(self, other)
|| (self.name == other.name && self.line == other.line && self.column == other.column)
}
}
pub fn format_error(
context: Option<&str>,
context_mark: Option<&RichMark>,
problem: Option<&str>,
problem_mark: Option<&RichMark>,
note: Option<&str>,
indent: usize,
) -> String {
let mut lines: Vec<String> = Vec::new();
let indent_str = " ".repeat(indent);
if let Some(c) = context {
lines.push(format!("{}{}", indent_str, c));
}
if let Some(cm) = context_mark {
let same_as_problem = matches!((problem, problem_mark), (Some(_), Some(pm)) if cm == pm);
if !same_as_problem {
lines.push(cm.to_string_marked(indent, "in ", true));
}
}
if let Some(p) = problem {
lines.push(format!("{}{}", indent_str, p));
}
if let Some(pm) = problem_mark {
lines.push(pm.to_string_marked(indent, "in ", true));
}
if let Some(n) = note {
lines.push(format!("{}{}", indent_str, n));
}
lines.join("\n")
}
pub fn echoerr<W: std::io::Write>(
stream: &mut W,
context: Option<&str>,
context_mark: Option<&RichMark>,
problem: Option<&str>,
problem_mark: Option<&RichMark>,
description: Option<&str>,
indent: usize,
) -> std::io::Result<()> {
stream.write_all(b"\n")?;
let formatted = format_error(
context,
context_mark,
problem,
problem_mark,
description,
indent,
);
stream.write_all(formatted.as_bytes())?;
stream.write_all(b"\n")?;
Ok(())
}
#[derive(Debug, Clone)]
pub struct MarkedError {
pub message: String,
}
impl MarkedError {
pub fn new(
context: Option<&str>,
context_mark: Option<&RichMark>,
problem: Option<&str>,
problem_mark: Option<&RichMark>,
note: Option<&str>,
) -> Self {
Self {
message: format_error(context, context_mark, problem, problem_mark, note, 0),
}
}
}
impl std::fmt::Display for MarkedError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.message)
}
}
impl std::error::Error for MarkedError {}
pub struct EchoErr {
pub indent: usize,
}
impl EchoErr {
pub fn new(indent: usize) -> Self {
Self { indent }
}
}
impl EchoErr {
pub fn call(
&self,
mut kwargs: serde_json::Map<String, serde_json::Value>,
) -> serde_json::Map<String, serde_json::Value> {
kwargs
.entry("indent")
.or_insert_with(|| serde_json::Value::from(self.indent));
kwargs
}
}
pub struct DelayedEchoErr {
pub errs: Vec<Vec<serde_json::Map<String, serde_json::Value>>>,
pub message: String,
pub separator_message: String,
pub indent_shift: usize,
pub indent: usize,
}
impl DelayedEchoErr {
pub fn new(
parent_indent: usize,
message: impl Into<String>,
separator_message: impl Into<String>,
) -> Self {
let message = message.into();
let separator_message = separator_message.into();
let indent_shift = if !message.is_empty() || !separator_message.is_empty() {
4
} else {
0
};
Self {
errs: vec![Vec::new()],
message,
separator_message,
indent_shift,
indent: parent_indent + indent_shift,
}
}
pub fn call(&mut self, mut kwargs: serde_json::Map<String, serde_json::Value>) {
let prev = kwargs.get("indent").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
kwargs.insert(
"indent".to_string(),
serde_json::Value::from(prev + self.indent),
);
if let Some(last) = self.errs.last_mut() {
last.push(kwargs);
}
}
pub fn next_variant(&mut self) {
self.errs.push(Vec::new());
}
pub fn echo_all(&self) -> Vec<serde_json::Map<String, serde_json::Value>> {
let mut out: Vec<serde_json::Map<String, serde_json::Value>> = Vec::new();
if !self.message.is_empty() {
let mut kw = serde_json::Map::new();
kw.insert(
"problem".to_string(),
serde_json::Value::String(self.message.clone()),
);
kw.insert(
"indent".to_string(),
serde_json::Value::from(self.indent - self.indent_shift),
);
out.push(kw);
}
for (i, variant) in self.errs.iter().enumerate() {
if variant.is_empty() {
continue;
}
if !self.separator_message.is_empty() && i > 0 {
let mut kw = serde_json::Map::new();
kw.insert(
"problem".to_string(),
serde_json::Value::String(self.separator_message.clone()),
);
kw.insert(
"indent".to_string(),
serde_json::Value::from(self.indent - self.indent_shift),
);
out.push(kw);
}
for kwargs in variant {
out.push(kwargs.clone());
}
}
out
}
pub fn is_truthy(&self) -> bool {
!self.errs.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn non_printable_re_matches_ctrl_chars() {
assert!(NON_PRINTABLE_RE().is_match("\x07"));
assert!(NON_PRINTABLE_RE().is_match("\x1f"));
assert!(!NON_PRINTABLE_RE().is_match("abc"));
}
#[test]
fn non_printable_re_allows_tab_newline_cr() {
assert!(!NON_PRINTABLE_RE().is_match("\t"));
assert!(!NON_PRINTABLE_RE().is_match("\n"));
assert!(!NON_PRINTABLE_RE().is_match("\r"));
}
#[test]
fn repl_formats_codepoint_as_hex() {
assert_eq!(repl("\x07"), "<x0007>");
assert_eq!(repl("\x1f"), "<x001f>");
assert_eq!(repl("A"), "<x0041>");
}
#[test]
fn strtrans_replaces_tab_with_dashes() {
assert_eq!(strtrans("a\tb"), "a>---b");
}
#[test]
fn strtrans_escapes_non_printable() {
assert_eq!(strtrans("a\x07b"), "a<x0007>b");
}
#[test]
fn strtrans_passes_printable_through() {
assert_eq!(strtrans("hello world"), "hello world");
}
#[test]
fn rich_mark_new_has_no_old_or_merged() {
let m = RichMark::new("f.json", 0, 0, None, 0);
assert!(m.old_mark.is_none());
assert!(m.merged_marks.is_empty());
}
#[test]
fn rich_mark_copy_is_equal() {
let m = RichMark::new("f.json", 3, 4, None, 0);
let c = m.copy();
assert_eq!(m.name, c.name);
assert_eq!(m.line, c.line);
assert_eq!(m.column, c.column);
}
#[test]
fn rich_mark_get_snippet_none_when_no_buffer() {
let m = RichMark::new("f.json", 0, 0, None, 0);
assert!(m.get_snippet(4, 75).is_none());
}
#[test]
fn rich_mark_get_snippet_extracts_line() {
let buf: Vec<char> = "hello world\n".chars().collect();
let m = RichMark::new("f.json", 0, 6, Some(buf), 6);
let snippet = m.get_snippet(4, 75).unwrap();
assert!(snippet.contains("hello world"));
assert!(snippet.contains('^'));
let caret_line = snippet.lines().nth(1).unwrap();
assert_eq!(caret_line, " ^"); }
#[test]
fn rich_mark_advance_string_offsets_column_and_pointer() {
let m = RichMark::new("f.json", 1, 5, None, 10);
let advanced = m.advance_string(3);
assert_eq!(advanced.column, 8);
assert_eq!(advanced.pointer, 13);
assert_eq!(advanced.line, 1);
}
#[test]
fn rich_mark_set_merged_mark_appends() {
let mut m = RichMark::new("a", 0, 0, None, 0);
m.set_merged_mark(RichMark::new("b", 1, 0, None, 0));
m.set_merged_mark(RichMark::new("c", 2, 0, None, 0));
assert_eq!(m.merged_marks.len(), 2);
assert_eq!(m.merged_marks[0].name, "b");
assert_eq!(m.merged_marks[1].name, "c");
}
#[test]
fn rich_mark_set_old_mark_chains() {
let mut m = RichMark::new("a", 0, 0, None, 0);
let old = RichMark::new("b", 1, 0, None, 0);
m.set_old_mark(old).unwrap();
assert!(m.old_mark.is_some());
assert_eq!(m.old_mark.as_ref().unwrap().name, "b");
}
#[test]
fn rich_mark_eq_compares_name_line_column() {
let a = RichMark::new("f", 1, 2, None, 10);
let b = RichMark::new("f", 1, 2, None, 99); assert_eq!(a, b);
let c = RichMark::new("f", 1, 3, None, 10); assert_ne!(a, c);
}
#[test]
fn rich_mark_to_string_marked_emits_line_column() {
let m = RichMark::new("f.json", 2, 4, None, 0);
let s = m.to_string_marked(0, "in ", false);
assert!(s.contains("\"f.json\", line 3, column 5"));
assert!(s.contains("in "));
}
#[test]
fn format_error_combines_context_and_problem() {
let cm = RichMark::new("ctx.json", 0, 0, None, 0);
let pm = RichMark::new("prob.json", 5, 0, None, 0);
let s = format_error(
Some("found error"),
Some(&cm),
Some("bad token"),
Some(&pm),
Some("hint: check syntax"),
0,
);
assert!(s.contains("found error"));
assert!(s.contains("bad token"));
assert!(s.contains("ctx.json"));
assert!(s.contains("prob.json"));
assert!(s.contains("hint: check syntax"));
}
#[test]
fn format_error_omits_context_mark_when_same_as_problem_mark() {
let m = RichMark::new("f.json", 0, 0, None, 0);
let s = format_error(Some("ctx"), Some(&m), Some("prob"), Some(&m), None, 0);
let count = s.matches("\"f.json\"").count();
assert_eq!(count, 1);
}
#[test]
fn format_error_indent_prefixes_text() {
let s = format_error(Some("ctx"), None, None, None, None, 4);
assert!(s.starts_with(" ctx"));
}
#[test]
fn marked_error_format_includes_problem() {
let pm = RichMark::new("f.json", 1, 1, None, 0);
let e = MarkedError::new(
Some("syntax error"),
None,
Some("unexpected token"),
Some(&pm),
None,
);
let s = e.to_string();
assert!(s.contains("syntax error"));
assert!(s.contains("unexpected token"));
assert!(s.contains("f.json"));
}
#[test]
fn marked_error_implements_error_traits() {
let e = MarkedError::new(Some("ctx"), None, Some("prob"), None, None);
let _: &dyn std::error::Error = &e;
}
#[test]
fn echoerr_holds_indent() {
let e = EchoErr::new(4);
assert_eq!(e.indent, 4);
}
#[test]
fn echo_err_call_defaults_indent_to_self_indent() {
let e = EchoErr::new(8);
let kwargs = serde_json::Map::new();
let r = e.call(kwargs);
assert_eq!(r["indent"], 8);
}
#[test]
fn echo_err_call_preserves_explicit_indent() {
let e = EchoErr::new(8);
let mut kwargs = serde_json::Map::new();
kwargs.insert("indent".to_string(), serde_json::json!(20));
let r = e.call(kwargs);
assert_eq!(r["indent"], 20);
}
#[test]
fn delayed_echo_err_new_empty_message_shift_zero() {
let d = DelayedEchoErr::new(0, "", "");
assert_eq!(d.indent_shift, 0);
assert_eq!(d.indent, 0);
}
#[test]
fn delayed_echo_err_new_message_shifts_by_4() {
let d = DelayedEchoErr::new(2, "Error context", "");
assert_eq!(d.indent_shift, 4);
assert_eq!(d.indent, 6);
}
#[test]
fn delayed_echo_err_new_separator_shifts_by_4() {
let d = DelayedEchoErr::new(2, "", "Or:");
assert_eq!(d.indent_shift, 4);
assert_eq!(d.indent, 6);
}
#[test]
fn delayed_echo_err_call_appends_to_last_variant() {
let mut d = DelayedEchoErr::new(0, "", "");
let mut kw = serde_json::Map::new();
kw.insert("problem".to_string(), serde_json::json!("x"));
d.call(kw);
assert_eq!(d.errs[0].len(), 1);
assert_eq!(d.errs[0][0]["problem"], "x");
}
#[test]
fn delayed_echo_err_call_shifts_indent_by_self_indent() {
let mut d = DelayedEchoErr::new(0, "message", "");
let mut kw = serde_json::Map::new();
kw.insert("indent".to_string(), serde_json::json!(2));
d.call(kw);
assert_eq!(d.errs[0][0]["indent"], 6);
}
#[test]
fn delayed_echo_err_call_defaults_kwargs_indent_to_zero() {
let mut d = DelayedEchoErr::new(0, "message", "");
let kw = serde_json::Map::new();
d.call(kw);
assert_eq!(d.errs[0][0]["indent"], 4);
}
#[test]
fn delayed_echo_err_next_variant_appends_empty_bucket() {
let mut d = DelayedEchoErr::new(0, "", "");
assert_eq!(d.errs.len(), 1);
d.next_variant();
assert_eq!(d.errs.len(), 2);
assert!(d.errs[1].is_empty());
}
#[test]
fn delayed_echo_err_echo_all_empty_variants_returns_empty() {
let d = DelayedEchoErr::new(0, "", "");
assert!(d.echo_all().is_empty());
}
#[test]
fn delayed_echo_err_echo_all_emits_message_first() {
let mut d = DelayedEchoErr::new(0, "Error in config", "");
let mut kw = serde_json::Map::new();
kw.insert("problem".to_string(), serde_json::json!("detail"));
d.call(kw);
let out = d.echo_all();
assert_eq!(out[0]["problem"], "Error in config");
assert_eq!(out[0]["indent"], 0);
}
#[test]
fn delayed_echo_err_echo_all_emits_separator_between_variants() {
let mut d = DelayedEchoErr::new(0, "", "Or:");
let mut kw = serde_json::Map::new();
kw.insert("problem".to_string(), serde_json::json!("variant1"));
d.call(kw);
d.next_variant();
let mut kw = serde_json::Map::new();
kw.insert("problem".to_string(), serde_json::json!("variant2"));
d.call(kw);
let out = d.echo_all();
let problems: Vec<&str> = out
.iter()
.filter_map(|m| m.get("problem").and_then(|v| v.as_str()))
.collect();
assert_eq!(problems, vec!["variant1", "Or:", "variant2"]);
}
#[test]
fn delayed_echo_err_is_truthy_when_errs_non_empty() {
let d = DelayedEchoErr::new(0, "", "");
assert!(d.is_truthy());
}
#[test]
fn delayed_echo_err_echo_all_skips_empty_first_variant_when_subsequent_has_content() {
let mut d = DelayedEchoErr::new(0, "", "separator");
d.next_variant();
let mut kw = serde_json::Map::new();
kw.insert("problem".to_string(), serde_json::json!("v2"));
d.call(kw);
let out = d.echo_all();
let problems: Vec<&str> = out
.iter()
.filter_map(|m| m.get("problem").and_then(|v| v.as_str()))
.collect();
assert_eq!(problems, vec!["separator", "v2"]);
}
#[test]
fn echoerr_writes_newline_problem_newline() {
let mut buf: Vec<u8> = Vec::new();
echoerr(&mut buf, None, None, Some("disk full"), None, None, 0).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(s.starts_with('\n'));
assert!(s.contains("disk full"));
assert!(s.ends_with('\n'));
}
}