use crate::prelude::*;
use core::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
pub struct Location {
index: usize,
line: usize,
column: usize,
}
impl Location {
pub fn from_index(input: &str, index: usize) -> Self {
let mut line = 1;
let mut column = 1;
for (i, c) in input.char_indices() {
if i >= index {
break;
}
if c == '\n' {
line += 1;
column = 1;
} else {
column += 1;
}
}
Location {
index,
line,
column,
}
}
pub fn new(line: usize, col: usize, index: usize) -> Self {
Location {
index,
line,
column: col,
}
}
pub fn index(&self) -> usize {
self.index
}
pub fn line(&self) -> usize {
self.line
}
pub fn column(&self) -> usize {
self.column
}
}
impl fmt::Display for Location {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "line {}, column {}", self.line, self.column)
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum BudgetBreach {
MaxEvents {
limit: usize,
observed: usize,
},
MaxNodes {
limit: usize,
observed: usize,
},
MaxTotalScalarBytes {
limit: usize,
observed: usize,
},
MaxDocuments {
limit: usize,
observed: usize,
},
MaxMergeKeys {
limit: usize,
observed: usize,
},
AliasAnchorRatio {
ratio: f64,
anchors: usize,
aliases: usize,
},
}
impl fmt::Display for BudgetBreach {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
BudgetBreach::MaxEvents { limit, observed } => write!(
f,
"max_events budget exceeded: observed {observed} > limit {limit}"
),
BudgetBreach::MaxNodes { limit, observed } => write!(
f,
"max_nodes budget exceeded: observed {observed} > limit {limit}"
),
BudgetBreach::MaxTotalScalarBytes { limit, observed } => write!(
f,
"max_total_scalar_bytes budget exceeded: observed {observed} > limit {limit}"
),
BudgetBreach::MaxDocuments { limit, observed } => write!(
f,
"max_documents budget exceeded: observed {observed} > limit {limit}"
),
BudgetBreach::MaxMergeKeys { limit, observed } => write!(
f,
"max_merge_keys budget exceeded: observed {observed} > limit {limit}"
),
BudgetBreach::AliasAnchorRatio {
ratio,
anchors,
aliases,
} => write!(
f,
"alias_anchor_ratio heuristic tripped: {aliases} aliases / {anchors} anchors > {ratio}"
),
}
}
}
#[derive(Debug)]
#[non_exhaustive]
pub enum Error {
Parse(String),
ParseWithLocation {
message: String,
location: Location,
},
Serialize(String),
Deserialize(String),
DeserializeWithLocation {
message: String,
location: Location,
},
#[cfg(feature = "std")]
Io(std::io::Error),
Custom(String),
RecursionLimitExceeded {
depth: usize,
},
DuplicateKey(String),
RepetitionLimitExceeded,
Budget(BudgetBreach),
UnknownAnchor(String),
UnknownAnchorAt {
name: String,
location: Location,
suggestion: Option<(String, Location)>,
},
MissingField(String),
UnknownField(String),
ScalarInMergeElement,
SequenceInMergeElement,
TaggedInMerge,
Invalid(String),
TypeMismatch {
expected: &'static str,
found: String,
},
Shared(Arc<Error>),
EndOfStream,
MoreThanOneDocument,
ScalarInMerge,
EmptyTag,
FailedToParseNumber(String),
Message(String, Option<usize>),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::Parse(msg) => write!(f, "YAML parse error: {msg}"),
Error::ParseWithLocation { message, location } => {
write!(f, "YAML parse error at {location}: {message}")
}
Error::Serialize(msg) => write!(f, "serialization error: {msg}"),
Error::Deserialize(msg) => write!(f, "deserialization error: {msg}"),
Error::DeserializeWithLocation { message, location } => {
write!(f, "deserialization error at {location}: {message}")
}
#[cfg(feature = "std")]
Error::Io(e) => write!(f, "I/O error: {e}"),
Error::Custom(msg) => f.write_str(msg),
Error::RecursionLimitExceeded { depth } => {
write!(f, "recursion depth limit exceeded: {depth}")
}
Error::DuplicateKey(name) => write!(f, "duplicate key: {name}"),
Error::RepetitionLimitExceeded => f.write_str("alias expansion limit exceeded"),
Error::Budget(breach) => write!(f, "{breach}"),
Error::UnknownAnchor(name) => write!(f, "unknown anchor: {name}"),
Error::UnknownAnchorAt { name, location, .. } => {
write!(f, "unknown anchor: {name} at {location}")
}
Error::MissingField(name) => write!(f, "missing field: {name}"),
Error::UnknownField(name) => write!(f, "unknown field: {name}"),
Error::ScalarInMergeElement => f.write_str("scalar in merge element"),
Error::SequenceInMergeElement => f.write_str("sequence in merge element"),
Error::TaggedInMerge => f.write_str("tagged value in merge"),
Error::Invalid(msg) => write!(f, "invalid YAML: {msg}"),
Error::TypeMismatch { expected, found } => {
write!(f, "type mismatch: expected {expected}, found {found}")
}
Error::Shared(arc) => fmt::Display::fmt(arc.as_ref(), f),
Error::EndOfStream => f.write_str("unexpected end of stream"),
Error::MoreThanOneDocument => {
f.write_str("multiple documents in stream; expected exactly one")
}
Error::ScalarInMerge => f.write_str("scalar in merge"),
Error::EmptyTag => f.write_str("empty tag"),
Error::FailedToParseNumber(msg) => write!(f, "failed to parse number: {msg}"),
Error::Message(msg, _) => write!(f, "serde error: {msg}"),
}
}
}
#[cfg(feature = "std")]
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Error::Io(e) => Some(e),
Error::Shared(arc) => Some(arc.as_ref()),
_ => None,
}
}
}
#[cfg(feature = "std")]
impl From<std::io::Error> for Error {
fn from(e: std::io::Error) -> Self {
Error::Io(e)
}
}
impl Error {
pub fn location(&self) -> Option<Location> {
match self {
Error::ParseWithLocation { location, .. } => Some(*location),
Error::DeserializeWithLocation { location, .. } => Some(*location),
Error::UnknownAnchorAt { location, .. } => Some(*location),
Error::Shared(arc) => arc.location(),
_ => None,
}
}
pub fn format_with_source(&self, source: &str) -> String {
let loc = match self.location() {
Some(l) => l,
None => return format!("{self}"),
};
let line_no = loc.line();
let col = loc.column();
let line_idx = line_no.saturating_sub(1);
let line = match source.lines().nth(line_idx) {
Some(l) => l,
None if line_no == 0 => source.lines().next().unwrap_or(""),
None => return format!("{self}"),
};
let caret_col = col.saturating_sub(1);
let caret: String = core::iter::repeat(' ')
.take(caret_col)
.chain(core::iter::once('^'))
.collect();
format!("error: {self}\n --> line {line_no}:{col}\n {line}\n {caret}")
}
pub fn format_with_source_radius(&self, source: &str, radius: usize) -> String {
let loc = match self.location() {
Some(l) => l,
None => return format!("{self}"),
};
let line_no = loc.line();
let col = loc.column();
let line_idx = line_no.saturating_sub(1);
let lines: Vec<&str> = source.lines().collect();
if lines.is_empty() {
return format!("{self}");
}
let target = match lines.get(line_idx) {
Some(_) => line_idx,
None if line_no == 0 => 0,
None => return format!("{self}"),
};
let lo = target.saturating_sub(radius);
let hi = (target + radius).min(lines.len().saturating_sub(1));
let gutter_w = (hi + 1).to_string().len();
let caret_col = col.saturating_sub(1);
let mut out = format!("error: {self}\n");
out.push_str(&format!(
" --> line {line_no}:{col}\n",
line_no = line_no,
col = col,
));
out.push_str(&format!("{:>w$} |\n", "", w = gutter_w));
for (i, idx) in (lo..=hi).enumerate() {
let n = idx + 1;
let line_text = lines[idx];
out.push_str(&format!(
"{n:>w$} | {line_text}\n",
n = n,
w = gutter_w,
line_text = line_text,
));
if idx == target {
let pad = " ".repeat(caret_col);
out.push_str(&format!("{:>w$} | {pad}^\n", "", w = gutter_w, pad = pad,));
}
let _ = i;
}
out.push_str(&format!("{:>w$} |\n", "", w = gutter_w));
out
}
#[must_use]
pub fn format_with_source_truncated(&self, source: &str, max_chars: usize) -> String {
let full = self.format_with_source(source);
truncate_with_ellipsis(full, max_chars)
}
#[must_use]
pub fn format_with_source_radius_truncated(
&self,
source: &str,
radius: usize,
max_chars: usize,
) -> String {
let full = self.format_with_source_radius(source, radius);
truncate_with_ellipsis(full, max_chars)
}
pub fn into_shared(self) -> Arc<Self> {
match self {
Error::Shared(arc) => arc,
other => Arc::new(other),
}
}
pub fn is_shared(&self) -> bool {
matches!(self, Error::Shared(_))
}
pub fn as_inner(&self) -> Option<&Self> {
match self {
Error::Shared(arc) => Some(&**arc),
_ => None,
}
}
pub fn parse_at(message: impl Into<String>, source: &str, index: usize) -> Self {
Error::ParseWithLocation {
message: message.into(),
location: Location::from_index(source, index),
}
}
pub fn deserialize_at(message: impl Into<String>, source: &str, index: usize) -> Self {
Error::DeserializeWithLocation {
message: message.into(),
location: Location::from_index(source, index),
}
}
pub fn from_shared(arc: Arc<Error>) -> Error {
Error::Shared(arc)
}
pub fn render(&self, source: &str) -> String {
self.render_with_options(source, &RenderOptions::default())
}
pub fn render_with_options(&self, source: &str, opts: &RenderOptions) -> String {
let plain = if opts.crop_radius == 0 {
self.format_with_source(source)
} else {
self.format_with_source_radius(source, opts.crop_radius)
};
if opts.color {
colorize_render(&plain)
} else {
plain
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RenderOptions {
pub crop_radius: usize,
pub color: bool,
}
impl Default for RenderOptions {
fn default() -> Self {
RenderOptions {
crop_radius: 2,
color: false,
}
}
}
impl RenderOptions {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn crop_radius(mut self, radius: usize) -> Self {
self.crop_radius = radius;
self
}
#[must_use]
pub fn color(mut self, on: bool) -> Self {
self.color = on;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct CroppedRegion<'a> {
pub lines: Vec<&'a str>,
pub focus_index: usize,
pub low_line: usize,
pub focus_line: usize,
}
impl<'a> CroppedRegion<'a> {
pub fn extract(source: &'a str, target_line: usize, radius: usize) -> CroppedRegion<'a> {
let all: Vec<&str> = source.lines().collect();
if all.is_empty() {
return CroppedRegion {
lines: Vec::new(),
focus_index: 0,
low_line: 0,
focus_line: 0,
};
}
let target_idx = target_line.saturating_sub(1).min(all.len() - 1);
let lo = target_idx.saturating_sub(radius);
let hi = (target_idx + radius).min(all.len() - 1);
let lines: Vec<&str> = all[lo..=hi].to_vec();
CroppedRegion {
lines,
focus_index: target_idx - lo,
low_line: lo + 1,
focus_line: target_idx + 1,
}
}
}
fn colorize_render(plain: &str) -> String {
const RED: &str = "\x1b[31;1m";
const BLUE: &str = "\x1b[34;1m";
const YELLOW: &str = "\x1b[33;1m";
const RESET: &str = "\x1b[0m";
let mut out = String::with_capacity(plain.len() + 64);
for line in plain.split_inclusive('\n') {
let trimmed = line.trim_end_matches('\n');
if let Some(rest) = trimmed.strip_prefix("error:") {
out.push_str(RED);
out.push_str("error:");
out.push_str(RESET);
out.push_str(rest);
} else if trimmed.trim_start().starts_with('|')
|| trimmed.starts_with(" --> ")
|| trimmed.contains(" | ")
{
out.push_str(BLUE);
out.push_str(trimmed);
out.push_str(RESET);
} else if trimmed.trim_start().starts_with('^') {
out.push_str(YELLOW);
out.push_str(trimmed);
out.push_str(RESET);
} else {
out.push_str(trimmed);
}
if line.ends_with('\n') {
out.push('\n');
}
}
out
}
impl serde::ser::Error for Error {
fn custom<T: fmt::Display>(msg: T) -> Self {
Error::Custom(msg.to_string())
}
}
impl serde::de::Error for Error {
fn custom<T: fmt::Display>(msg: T) -> Self {
Error::Custom(msg.to_string())
}
fn missing_field(field: &'static str) -> Self {
Error::MissingField(field.to_string())
}
fn unknown_field(field: &str, _expected: &'static [&'static str]) -> Self {
Error::UnknownField(field.to_string())
}
}
fn truncate_with_ellipsis(s: String, max_chars: usize) -> String {
let len = s.chars().count();
if len <= max_chars {
return s;
}
if max_chars < 3 {
let end = s
.char_indices()
.nth(max_chars)
.map(|(i, _)| i)
.unwrap_or(s.len());
return s[..end].to_owned();
}
let keep_chars = max_chars - 3;
let end = s
.char_indices()
.nth(keep_chars)
.map(|(i, _)| i)
.unwrap_or(s.len());
let mut out = String::with_capacity(end + 3);
out.push_str(&s[..end]);
out.push_str("...");
out
}
pub type Result<T> = core::result::Result<T, Error>;
#[cfg(feature = "miette")]
impl miette::Diagnostic for Error {
fn code<'a>(&'a self) -> Option<Box<dyn fmt::Display + 'a>> {
if let Error::Shared(arc) = self {
return arc.code();
}
let code = match self {
Error::Parse(_) | Error::ParseWithLocation { .. } => "noyalib::parse",
Error::Serialize(_) => "noyalib::serialize",
Error::Deserialize(_) | Error::DeserializeWithLocation { .. } => "noyalib::deserialize",
Error::TypeMismatch { .. } => "noyalib::type_mismatch",
Error::MissingField(_) => "noyalib::missing_field",
Error::UnknownField(_) => "noyalib::unknown_field",
Error::RecursionLimitExceeded { .. } => "noyalib::recursion_limit",
Error::RepetitionLimitExceeded => "noyalib::repetition_limit",
Error::Budget(_) => "noyalib::budget",
Error::UnknownAnchor(_) | Error::UnknownAnchorAt { .. } => "noyalib::unknown_anchor",
Error::DuplicateKey(_) => "noyalib::duplicate_key",
Error::EndOfStream => "noyalib::eof",
Error::MoreThanOneDocument => "noyalib::multi_document",
Error::Io(_) => "noyalib::io",
_ => "noyalib::error",
};
Some(Box::new(code))
}
fn help<'a>(&'a self) -> Option<Box<dyn fmt::Display + 'a>> {
let help: Option<String> = match self {
Error::UnknownAnchorAt {
suggestion: Some((name, _)),
..
} => Some(format!("did you mean '&{name}'?")),
Error::UnknownAnchor(_) => {
Some("define the anchor (&name) before referencing it".into())
}
Error::RecursionLimitExceeded { .. } => {
Some("increase ParserConfig::max_depth or simplify nesting".into())
}
Error::RepetitionLimitExceeded => {
Some("increase ParserConfig::max_alias_expansions or reduce alias usage".into())
}
Error::Budget(_) => {
Some("raise the matching ParserConfig::max_* limit or simplify the input".into())
}
Error::DuplicateKey(_) => {
Some("use DuplicateKeyPolicy::Last or ::Error to control behaviour".into())
}
Error::MoreThanOneDocument => {
Some("use noyalib::load_all() to parse multi-document streams".into())
}
_ => None,
};
help.map(|s| -> Box<dyn fmt::Display + 'a> { Box::new(s) })
}
fn source_code(&self) -> Option<&dyn miette::SourceCode> {
if let Error::Shared(arc) = self {
return arc.source_code();
}
None
}
fn labels(&self) -> Option<Box<dyn Iterator<Item = miette::LabeledSpan> + '_>> {
match self {
Error::ParseWithLocation { message, location } => Some(Box::new(core::iter::once(
miette::LabeledSpan::new(Some(message.clone()), location.index(), 1),
))),
Error::DeserializeWithLocation { message, location } => {
Some(Box::new(core::iter::once(miette::LabeledSpan::new(
Some(message.clone()),
location.index(),
1,
))))
}
Error::TypeMismatch {
expected: _,
found: _,
} => None,
Error::UnknownAnchorAt {
name,
location,
suggestion,
} => {
let mut labels = Vec::new();
labels.push(miette::LabeledSpan::new(
Some(format!("unknown anchor '{name}'")),
location.index(),
1,
));
if let Some((s_name, s_loc)) = suggestion {
labels.push(miette::LabeledSpan::new(
Some(format!("did you mean '&{s_name}'?")),
s_loc.index(),
1,
));
}
Some(Box::new(labels.into_iter()))
}
Error::Shared(arc) => arc.labels(),
Error::Message(msg, Some(offset)) => Some(Box::new(core::iter::once(
miette::LabeledSpan::new(Some(msg.clone()), *offset, 1),
))),
_ => None,
}
}
}
pub(crate) fn closest_name<'a>(
name: &str,
names: impl Iterator<Item = &'a str>,
) -> Option<&'a str> {
let mut best_dist = usize::MAX;
let mut best_name = None;
for n in names {
let dist = edit_distance(name, n);
if dist < best_dist && dist <= 2 {
best_dist = dist;
best_name = Some(n);
}
}
best_name
}
fn edit_distance(a: &str, b: &str) -> usize {
let a_len = a.chars().count();
let b_len = b.chars().count();
if a_len == 0 {
return b_len;
}
if b_len == 0 {
return a_len;
}
let mut row: Vec<usize> = (0..=b_len).collect();
for (i, ca) in a.chars().enumerate() {
let mut prev = i + 1;
for (j, cb) in b.chars().enumerate() {
let mut next = row[j] + (if ca == cb { 0 } else { 1 });
if i + 1 < row[j + 1] + 1 && i + 1 < next {
next = i + 1;
}
if prev + 1 < next {
next = prev + 1;
}
row[j] = prev;
prev = next;
}
row[b_len] = prev;
}
row[b_len]
}
#[track_caller]
#[cold]
#[inline(never)]
#[cfg_attr(noyalib_coverage, coverage(off))]
pub(crate) fn invariant_violated(msg: &'static str) -> ! {
unreachable!("invariant violated: {msg}")
}
#[cfg(test)]
mod truncate_tests {
use super::truncate_with_ellipsis;
#[test]
fn under_budget_passthrough() {
assert_eq!(truncate_with_ellipsis("hello".into(), 10), "hello");
assert_eq!(truncate_with_ellipsis("hello".into(), 5), "hello");
}
#[test]
fn over_budget_truncates_with_ellipsis() {
assert_eq!(truncate_with_ellipsis("hello world".into(), 8), "hello...");
assert_eq!(truncate_with_ellipsis("0123456789".into(), 5), "01...");
}
#[test]
fn tiny_budget_drops_ellipsis() {
assert_eq!(truncate_with_ellipsis("hello".into(), 0), "");
assert_eq!(truncate_with_ellipsis("hello".into(), 1), "h");
assert_eq!(truncate_with_ellipsis("hello".into(), 2), "he");
}
#[test]
fn utf8_aligned_at_char_boundary() {
let s = "café au lait — décaféiné".to_string();
let t = truncate_with_ellipsis(s, 10);
assert!(t.is_char_boundary(t.len()));
assert_eq!(t.chars().count(), 10);
}
}