Skip to main content

noyalib/
error.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (c) 2026 Noyalib. All rights reserved.
3
4//! Error handling types.
5
6use crate::prelude::*;
7use core::fmt;
8
9/// A `(line, column, byte index)` location in a YAML document.
10///
11/// `line` and `column` are **1-based** for any [`Location`]
12/// produced by parsing or by [`Location::from_index`] /
13/// [`Location::new`]. The single exception is
14/// [`Location::default()`] (and the `Spanned::new` constructor it
15/// powers), which yields `0/0/0` as a sentinel for "not yet
16/// populated by a parser pass." User code that only ever sees a
17/// [`Location`] returned from a parser may treat both axes as
18/// strictly ≥ 1.
19///
20/// `index` is always **0-based** and counts UTF-8 bytes from the
21/// start of the document.
22///
23/// # Examples
24///
25/// ```
26/// use noyalib::Location;
27/// let loc = Location::from_index("a: 1\nb: 2\n", 5);
28/// assert_eq!(loc.line(), 2);
29/// assert_eq!(loc.column(), 1);
30/// ```
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
32pub struct Location {
33    index: usize,
34    line: usize,
35    column: usize,
36}
37
38impl Location {
39    /// Create a new location from a byte index.
40    ///
41    /// # Examples
42    ///
43    /// ```
44    /// use noyalib::Location;
45    /// let loc = Location::from_index("hello\nworld", 6);
46    /// assert_eq!(loc.line(), 2);
47    /// ```
48    pub fn from_index(input: &str, index: usize) -> Self {
49        let mut line = 1;
50        let mut column = 1;
51        for (i, c) in input.char_indices() {
52            if i >= index {
53                break;
54            }
55            if c == '\n' {
56                line += 1;
57                column = 1;
58            } else {
59                column += 1;
60            }
61        }
62        Location {
63            index,
64            line,
65            column,
66        }
67    }
68
69    /// Create a new location from line, column, and byte index.
70    ///
71    /// # Examples
72    ///
73    /// ```
74    /// use noyalib::Location;
75    /// let loc = Location::new(1, 1, 0);
76    /// assert_eq!(loc.line(), 1);
77    /// ```
78    pub fn new(line: usize, col: usize, index: usize) -> Self {
79        Location {
80            index,
81            line,
82            column: col,
83        }
84    }
85
86    /// The 0-based byte index.
87    ///
88    /// # Examples
89    ///
90    /// ```
91    /// use noyalib::Location;
92    /// let loc = Location::from_index("abc", 2);
93    /// assert_eq!(loc.index(), 2);
94    /// ```
95    pub fn index(&self) -> usize {
96        self.index
97    }
98
99    /// The 1-based line number.
100    ///
101    /// Returns `0` only for a [`Location::default()`] that has not
102    /// been populated by a parser pass; any [`Location`] produced
103    /// by [`Location::from_index`], [`Location::new`], or returned
104    /// from a parser is `≥ 1`.
105    ///
106    /// # Examples
107    ///
108    /// ```
109    /// use noyalib::Location;
110    /// let loc = Location::from_index("a\nb", 2);
111    /// assert_eq!(loc.line(), 2);
112    /// ```
113    pub fn line(&self) -> usize {
114        self.line
115    }
116
117    /// The 1-based column number.
118    ///
119    /// Returns `0` only for a [`Location::default()`] that has not
120    /// been populated by a parser pass; any [`Location`] produced
121    /// by [`Location::from_index`], [`Location::new`], or returned
122    /// from a parser is `≥ 1`.
123    ///
124    /// # Examples
125    ///
126    /// ```
127    /// use noyalib::Location;
128    /// let loc = Location::from_index("abcd", 3);
129    /// assert_eq!(loc.column(), 4);
130    /// ```
131    pub fn column(&self) -> usize {
132        self.column
133    }
134}
135
136impl fmt::Display for Location {
137    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
138        write!(f, "line {}, column {}", self.line, self.column)
139    }
140}
141
142/// Errors that can occur during YAML serialization or deserialization.
143///
144/// Identifies which configurable parser budget was breached
145/// when an [`Error::Budget`] is raised.
146///
147/// Each variant carries the configured `limit` and (where
148/// meaningful) the `observed` value at the moment the cap
149/// tripped. Pattern-match on this enum to surface the specific
150/// budget in CLI / LSP / MCP diagnostics.
151///
152/// # Examples
153///
154/// ```
155/// use noyalib::{BudgetBreach, Error};
156/// let breach = BudgetBreach::MaxNodes { limit: 250_000, observed: 250_001 };
157/// let _e = Error::Budget(breach);
158/// ```
159#[derive(Debug, Clone, PartialEq)]
160#[non_exhaustive]
161pub enum BudgetBreach {
162    /// Total parser events exceeded `ParserConfig::max_events`.
163    MaxEvents {
164        /// The configured cap.
165        limit: usize,
166        /// The observed event count when the cap tripped.
167        observed: usize,
168    },
169    /// Total `Value` nodes in the AST exceeded
170    /// `ParserConfig::max_nodes`.
171    MaxNodes {
172        /// The configured cap.
173        limit: usize,
174        /// The observed node count when the cap tripped.
175        observed: usize,
176    },
177    /// Cumulative scalar byte count exceeded
178    /// `ParserConfig::max_total_scalar_bytes`.
179    MaxTotalScalarBytes {
180        /// The configured cap, in bytes.
181        limit: usize,
182        /// The observed cumulative scalar bytes when the cap tripped.
183        observed: usize,
184    },
185    /// Multi-document stream exceeded
186    /// `ParserConfig::max_documents`.
187    MaxDocuments {
188        /// The configured cap.
189        limit: usize,
190        /// The observed document count when the cap tripped.
191        observed: usize,
192    },
193    /// Merge-key (`<<`) count exceeded
194    /// `ParserConfig::max_merge_keys`.
195    MaxMergeKeys {
196        /// The configured cap.
197        limit: usize,
198        /// The observed merge-key count when the cap tripped.
199        observed: usize,
200    },
201    /// Alias-to-anchor ratio exceeded
202    /// `ParserConfig::alias_anchor_ratio` — heuristic for
203    /// billion-laughs-style amplification.
204    AliasAnchorRatio {
205        /// The configured ratio cap.
206        ratio: f64,
207        /// Number of anchors observed at the moment the cap tripped.
208        anchors: usize,
209        /// Number of aliases observed at the moment the cap tripped.
210        aliases: usize,
211    },
212}
213
214impl fmt::Display for BudgetBreach {
215    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
216        match self {
217            BudgetBreach::MaxEvents { limit, observed } => write!(
218                f,
219                "max_events budget exceeded: observed {observed} > limit {limit}"
220            ),
221            BudgetBreach::MaxNodes { limit, observed } => write!(
222                f,
223                "max_nodes budget exceeded: observed {observed} > limit {limit}"
224            ),
225            BudgetBreach::MaxTotalScalarBytes { limit, observed } => write!(
226                f,
227                "max_total_scalar_bytes budget exceeded: observed {observed} > limit {limit}"
228            ),
229            BudgetBreach::MaxDocuments { limit, observed } => write!(
230                f,
231                "max_documents budget exceeded: observed {observed} > limit {limit}"
232            ),
233            BudgetBreach::MaxMergeKeys { limit, observed } => write!(
234                f,
235                "max_merge_keys budget exceeded: observed {observed} > limit {limit}"
236            ),
237            BudgetBreach::AliasAnchorRatio {
238                ratio,
239                anchors,
240                aliases,
241            } => write!(
242                f,
243                "alias_anchor_ratio heuristic tripped: {aliases} aliases / {anchors} anchors > {ratio}"
244            ),
245        }
246    }
247}
248
249/// Coarse-grained classification of an [`Error`], exposed for
250/// callers that need to route errors without pattern-matching
251/// every variant. Prefer this over inventing per-variant boolean
252/// accessors (`is_syntax()`, `is_io()`, …): one classifier scales
253/// as new variants land under [`Error`]'s `#[non_exhaustive]`
254/// attribute.
255///
256/// This enum is itself `#[non_exhaustive]` so future variants
257/// (e.g. a dedicated Timeout kind) can be added without a semver
258/// break.
259///
260/// # Examples
261///
262/// ```
263/// use noyalib::{from_str, ErrorKind, Value};
264/// let err = from_str::<Value>("1: a\n\"1\": b\n").unwrap_err();
265/// assert_eq!(err.kind(), ErrorKind::KeyCollision);
266/// ```
267#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
268#[non_exhaustive]
269pub enum ErrorKind {
270    /// The document was malformed: unterminated flow collection,
271    /// bad indentation, invalid escape, unexpected token, etc.
272    Syntax,
273    /// An I/O operation failed while reading the input.
274    Io,
275    /// A configurable DoS budget or hard security limit was
276    /// exceeded (recursion depth, alias expansion, mapping size,
277    /// merge-key count, …). Covers every `Error::Budget` breach
278    /// as well as [`Error::RecursionLimitExceeded`] and
279    /// [`Error::RepetitionLimitExceeded`].
280    Budget,
281    /// A user-supplied [`crate::policy::Policy`] rejected the
282    /// document, or a policy-only structural rule (merge-value
283    /// shape, scalar-in-merge) tripped.
284    Policy,
285    /// Two distinct-typed YAML keys collapsed to the same string
286    /// key — see [`Error::KeyCollision`].
287    KeyCollision,
288    /// A genuine duplicate key was refused under
289    /// [`crate::DuplicateKeyPolicy::Error`].
290    DuplicateKey,
291    /// The parser reached end-of-stream where more input was
292    /// required.
293    EndOfStream,
294    /// The document parsed but the requested target type could
295    /// not be built (missing field, unknown field, type mismatch,
296    /// bad tag, unknown anchor, …). Covers the whole
297    /// serde-facing "shape doesn't match" family.
298    Data,
299    /// The error came in via [`Error::Custom`] or [`Error::Message`]
300    /// (usually from a `serde::de::Error` bridge) and doesn't map
301    /// cleanly to any of the other kinds.
302    Other,
303}
304
305/// # Examples
306///
307/// ```
308/// use noyalib::{from_str, Error, Value};
309/// let err = from_str::<Value>("a: [unclosed").unwrap_err();
310/// assert!(matches!(err, Error::Parse(_) | Error::ParseWithLocation { .. }));
311/// ```
312#[derive(Debug)]
313#[non_exhaustive]
314pub enum Error {
315    /// Error during YAML parsing.
316    ///
317    /// # Examples
318    ///
319    /// ```
320    /// let _e = noyalib::Error::Parse("unexpected token".into());
321    /// ```
322    Parse(String),
323
324    /// Error during YAML parsing with location information.
325    ///
326    /// # Examples
327    ///
328    /// ```
329    /// use noyalib::{Error, Location};
330    /// let _e = Error::ParseWithLocation {
331    ///     message: "bad token".into(),
332    ///     location: Location::from_index("a: [", 3),
333    /// };
334    /// ```
335    ParseWithLocation {
336        /// The error message.
337        ///
338        /// # Examples
339        ///
340        /// ```
341        /// use noyalib::{Error, Location};
342        /// let e = Error::ParseWithLocation {
343        ///     message: "bad".into(),
344        ///     location: Location::default(),
345        /// };
346        /// if let Error::ParseWithLocation { message, .. } = e {
347        ///     assert_eq!(message, "bad");
348        /// }
349        /// ```
350        message: String,
351        /// The location in the source where the error occurred.
352        ///
353        /// # Examples
354        ///
355        /// ```
356        /// use noyalib::{Error, Location};
357        /// let e = Error::ParseWithLocation {
358        ///     message: "x".into(),
359        ///     location: Location::from_index("abc", 1),
360        /// };
361        /// if let Error::ParseWithLocation { location, .. } = e {
362        ///     assert_eq!(location.column(), 2);
363        /// }
364        /// ```
365        location: Location,
366    },
367
368    /// Error during serialization.
369    ///
370    /// # Examples
371    ///
372    /// ```
373    /// let _e = noyalib::Error::Serialize("bad value".into());
374    /// ```
375    Serialize(String),
376
377    /// Error during deserialization.
378    ///
379    /// # Examples
380    ///
381    /// ```
382    /// let _e = noyalib::Error::Deserialize("type mismatch".into());
383    /// ```
384    Deserialize(String),
385
386    /// Error during deserialization with location information.
387    ///
388    /// # Examples
389    ///
390    /// ```
391    /// use noyalib::{Error, Location};
392    /// let _e = Error::DeserializeWithLocation {
393    ///     message: "expected int".into(),
394    ///     location: Location::default(),
395    /// };
396    /// ```
397    DeserializeWithLocation {
398        /// The error message.
399        ///
400        /// # Examples
401        ///
402        /// ```
403        /// use noyalib::{Error, Location};
404        /// let e = Error::DeserializeWithLocation {
405        ///     message: "m".into(),
406        ///     location: Location::default(),
407        /// };
408        /// if let Error::DeserializeWithLocation { message, .. } = e {
409        ///     assert_eq!(message, "m");
410        /// }
411        /// ```
412        message: String,
413        /// The location in the source where the error occurred.
414        ///
415        /// # Examples
416        ///
417        /// ```
418        /// use noyalib::{Error, Location};
419        /// let e = Error::DeserializeWithLocation {
420        ///     message: "m".into(),
421        ///     location: Location::from_index("ab", 1),
422        /// };
423        /// if let Error::DeserializeWithLocation { location, .. } = e {
424        ///     assert_eq!(location.column(), 2);
425        /// }
426        /// ```
427        location: Location,
428    },
429
430    /// I/O error (requires std feature).
431    ///
432    /// # Examples
433    ///
434    /// ```
435    /// let ioe = std::io::Error::new(std::io::ErrorKind::Other, "nope");
436    /// let _e = noyalib::Error::Io(ioe);
437    /// ```
438    #[cfg(feature = "std")]
439    Io(std::io::Error),
440
441    /// Custom error message.
442    ///
443    /// # Examples
444    ///
445    /// ```
446    /// let _e = noyalib::Error::Custom("whatever".into());
447    /// ```
448    Custom(String),
449
450    /// Error when recursion depth limit is exceeded.
451    ///
452    /// # Examples
453    ///
454    /// ```
455    /// let _e = noyalib::Error::RecursionLimitExceeded { depth: 64 };
456    /// ```
457    RecursionLimitExceeded {
458        /// The current depth.
459        ///
460        /// # Examples
461        ///
462        /// ```
463        /// use noyalib::Error;
464        /// if let Error::RecursionLimitExceeded { depth } =
465        ///     (Error::RecursionLimitExceeded { depth: 10 })
466        /// {
467        ///     assert_eq!(depth, 10);
468        /// }
469        /// ```
470        depth: usize,
471    },
472
473    /// Error when a duplicate key is encountered.
474    ///
475    /// # Examples
476    ///
477    /// ```
478    /// let _e = noyalib::Error::DuplicateKey("name".into());
479    /// ```
480    DuplicateKey(String),
481
482    /// Two distinct-typed keys collapsed to the same string key.
483    ///
484    /// The mapping key model is `Mapping<String, Value>`, so keys are
485    /// stringified. Distinct YAML keys that share a spelling — e.g. the
486    /// integer `1` and the string `"1"`, or `true` and `"true"` — would
487    /// silently overwrite each other, losing an entry. This is raised
488    /// instead, carrying the collapsed string key. Unlike
489    /// [`Self::DuplicateKey`], it fires regardless of `DuplicateKeyPolicy`
490    /// because it is data loss, not an authored duplicate.
491    ///
492    /// # Examples
493    ///
494    /// The construction shape:
495    ///
496    /// ```
497    /// let _e = noyalib::Error::KeyCollision("1".into());
498    /// ```
499    ///
500    /// Reproduces from real YAML. The integer key `1` and the
501    /// string key `"1"` both stringify to `"1"`, so parsing must
502    /// refuse rather than silently drop the first entry:
503    ///
504    /// ```
505    /// use noyalib::{Error, Value, from_str};
506    /// let err = from_str::<Value>("1: a\n\"1\": b\n").unwrap_err();
507    /// assert!(matches!(err, Error::KeyCollision(_)));
508    /// ```
509    KeyCollision(String),
510
511    /// Repetition limit exceeded (security limit against billion-laughs).
512    ///
513    /// # Examples
514    ///
515    /// ```
516    /// let _e = noyalib::Error::RepetitionLimitExceeded;
517    /// ```
518    RepetitionLimitExceeded,
519
520    /// A configurable parser budget was exceeded.
521    ///
522    /// Carries a [`BudgetBreach`] identifying which limit fired,
523    /// the configured cap, and (where meaningful) the observed
524    /// value at the moment the cap tripped. Distinct from the
525    /// older [`Error::RecursionLimitExceeded`] /
526    /// [`Error::RepetitionLimitExceeded`] variants — those stay
527    /// for backwards compatibility on the depth / alias-expansion
528    /// limits; new budgets in the v0.0.2 expansion (`max_events`,
529    /// `max_nodes`, `max_total_scalar_bytes`, `max_documents`,
530    /// `max_merge_keys`, `alias_anchor_ratio`) all flow through
531    /// `Error::Budget`.
532    ///
533    /// # Examples
534    ///
535    /// ```
536    /// use noyalib::{BudgetBreach, Error};
537    /// let _e = Error::Budget(BudgetBreach::MaxDocuments {
538    ///     limit: 1_000,
539    ///     observed: 1_001,
540    /// });
541    /// ```
542    Budget(BudgetBreach),
543
544    /// Unknown anchor encountered.
545    ///
546    /// # Examples
547    ///
548    /// ```
549    /// let _e = noyalib::Error::UnknownAnchor("missing".into());
550    /// ```
551    UnknownAnchor(String),
552
553    /// Unknown anchor encountered at a specific location.
554    ///
555    /// # Examples
556    ///
557    /// ```
558    /// use noyalib::{Error, Location};
559    /// let _e = Error::UnknownAnchorAt {
560    ///     name: "x".into(),
561    ///     location: Location::default(),
562    ///     suggestion: None,
563    /// };
564    /// ```
565    UnknownAnchorAt {
566        /// The anchor name.
567        ///
568        /// # Examples
569        ///
570        /// ```
571        /// use noyalib::{Error, Location};
572        /// let e = Error::UnknownAnchorAt {
573        ///     name: "x".into(),
574        ///     location: Location::default(),
575        ///     suggestion: None,
576        /// };
577        /// if let Error::UnknownAnchorAt { name, .. } = e {
578        ///     assert_eq!(name, "x");
579        /// }
580        /// ```
581        name: String,
582        /// The location where it was used.
583        ///
584        /// # Examples
585        ///
586        /// ```
587        /// use noyalib::{Error, Location};
588        /// let e = Error::UnknownAnchorAt {
589        ///     name: "x".into(),
590        ///     location: Location::from_index("ab", 1),
591        ///     suggestion: None,
592        /// };
593        /// if let Error::UnknownAnchorAt { location, .. } = e {
594        ///     assert_eq!(location.column(), 2);
595        /// }
596        /// ```
597        location: Location,
598        /// Optional suggestion for a similar anchor.
599        ///
600        /// # Examples
601        ///
602        /// ```
603        /// use noyalib::{Error, Location};
604        /// let e = Error::UnknownAnchorAt {
605        ///     name: "x".into(),
606        ///     location: Location::default(),
607        ///     suggestion: Some(("y".into(), Location::default())),
608        /// };
609        /// if let Error::UnknownAnchorAt { suggestion: Some((s, _)), .. } = e {
610        ///     assert_eq!(s, "y");
611        /// }
612        /// ```
613        suggestion: Option<(String, Location)>,
614    },
615
616    /// Missing field in a mapping.
617    ///
618    /// # Examples
619    ///
620    /// ```
621    /// let _e = noyalib::Error::MissingField("name".into());
622    /// ```
623    MissingField(String),
624
625    /// Unknown field in a mapping (with `deny_unknown_fields`).
626    ///
627    /// # Examples
628    ///
629    /// ```
630    /// let _e = noyalib::Error::UnknownField("extra".into());
631    /// ```
632    UnknownField(String),
633
634    /// Scalar encountered where a mapping was expected during merge.
635    ///
636    /// # Examples
637    ///
638    /// ```
639    /// let _e = noyalib::Error::ScalarInMergeElement;
640    /// ```
641    ScalarInMergeElement,
642
643    /// Sequence encountered where a mapping was expected during merge.
644    ///
645    /// # Examples
646    ///
647    /// ```
648    /// let _e = noyalib::Error::SequenceInMergeElement;
649    /// ```
650    SequenceInMergeElement,
651
652    /// Tagged value encountered during merge.
653    ///
654    /// # Examples
655    ///
656    /// ```
657    /// let _e = noyalib::Error::TaggedInMerge;
658    /// ```
659    TaggedInMerge,
660
661    /// Generic invalid construct error.
662    ///
663    /// # Examples
664    ///
665    /// ```
666    /// let _e = noyalib::Error::Invalid("bad construct".into());
667    /// ```
668    Invalid(String),
669
670    /// A type mismatch error.
671    ///
672    /// # Examples
673    ///
674    /// ```
675    /// let _e = noyalib::Error::TypeMismatch {
676    ///     expected: "integer",
677    ///     found: "string".into(),
678    /// };
679    /// ```
680    TypeMismatch {
681        /// The expected type.
682        ///
683        /// # Examples
684        ///
685        /// ```
686        /// use noyalib::Error;
687        /// let e = Error::TypeMismatch { expected: "int", found: "str".into() };
688        /// if let Error::TypeMismatch { expected, .. } = e {
689        ///     assert_eq!(expected, "int");
690        /// }
691        /// ```
692        expected: &'static str,
693        /// The type that was actually found.
694        ///
695        /// # Examples
696        ///
697        /// ```
698        /// use noyalib::Error;
699        /// let e = Error::TypeMismatch { expected: "int", found: "str".into() };
700        /// if let Error::TypeMismatch { found, .. } = e {
701        ///     assert_eq!(found, "str");
702        /// }
703        /// ```
704        found: String,
705    },
706
707    /// Shared error instance (Arc-wrapped for cloning).
708    ///
709    /// # Examples
710    ///
711    /// ```
712    /// use std::sync::Arc;
713    /// let _e = noyalib::Error::Shared(Arc::new(noyalib::Error::EndOfStream));
714    /// ```
715    Shared(Arc<Error>),
716
717    /// End of stream reached unexpectedly.
718    ///
719    /// # Examples
720    ///
721    /// ```
722    /// let _e = noyalib::Error::EndOfStream;
723    /// ```
724    EndOfStream,
725
726    /// More than one document found where one was expected.
727    ///
728    /// # Examples
729    ///
730    /// ```
731    /// let _e = noyalib::Error::MoreThanOneDocument;
732    /// ```
733    MoreThanOneDocument,
734
735    /// Scalar in merge (legacy variant).
736    ///
737    /// # Examples
738    ///
739    /// ```
740    /// let _e = noyalib::Error::ScalarInMerge;
741    /// ```
742    ScalarInMerge,
743
744    /// Empty tag encountered.
745    ///
746    /// # Examples
747    ///
748    /// ```
749    /// let _e = noyalib::Error::EmptyTag;
750    /// ```
751    EmptyTag,
752
753    /// Failed to parse a number.
754    ///
755    /// # Examples
756    ///
757    /// ```
758    /// let _e = noyalib::Error::FailedToParseNumber("not-a-number".into());
759    /// ```
760    FailedToParseNumber(String),
761
762    /// A message error from Serde (compat variant).
763    ///
764    /// # Examples
765    ///
766    /// ```
767    /// let _e = noyalib::Error::Message("oops".into(), Some(42));
768    /// ```
769    Message(String, Option<usize>),
770}
771
772// ── Manual `Display` + `Error` impls ───────────────────────────────────
773//
774// noyalib does not depend on `thiserror` so the proc-macro
775// expansion cost stays out of every downstream crate's compile.
776// These impls reproduce the exact format strings the previous
777// `#[error("...")]` attributes generated, so the user-visible
778// `Display` output is byte-stable across the migration.
779
780impl fmt::Display for Error {
781    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
782        match self {
783            Error::Parse(msg) => write!(f, "YAML parse error: {msg}"),
784            Error::ParseWithLocation { message, location } => {
785                write!(f, "YAML parse error at {location}: {message}")
786            }
787            Error::Serialize(msg) => write!(f, "serialization error: {msg}"),
788            Error::Deserialize(msg) => write!(f, "deserialization error: {msg}"),
789            Error::DeserializeWithLocation { message, location } => {
790                write!(f, "deserialization error at {location}: {message}")
791            }
792            #[cfg(feature = "std")]
793            Error::Io(e) => write!(f, "I/O error: {e}"),
794            Error::Custom(msg) => f.write_str(msg),
795            Error::RecursionLimitExceeded { depth } => {
796                write!(f, "recursion depth limit exceeded: {depth}")
797            }
798            Error::DuplicateKey(name) => write!(f, "duplicate key: {name}"),
799            Error::KeyCollision(name) => write!(
800                f,
801                "distinct mapping keys collide after string conversion: {name} \
802                 (e.g. `1` and `\"1\"`, or `true` and `\"true\"`)"
803            ),
804            Error::RepetitionLimitExceeded => f.write_str("alias expansion limit exceeded"),
805            Error::Budget(breach) => write!(f, "{breach}"),
806            Error::UnknownAnchor(name) => write!(f, "unknown anchor: {name}"),
807            Error::UnknownAnchorAt { name, location, .. } => {
808                write!(f, "unknown anchor: {name} at {location}")
809            }
810            Error::MissingField(name) => write!(f, "missing field: {name}"),
811            Error::UnknownField(name) => write!(f, "unknown field: {name}"),
812            Error::ScalarInMergeElement => f.write_str("scalar in merge element"),
813            Error::SequenceInMergeElement => f.write_str("sequence in merge element"),
814            Error::TaggedInMerge => f.write_str("tagged value in merge"),
815            Error::Invalid(msg) => write!(f, "invalid YAML: {msg}"),
816            Error::TypeMismatch { expected, found } => {
817                write!(f, "type mismatch: expected {expected}, found {found}")
818            }
819            Error::Shared(arc) => fmt::Display::fmt(arc.as_ref(), f),
820            Error::EndOfStream => f.write_str("unexpected end of stream"),
821            Error::MoreThanOneDocument => {
822                f.write_str("multiple documents in stream; expected exactly one")
823            }
824            Error::ScalarInMerge => f.write_str("scalar in merge"),
825            Error::EmptyTag => f.write_str("empty tag"),
826            Error::FailedToParseNumber(msg) => write!(f, "failed to parse number: {msg}"),
827            Error::Message(msg, _) => write!(f, "serde error: {msg}"),
828        }
829    }
830}
831
832// `core::error::Error` stabilised in Rust 1.81 — the crate MSRV
833// is 1.86, so the trait implementation is unconditional. Only
834// the `Io(std::io::Error)` arm needs an in-line `cfg` gate,
835// because `Error::Io` itself is `#[cfg(feature = "std")]`. Under
836// `no_std` callers keep full access to the trait for routing via
837// `core::error::Error`-consuming ecosystems (`snafu-nostd`,
838// `miette` no-std shim, embedded HAL error stacks).
839impl core::error::Error for Error {
840    fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
841        match self {
842            #[cfg(feature = "std")]
843            Error::Io(e) => Some(e),
844            Error::Shared(arc) => Some(arc.as_ref()),
845            _ => None,
846        }
847    }
848}
849
850#[cfg(feature = "std")]
851impl From<std::io::Error> for Error {
852    fn from(e: std::io::Error) -> Self {
853        Error::Io(e)
854    }
855}
856
857impl Error {
858    /// Get the location of the error, if any.
859    ///
860    /// # Examples
861    ///
862    /// ```
863    /// use noyalib::{from_str, Value};
864    /// let err = from_str::<Value>("a: [unclosed").unwrap_err();
865    /// let _ = err.location();
866    /// ```
867    pub fn location(&self) -> Option<Location> {
868        match self {
869            Error::ParseWithLocation { location, .. } => Some(*location),
870            Error::DeserializeWithLocation { location, .. } => Some(*location),
871            Error::UnknownAnchorAt { location, .. } => Some(*location),
872            Error::Shared(arc) => arc.location(),
873            _ => None,
874        }
875    }
876
877    /// Coarse-grained classification for routing without matching
878    /// every variant of the `#[non_exhaustive]` [`Error`] enum.
879    ///
880    /// The mapping is stable across variant additions: new
881    /// variants land under an existing [`ErrorKind`] whenever
882    /// possible, so downstream `match err.kind()` sites keep
883    /// compiling. When a new *category* is needed the enum grows
884    /// (also `#[non_exhaustive]`).
885    ///
886    /// # Examples
887    ///
888    /// ```
889    /// use noyalib::{from_str, ErrorKind, Value};
890    ///
891    /// let syntax = from_str::<Value>("a: [unclosed").unwrap_err();
892    /// assert_eq!(syntax.kind(), ErrorKind::Syntax);
893    ///
894    /// let collision = from_str::<Value>("1: a\n\"1\": b\n").unwrap_err();
895    /// assert_eq!(collision.kind(), ErrorKind::KeyCollision);
896    /// ```
897    pub fn kind(&self) -> ErrorKind {
898        match self {
899            Error::Parse(_) | Error::ParseWithLocation { .. } => ErrorKind::Syntax,
900            Error::Serialize(_) => ErrorKind::Data,
901            Error::Deserialize(_) | Error::DeserializeWithLocation { .. } => ErrorKind::Data,
902            #[cfg(feature = "std")]
903            Error::Io(_) => ErrorKind::Io,
904            Error::Custom(_) => ErrorKind::Other,
905            Error::RecursionLimitExceeded { .. } => ErrorKind::Budget,
906            Error::DuplicateKey(_) => ErrorKind::DuplicateKey,
907            Error::KeyCollision(_) => ErrorKind::KeyCollision,
908            Error::RepetitionLimitExceeded => ErrorKind::Budget,
909            Error::Budget(_) => ErrorKind::Budget,
910            Error::UnknownAnchor(_) | Error::UnknownAnchorAt { .. } => ErrorKind::Data,
911            Error::MissingField(_) | Error::UnknownField(_) => ErrorKind::Data,
912            Error::ScalarInMergeElement
913            | Error::SequenceInMergeElement
914            | Error::TaggedInMerge
915            | Error::ScalarInMerge => ErrorKind::Policy,
916            Error::Invalid(_) => ErrorKind::Data,
917            Error::TypeMismatch { .. } => ErrorKind::Data,
918            Error::Shared(arc) => arc.kind(),
919            Error::EndOfStream => ErrorKind::EndOfStream,
920            Error::MoreThanOneDocument => ErrorKind::Data,
921            Error::EmptyTag => ErrorKind::Syntax,
922            Error::FailedToParseNumber(_) => ErrorKind::Syntax,
923            Error::Message(_, _) => ErrorKind::Other,
924        }
925    }
926
927    /// Format the error with source context. If the error carries a source
928    /// location and the line is in range, the output includes a
929    /// `line <n>:<col>` prefix, the offending line, and a caret (`^`)
930    /// pointing at the column. Out-of-range lines fall back to plain
931    /// `Display`.
932    ///
933    /// For rustc-style multi-line context with surrounding lines, use
934    /// [`Self::format_with_source_radius`].
935    ///
936    /// # Examples
937    ///
938    /// ```
939    /// use noyalib::{from_str, Value};
940    /// let source = "a: [unclosed";
941    /// let err = from_str::<Value>(source).unwrap_err();
942    /// let formatted = err.format_with_source(source);
943    /// assert!(formatted.contains("error"));
944    /// ```
945    pub fn format_with_source(&self, source: &str) -> String {
946        let loc = match self.location() {
947            Some(l) => l,
948            None => return format!("{self}"),
949        };
950        let line_no = loc.line();
951        let col = loc.column();
952        let line_idx = line_no.saturating_sub(1);
953        let line = match source.lines().nth(line_idx) {
954            Some(l) => l,
955            None if line_no == 0 => source.lines().next().unwrap_or(""),
956            None => return format!("{self}"),
957        };
958        let caret_col = col.saturating_sub(1);
959        let caret: String = core::iter::repeat_n(' ', caret_col)
960            .chain(core::iter::once('^'))
961            .collect();
962        format!("error: {self}\n  --> line {line_no}:{col}\n  {line}\n  {caret}")
963    }
964
965    /// Format the error with `radius` lines of context above and
966    /// below the offending line — rustc-style. Each line gets a line
967    /// number on the left; the caret line under the offending column
968    /// is unnumbered. The output is byte-for-byte stable across
969    /// minor releases (no terminal escape codes, no
970    /// platform-conditional whitespace).
971    ///
972    /// Out-of-range locations fall back to plain `Display` (no
973    /// snippet) — same contract as [`Self::format_with_source`].
974    ///
975    /// # Examples
976    ///
977    /// ```
978    /// use noyalib::{from_str, Value};
979    /// // Indentation-mismatch error — carries a concrete `(line,
980    /// // column)` location, so the snippet renderer engages.
981    /// let source = "\
982    /// header: ok
983    /// service:
984    ///    nested: x
985    ///   bad: y
986    /// trailer: ok
987    /// ";
988    /// let e = from_str::<Value>(source).unwrap_err();
989    /// let formatted = e.format_with_source_radius(source, 1);
990    /// // Output includes the offending line plus a single line
991    /// // of context above and below.
992    /// assert!(formatted.contains("|"));
993    /// assert!(formatted.contains("bad: y"));
994    /// ```
995    pub fn format_with_source_radius(&self, source: &str, radius: usize) -> String {
996        let loc = match self.location() {
997            Some(l) => l,
998            None => return format!("{self}"),
999        };
1000        let line_no = loc.line();
1001        let col = loc.column();
1002        let line_idx = line_no.saturating_sub(1);
1003
1004        let lines: Vec<&str> = source.lines().collect();
1005        if lines.is_empty() {
1006            return format!("{self}");
1007        }
1008        let target = match lines.get(line_idx) {
1009            Some(_) => line_idx,
1010            None if line_no == 0 => 0,
1011            None => return format!("{self}"),
1012        };
1013
1014        let lo = target.saturating_sub(radius);
1015        let hi = (target + radius).min(lines.len().saturating_sub(1));
1016        // Width of the highest line number we'll print, for column
1017        // alignment of the gutter.
1018        let gutter_w = (hi + 1).to_string().len();
1019        let caret_col = col.saturating_sub(1);
1020
1021        let mut out = format!("error: {self}\n");
1022        out.push_str(&format!(
1023            "  --> line {line_no}:{col}\n",
1024            line_no = line_no,
1025            col = col,
1026        ));
1027
1028        // Top spacer
1029        out.push_str(&format!("{:>w$} |\n", "", w = gutter_w));
1030        for (i, idx) in (lo..=hi).enumerate() {
1031            let n = idx + 1;
1032            let line_text = lines[idx];
1033            out.push_str(&format!(
1034                "{n:>w$} | {line_text}\n",
1035                n = n,
1036                w = gutter_w,
1037                line_text = line_text,
1038            ));
1039            if idx == target {
1040                // Caret line — gutter is blank, then `|`, then
1041                // spaces up to the column, then `^`.
1042                let pad = " ".repeat(caret_col);
1043                out.push_str(&format!("{:>w$} | {pad}^\n", "", w = gutter_w, pad = pad,));
1044            }
1045            let _ = i;
1046        }
1047        // Bottom spacer
1048        out.push_str(&format!("{:>w$} |\n", "", w = gutter_w));
1049        out
1050    }
1051
1052    /// Format the error with source context, capped at `max_chars`
1053    /// **ASCII characters** — the bridged-channel-friendly variant
1054    /// of [`Self::format_with_source`]. Use when the diagnostic is
1055    /// destined for a Slack message, a Sentry tag, a structured
1056    /// log field, or any sink with a hard length budget.
1057    ///
1058    /// # Truncation contract
1059    ///
1060    /// 1. The output is plain ASCII (the renderer already emits no
1061    ///    ANSI escapes, so this is a no-op for that axis).
1062    /// 2. If the rendered string is `<= max_chars`, returns it
1063    ///    unchanged.
1064    /// 3. Otherwise truncates at a UTF-8 character boundary
1065    ///    `<= max_chars - 3` and appends an `...` ellipsis so the
1066    ///    final length is at most `max_chars`.
1067    /// 4. `max_chars` smaller than 3 keeps as much of the prefix
1068    ///    as fits and drops the ellipsis (so a `max_chars = 2`
1069    ///    yields exactly two characters of the message).
1070    ///
1071    /// # Examples
1072    ///
1073    /// ```
1074    /// use noyalib::{from_str, Value};
1075    /// let source = "a: [unclosed";
1076    /// let err = from_str::<Value>(source).unwrap_err();
1077    /// let short = err.format_with_source_truncated(source, 60);
1078    /// assert!(short.len() <= 60);
1079    /// // Untrimmed output is the same as `format_with_source`:
1080    /// let full = err.format_with_source(source);
1081    /// let unbounded = err.format_with_source_truncated(source, full.len() + 100);
1082    /// assert_eq!(unbounded, full);
1083    /// ```
1084    #[must_use]
1085    pub fn format_with_source_truncated(&self, source: &str, max_chars: usize) -> String {
1086        let full = self.format_with_source(source);
1087        truncate_with_ellipsis(full, max_chars)
1088    }
1089
1090    /// Format the error with multi-line `radius` context, capped
1091    /// at `max_chars`. Same truncation contract as
1092    /// [`Self::format_with_source_truncated`].
1093    ///
1094    /// # Examples
1095    ///
1096    /// ```
1097    /// use noyalib::{from_str, Value};
1098    /// let source = "a:\n  b:\n    c: [unclosed";
1099    /// let err = from_str::<Value>(source).unwrap_err();
1100    /// let s = err.format_with_source_radius_truncated(source, 1, 80);
1101    /// assert!(s.len() <= 80);
1102    /// ```
1103    #[must_use]
1104    pub fn format_with_source_radius_truncated(
1105        &self,
1106        source: &str,
1107        radius: usize,
1108        max_chars: usize,
1109    ) -> String {
1110        let full = self.format_with_source_radius(source, radius);
1111        truncate_with_ellipsis(full, max_chars)
1112    }
1113
1114    /// Convert the error into a shared Arc pointer. If the error is
1115    /// already `Error::Shared`, the inner `Arc` is reused without
1116    /// double-wrapping.
1117    ///
1118    /// # Examples
1119    ///
1120    /// ```
1121    /// let shared = noyalib::Error::EndOfStream.into_shared();
1122    /// assert!(matches!(&*shared, noyalib::Error::EndOfStream));
1123    /// ```
1124    pub fn into_shared(self) -> Arc<Self> {
1125        match self {
1126            Error::Shared(arc) => arc,
1127            other => Arc::new(other),
1128        }
1129    }
1130
1131    /// Check if the error is a shared error.
1132    ///
1133    /// # Examples
1134    ///
1135    /// ```
1136    /// use std::sync::Arc;
1137    /// let e = noyalib::Error::Shared(Arc::new(noyalib::Error::EndOfStream));
1138    /// assert!(e.is_shared());
1139    /// ```
1140    pub fn is_shared(&self) -> bool {
1141        matches!(self, Error::Shared(_))
1142    }
1143
1144    /// Access the inner error if this is a shared error.
1145    ///
1146    /// # Examples
1147    ///
1148    /// ```
1149    /// use std::sync::Arc;
1150    /// let e = noyalib::Error::Shared(Arc::new(noyalib::Error::EndOfStream));
1151    /// assert!(e.as_inner().is_some());
1152    /// ```
1153    pub fn as_inner(&self) -> Option<&Self> {
1154        match self {
1155            Error::Shared(arc) => Some(&**arc),
1156            _ => None,
1157        }
1158    }
1159
1160    /// Create a new parse error at the given index.
1161    ///
1162    /// # Examples
1163    ///
1164    /// ```
1165    /// let e = noyalib::Error::parse_at("bad", "a: x", 3);
1166    /// assert!(matches!(e, noyalib::Error::ParseWithLocation { .. }));
1167    /// ```
1168    pub fn parse_at(message: impl Into<String>, source: &str, index: usize) -> Self {
1169        Error::ParseWithLocation {
1170            message: message.into(),
1171            location: Location::from_index(source, index),
1172        }
1173    }
1174
1175    /// Create a new deserialization error at the given index.
1176    ///
1177    /// # Examples
1178    ///
1179    /// ```
1180    /// let e = noyalib::Error::deserialize_at("bad", "a: x", 3);
1181    /// assert!(matches!(e, noyalib::Error::DeserializeWithLocation { .. }));
1182    /// ```
1183    pub fn deserialize_at(message: impl Into<String>, source: &str, index: usize) -> Self {
1184        Error::DeserializeWithLocation {
1185            message: message.into(),
1186            location: Location::from_index(source, index),
1187        }
1188    }
1189
1190    /// Create a new error from a shared error pointer.
1191    ///
1192    /// # Examples
1193    ///
1194    /// ```
1195    /// use std::sync::Arc;
1196    /// let e = noyalib::Error::from_shared(Arc::new(noyalib::Error::EndOfStream));
1197    /// assert!(e.is_shared());
1198    /// ```
1199    pub fn from_shared(arc: Arc<Error>) -> Error {
1200        Error::Shared(arc)
1201    }
1202
1203    /// Render the error in rustc-style with default options.
1204    ///
1205    /// Equivalent to
1206    /// `self.render_with_options(source, &RenderOptions::default())`.
1207    ///
1208    /// Issue #2 entry point — supersedes [`Self::format_with_source`]
1209    /// for new code; that method is preserved for backwards
1210    /// compatibility.
1211    ///
1212    /// # Examples
1213    ///
1214    /// ```
1215    /// use noyalib::{from_str, Value};
1216    /// let source = "a:\n  b: 1\n   c: 2\n";  // misaligned indent
1217    /// let err = from_str::<Value>(source).unwrap_err();
1218    /// let rendered = err.render(source);
1219    /// assert!(rendered.contains("error"));
1220    /// ```
1221    pub fn render(&self, source: &str) -> String {
1222        self.render_with_options(source, &RenderOptions::default())
1223    }
1224
1225    /// Render the error with caller-controlled options.
1226    ///
1227    /// `RenderOptions::crop_radius` sets how many lines of context
1228    /// surround the offending line; `RenderOptions::color` enables
1229    /// terminal ANSI colour codes. The default
1230    /// (`RenderOptions::default()`) is `crop_radius = 2`,
1231    /// `color = false`.
1232    ///
1233    /// # Examples
1234    ///
1235    /// ```
1236    /// use noyalib::{from_str, RenderOptions, Value};
1237    /// let source = "a: [unclosed";
1238    /// let err = from_str::<Value>(source).unwrap_err();
1239    /// let opts = RenderOptions { crop_radius: 1, color: false };
1240    /// let rendered = err.render_with_options(source, &opts);
1241    /// assert!(rendered.contains("error"));
1242    /// ```
1243    pub fn render_with_options(&self, source: &str, opts: &RenderOptions) -> String {
1244        let plain = if opts.crop_radius == 0 {
1245            self.format_with_source(source)
1246        } else {
1247            self.format_with_source_radius(source, opts.crop_radius)
1248        };
1249        if opts.color {
1250            colorize_render(&plain)
1251        } else {
1252            plain
1253        }
1254    }
1255}
1256
1257/// Caller-controlled rendering options for [`Error::render_with_options`].
1258///
1259/// Defaults to `crop_radius = 2` and `color = false` so the
1260/// stable byte-for-byte CI-friendly output stays the default.
1261/// Set `color = true` for interactive terminal use.
1262///
1263/// Construct directly with a struct literal — both fields are
1264/// public. Future field additions are tracked as a minor-version
1265/// event per the [SemVer policy](https://github.com/sebastienrousseau/noyalib/blob/main/doc/POLICIES.md#2-semver--api-stability).
1266///
1267/// # Examples
1268///
1269/// ```
1270/// use noyalib::RenderOptions;
1271/// let default = RenderOptions::default();
1272/// assert_eq!(default.crop_radius, 2);
1273/// assert!(!default.color);
1274///
1275/// // Custom — single-line, coloured.
1276/// let custom = RenderOptions { crop_radius: 0, color: true };
1277/// assert_eq!(custom.crop_radius, 0);
1278/// ```
1279#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1280pub struct RenderOptions {
1281    /// Number of source lines to include above and below the
1282    /// offending line. `0` collapses to a single-line render.
1283    /// Default `2` (rustc-style window).
1284    pub crop_radius: usize,
1285    /// When `true`, the rendered output includes terminal ANSI
1286    /// colour escapes (red `error:`, blue gutter, yellow caret).
1287    /// Default `false` so CI logs and golden tests stay stable.
1288    pub color: bool,
1289}
1290
1291impl Default for RenderOptions {
1292    fn default() -> Self {
1293        RenderOptions {
1294            crop_radius: 2,
1295            color: false,
1296        }
1297    }
1298}
1299
1300impl RenderOptions {
1301    /// Construct with all defaults (`crop_radius = 2`,
1302    /// `color = false`). Equivalent to [`RenderOptions::default()`].
1303    ///
1304    /// # Examples
1305    ///
1306    /// ```
1307    /// use noyalib::RenderOptions;
1308    /// let opts = RenderOptions::new();
1309    /// assert_eq!(opts.crop_radius, 2);
1310    /// assert!(!opts.color);
1311    /// ```
1312    #[must_use]
1313    pub fn new() -> Self {
1314        Self::default()
1315    }
1316
1317    /// Set the crop radius (lines of context on each side of
1318    /// the offending line). `0` collapses to a single-line render.
1319    ///
1320    /// # Examples
1321    ///
1322    /// ```
1323    /// use noyalib::RenderOptions;
1324    /// let opts = RenderOptions::new().crop_radius(4);
1325    /// assert_eq!(opts.crop_radius, 4);
1326    /// ```
1327    #[must_use]
1328    pub fn crop_radius(mut self, radius: usize) -> Self {
1329        self.crop_radius = radius;
1330        self
1331    }
1332
1333    /// Toggle ANSI colour escape codes on rendered output.
1334    ///
1335    /// # Examples
1336    ///
1337    /// ```
1338    /// use noyalib::RenderOptions;
1339    /// let opts = RenderOptions::new().color(true);
1340    /// assert!(opts.color);
1341    /// ```
1342    #[must_use]
1343    pub fn color(mut self, on: bool) -> Self {
1344        self.color = on;
1345        self
1346    }
1347}
1348
1349/// A windowed slice of source text around an error location.
1350///
1351/// Used internally by [`Error::render_with_options`] and exposed
1352/// for callers that want to extract the snippet without
1353/// formatting it themselves.
1354///
1355/// # Examples
1356///
1357/// ```
1358/// use noyalib::CroppedRegion;
1359/// let src = "line 1\nline 2 — error here\nline 3\nline 4\n";
1360/// let region = CroppedRegion::extract(src, 2, 1);
1361/// assert_eq!(region.lines.len(), 3);
1362/// assert!(region.lines[1].contains("error"));
1363/// ```
1364#[derive(Debug, Clone, PartialEq, Eq)]
1365#[non_exhaustive]
1366pub struct CroppedRegion<'a> {
1367    /// The lines extracted from the source — indices `low_line..=high_line`.
1368    pub lines: Vec<&'a str>,
1369    /// 0-based index in `lines` of the offending line (i.e., the
1370    /// line corresponding to the original `target_line` parameter).
1371    pub focus_index: usize,
1372    /// The 1-based line number of the first line in `lines`.
1373    pub low_line: usize,
1374    /// The 1-based line number of the offending (focus) line.
1375    pub focus_line: usize,
1376}
1377
1378impl<'a> CroppedRegion<'a> {
1379    /// Extract a `radius`-line window around `target_line` (1-based)
1380    /// from `source`. Out-of-range targets clamp to the available
1381    /// lines; an empty source yields an empty region with
1382    /// `focus_line = 0`.
1383    ///
1384    /// # Examples
1385    ///
1386    /// ```
1387    /// use noyalib::CroppedRegion;
1388    /// let src = "a\nb\nc\nd\ne\n";
1389    /// let r = CroppedRegion::extract(src, 3, 1);
1390    /// assert_eq!(r.lines, vec!["b", "c", "d"]);
1391    /// assert_eq!(r.focus_index, 1);
1392    /// assert_eq!(r.focus_line, 3);
1393    /// ```
1394    pub fn extract(source: &'a str, target_line: usize, radius: usize) -> CroppedRegion<'a> {
1395        let all: Vec<&str> = source.lines().collect();
1396        if all.is_empty() {
1397            return CroppedRegion {
1398                lines: Vec::new(),
1399                focus_index: 0,
1400                low_line: 0,
1401                focus_line: 0,
1402            };
1403        }
1404        let target_idx = target_line.saturating_sub(1).min(all.len() - 1);
1405        let lo = target_idx.saturating_sub(radius);
1406        let hi = (target_idx + radius).min(all.len() - 1);
1407        let lines: Vec<&str> = all[lo..=hi].to_vec();
1408        CroppedRegion {
1409            lines,
1410            focus_index: target_idx - lo,
1411            low_line: lo + 1,
1412            focus_line: target_idx + 1,
1413        }
1414    }
1415}
1416
1417/// Wrap the rendered `plain` output with ANSI colour escapes —
1418/// red for the `error:` header, blue for the gutter, yellow for
1419/// the `^` caret. Implementation detail of
1420/// [`Error::render_with_options`] when `color = true`.
1421fn colorize_render(plain: &str) -> String {
1422    const RED: &str = "\x1b[31;1m";
1423    const BLUE: &str = "\x1b[34;1m";
1424    const YELLOW: &str = "\x1b[33;1m";
1425    const RESET: &str = "\x1b[0m";
1426
1427    let mut out = String::with_capacity(plain.len() + 64);
1428    for line in plain.split_inclusive('\n') {
1429        let trimmed = line.trim_end_matches('\n');
1430        if let Some(rest) = trimmed.strip_prefix("error:") {
1431            out.push_str(RED);
1432            out.push_str("error:");
1433            out.push_str(RESET);
1434            out.push_str(rest);
1435        } else if trimmed.trim_start().starts_with('|')
1436            || trimmed.starts_with("  --> ")
1437            || trimmed.contains(" | ")
1438        {
1439            out.push_str(BLUE);
1440            out.push_str(trimmed);
1441            out.push_str(RESET);
1442        } else if trimmed.trim_start().starts_with('^') {
1443            out.push_str(YELLOW);
1444            out.push_str(trimmed);
1445            out.push_str(RESET);
1446        } else {
1447            out.push_str(trimmed);
1448        }
1449        if line.ends_with('\n') {
1450            out.push('\n');
1451        }
1452    }
1453    out
1454}
1455
1456impl serde::ser::Error for Error {
1457    fn custom<T: fmt::Display>(msg: T) -> Self {
1458        Error::Custom(msg.to_string())
1459    }
1460}
1461
1462impl serde::de::Error for Error {
1463    fn custom<T: fmt::Display>(msg: T) -> Self {
1464        Error::Custom(msg.to_string())
1465    }
1466
1467    fn missing_field(field: &'static str) -> Self {
1468        Error::MissingField(field.to_string())
1469    }
1470
1471    fn unknown_field(field: &str, _expected: &'static [&'static str]) -> Self {
1472        Error::UnknownField(field.to_string())
1473    }
1474}
1475
1476/// Truncate `s` to at most `max_chars` characters, replacing the
1477/// dropped suffix with an ASCII `...` ellipsis. Used by the
1478/// `*_truncated` formatters to fit error reports into bounded
1479/// log / message-bus channels.
1480///
1481/// Truncation always lands on a UTF-8 character boundary so the
1482/// output is a valid `String`. When `max_chars < 3` the ellipsis
1483/// is dropped and the function returns whatever prefix fits.
1484fn truncate_with_ellipsis(s: String, max_chars: usize) -> String {
1485    let len = s.chars().count();
1486    if len <= max_chars {
1487        return s;
1488    }
1489    if max_chars < 3 {
1490        // No room for `...` — return the longest character-aligned
1491        // prefix that fits.
1492        let end = s
1493            .char_indices()
1494            .nth(max_chars)
1495            .map(|(i, _)| i)
1496            .unwrap_or(s.len());
1497        return s[..end].to_owned();
1498    }
1499    let keep_chars = max_chars - 3;
1500    let end = s
1501        .char_indices()
1502        .nth(keep_chars)
1503        .map(|(i, _)| i)
1504        .unwrap_or(s.len());
1505    let mut out = String::with_capacity(end + 3);
1506    out.push_str(&s[..end]);
1507    out.push_str("...");
1508    out
1509}
1510
1511/// A result type where the error is [`Error`].
1512///
1513/// # Examples
1514///
1515/// ```
1516/// use noyalib::Result;
1517/// fn parse() -> Result<noyalib::Value> {
1518///     noyalib::from_str("k: 1")
1519/// }
1520/// assert!(parse().is_ok());
1521/// ```
1522pub type Result<T> = core::result::Result<T, Error>;
1523
1524#[cfg(feature = "miette")]
1525impl miette::Diagnostic for Error {
1526    fn code<'a>(&'a self) -> Option<Box<dyn fmt::Display + 'a>> {
1527        if let Error::Shared(arc) = self {
1528            return arc.code();
1529        }
1530        let code = match self {
1531            Error::Parse(_) | Error::ParseWithLocation { .. } => "noyalib::parse",
1532            Error::Serialize(_) => "noyalib::serialize",
1533            Error::Deserialize(_) | Error::DeserializeWithLocation { .. } => "noyalib::deserialize",
1534            Error::TypeMismatch { .. } => "noyalib::type_mismatch",
1535            Error::MissingField(_) => "noyalib::missing_field",
1536            Error::UnknownField(_) => "noyalib::unknown_field",
1537            Error::RecursionLimitExceeded { .. } => "noyalib::recursion_limit",
1538            Error::RepetitionLimitExceeded => "noyalib::repetition_limit",
1539            Error::Budget(_) => "noyalib::budget",
1540            Error::UnknownAnchor(_) | Error::UnknownAnchorAt { .. } => "noyalib::unknown_anchor",
1541            Error::DuplicateKey(_) => "noyalib::duplicate_key",
1542            Error::KeyCollision(_) => "noyalib::key_collision",
1543            Error::EndOfStream => "noyalib::eof",
1544            Error::MoreThanOneDocument => "noyalib::multi_document",
1545            Error::Io(_) => "noyalib::io",
1546            _ => "noyalib::error",
1547        };
1548        Some(Box::new(code))
1549    }
1550
1551    fn help<'a>(&'a self) -> Option<Box<dyn fmt::Display + 'a>> {
1552        let help: Option<String> = match self {
1553            Error::UnknownAnchorAt {
1554                suggestion: Some((name, _)),
1555                ..
1556            } => Some(format!("did you mean '&{name}'?")),
1557            // `UnknownAnchor` (legacy, no location) gets a generic hint;
1558            // `UnknownAnchorAt` without a similar-name suggestion stays
1559            // `None` so the dual-label diagnostic speaks for itself.
1560            Error::UnknownAnchor(_) => {
1561                Some("define the anchor (&name) before referencing it".into())
1562            }
1563            Error::RecursionLimitExceeded { .. } => {
1564                Some("increase ParserConfig::max_depth or simplify nesting".into())
1565            }
1566            Error::RepetitionLimitExceeded => {
1567                Some("increase ParserConfig::max_alias_expansions or reduce alias usage".into())
1568            }
1569            Error::Budget(_) => {
1570                Some("raise the matching ParserConfig::max_* limit or simplify the input".into())
1571            }
1572            Error::DuplicateKey(_) => {
1573                Some("use DuplicateKeyPolicy::Last or ::Error to control behaviour".into())
1574            }
1575            Error::KeyCollision(_) => Some(
1576                "give the colliding keys distinct spellings, or quote them consistently".into(),
1577            ),
1578            Error::MoreThanOneDocument => {
1579                Some("use noyalib::load_all() to parse multi-document streams".into())
1580            }
1581            _ => None,
1582        };
1583        help.map(|s| -> Box<dyn fmt::Display + 'a> { Box::new(s) })
1584    }
1585
1586    fn source_code(&self) -> Option<&dyn miette::SourceCode> {
1587        if let Error::Shared(arc) = self {
1588            return arc.source_code();
1589        }
1590        None
1591    }
1592
1593    fn labels(&self) -> Option<Box<dyn Iterator<Item = miette::LabeledSpan> + '_>> {
1594        match self {
1595            Error::ParseWithLocation { message, location } => Some(Box::new(core::iter::once(
1596                miette::LabeledSpan::new(Some(message.clone()), location.index(), 1),
1597            ))),
1598            Error::DeserializeWithLocation { message, location } => {
1599                Some(Box::new(core::iter::once(miette::LabeledSpan::new(
1600                    Some(message.clone()),
1601                    location.index(),
1602                    1,
1603                ))))
1604            }
1605            Error::TypeMismatch {
1606                expected: _,
1607                found: _,
1608            } => None,
1609            Error::UnknownAnchorAt {
1610                name,
1611                location,
1612                suggestion,
1613            } => {
1614                let mut labels = Vec::new();
1615                labels.push(miette::LabeledSpan::new(
1616                    Some(format!("unknown anchor '{name}'")),
1617                    location.index(),
1618                    1,
1619                ));
1620                if let Some((s_name, s_loc)) = suggestion {
1621                    labels.push(miette::LabeledSpan::new(
1622                        Some(format!("did you mean '&{s_name}'?")),
1623                        s_loc.index(),
1624                        1,
1625                    ));
1626                }
1627                Some(Box::new(labels.into_iter()))
1628            }
1629            Error::Shared(arc) => arc.labels(),
1630            Error::Message(msg, Some(offset)) => Some(Box::new(core::iter::once(
1631                miette::LabeledSpan::new(Some(msg.clone()), *offset, 1),
1632            ))),
1633            _ => None,
1634        }
1635    }
1636}
1637
1638pub(crate) fn closest_name<'a>(
1639    name: &str,
1640    names: impl Iterator<Item = &'a str>,
1641) -> Option<&'a str> {
1642    let mut best_dist = usize::MAX;
1643    let mut best_name = None;
1644    for n in names {
1645        let dist = edit_distance(name, n);
1646        if dist < best_dist && dist <= 2 {
1647            best_dist = dist;
1648            best_name = Some(n);
1649        }
1650    }
1651    best_name
1652}
1653
1654fn edit_distance(a: &str, b: &str) -> usize {
1655    let a_len = a.chars().count();
1656    let b_len = b.chars().count();
1657    if a_len == 0 {
1658        return b_len;
1659    }
1660    if b_len == 0 {
1661        return a_len;
1662    }
1663    let mut row: Vec<usize> = (0..=b_len).collect();
1664    for (i, ca) in a.chars().enumerate() {
1665        let mut prev = i + 1;
1666        for (j, cb) in b.chars().enumerate() {
1667            let mut next = row[j] + (if ca == cb { 0 } else { 1 });
1668            if i + 1 < row[j + 1] + 1 && i + 1 < next {
1669                next = i + 1;
1670            }
1671            if prev + 1 < next {
1672                next = prev + 1;
1673            }
1674            row[j] = prev;
1675            prev = next;
1676        }
1677        row[b_len] = prev;
1678    }
1679    row[b_len]
1680}
1681
1682/// Panic helper for invariants the type system cannot express but
1683/// which the implementation has proved hold. Intended to replace
1684/// inline `unreachable!()` arms so the panic site is a single
1685/// `coverage(off)`-annotated function rather than an
1686/// arm-by-arm region in the coverage report.
1687///
1688/// `msg` is the human-readable invariant statement — quoted
1689/// verbatim into the panic message when the impossible happens
1690/// (e.g. when a future refactor accidentally breaks the
1691/// invariant). The tail-call shape lets call sites use it in any
1692/// position that expects a divergent expression.
1693#[track_caller]
1694#[cold]
1695#[inline(never)]
1696#[cfg_attr(noyalib_coverage, coverage(off))]
1697pub(crate) fn invariant_violated(msg: &'static str) -> ! {
1698    unreachable!("invariant violated: {msg}")
1699}
1700
1701#[cfg(test)]
1702mod truncate_tests {
1703    use super::truncate_with_ellipsis;
1704
1705    #[test]
1706    fn under_budget_passthrough() {
1707        assert_eq!(truncate_with_ellipsis("hello".into(), 10), "hello");
1708        assert_eq!(truncate_with_ellipsis("hello".into(), 5), "hello");
1709    }
1710
1711    #[test]
1712    fn over_budget_truncates_with_ellipsis() {
1713        assert_eq!(truncate_with_ellipsis("hello world".into(), 8), "hello...");
1714        assert_eq!(truncate_with_ellipsis("0123456789".into(), 5), "01...");
1715    }
1716
1717    #[test]
1718    fn tiny_budget_drops_ellipsis() {
1719        assert_eq!(truncate_with_ellipsis("hello".into(), 0), "");
1720        assert_eq!(truncate_with_ellipsis("hello".into(), 1), "h");
1721        assert_eq!(truncate_with_ellipsis("hello".into(), 2), "he");
1722    }
1723
1724    #[test]
1725    fn utf8_aligned_at_char_boundary() {
1726        // Multi-byte chars — truncation must not split codepoints.
1727        let s = "café au lait — décaféiné".to_string();
1728        let t = truncate_with_ellipsis(s, 10);
1729        assert!(t.is_char_boundary(t.len()));
1730        assert_eq!(t.chars().count(), 10);
1731    }
1732}