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::diagnostic::{
7    Diagnostic, DiagnosticCode, DiagnosticLabel, DiagnosticSeverity, SourceSpan,
8};
9use crate::prelude::*;
10use core::fmt;
11
12/// A `(line, column, byte index)` location in a YAML document.
13///
14/// `line` and `column` are **1-based** for any [`Location`]
15/// produced by parsing or by [`Location::from_index`] /
16/// [`Location::new`]. The single exception is
17/// [`Location::default()`] (and the `Spanned::new` constructor it
18/// powers), which yields `0/0/0` as a sentinel for "not yet
19/// populated by a parser pass." User code that only ever sees a
20/// [`Location`] returned from a parser may treat both axes as
21/// strictly ≥ 1.
22///
23/// `index` is always **0-based** and counts UTF-8 bytes from the
24/// start of the document.
25///
26/// # Examples
27///
28/// ```
29/// use noyalib::Location;
30/// let loc = Location::from_index("a: 1\nb: 2\n", 5);
31/// assert_eq!(loc.line(), 2);
32/// assert_eq!(loc.column(), 1);
33/// ```
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
35pub struct Location {
36    index: usize,
37    line: usize,
38    column: usize,
39}
40
41impl Location {
42    /// Create a new location from a byte index.
43    ///
44    /// # Examples
45    ///
46    /// ```
47    /// use noyalib::Location;
48    /// let loc = Location::from_index("hello\nworld", 6);
49    /// assert_eq!(loc.line(), 2);
50    /// ```
51    #[must_use]
52    pub fn from_index(input: &str, index: usize) -> Self {
53        let mut line = 1;
54        let mut column = 1;
55        for (i, c) in input.char_indices() {
56            if i >= index {
57                break;
58            }
59            if c == '\n' {
60                line += 1;
61                column = 1;
62            } else {
63                column += 1;
64            }
65        }
66        Self {
67            index,
68            line,
69            column,
70        }
71    }
72
73    /// Create a new location from line, column, and byte index.
74    ///
75    /// # Examples
76    ///
77    /// ```
78    /// use noyalib::Location;
79    /// let loc = Location::new(1, 1, 0);
80    /// assert_eq!(loc.line(), 1);
81    /// ```
82    #[must_use]
83    pub fn new(line: usize, col: usize, index: usize) -> Self {
84        Self {
85            index,
86            line,
87            column: col,
88        }
89    }
90
91    /// The 0-based byte index.
92    ///
93    /// # Examples
94    ///
95    /// ```
96    /// use noyalib::Location;
97    /// let loc = Location::from_index("abc", 2);
98    /// assert_eq!(loc.index(), 2);
99    /// ```
100    #[must_use]
101    pub fn index(&self) -> usize {
102        self.index
103    }
104
105    /// The 1-based line number.
106    ///
107    /// Returns `0` only for a [`Location::default()`] that has not
108    /// been populated by a parser pass; any [`Location`] produced
109    /// by [`Location::from_index`], [`Location::new`], or returned
110    /// from a parser is `≥ 1`.
111    ///
112    /// # Examples
113    ///
114    /// ```
115    /// use noyalib::Location;
116    /// let loc = Location::from_index("a\nb", 2);
117    /// assert_eq!(loc.line(), 2);
118    /// ```
119    #[must_use]
120    pub fn line(&self) -> usize {
121        self.line
122    }
123
124    /// The 1-based column number.
125    ///
126    /// Returns `0` only for a [`Location::default()`] that has not
127    /// been populated by a parser pass; any [`Location`] produced
128    /// by [`Location::from_index`], [`Location::new`], or returned
129    /// from a parser is `≥ 1`.
130    ///
131    /// # Examples
132    ///
133    /// ```
134    /// use noyalib::Location;
135    /// let loc = Location::from_index("abcd", 3);
136    /// assert_eq!(loc.column(), 4);
137    /// ```
138    #[must_use]
139    pub fn column(&self) -> usize {
140        self.column
141    }
142}
143
144impl fmt::Display for Location {
145    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
146        write!(f, "line {}, column {}", self.line, self.column)
147    }
148}
149
150/// Errors that can occur during YAML serialization or deserialization.
151///
152/// Identifies which configurable parser budget was breached
153/// when an [`Error::Budget`] is raised.
154///
155/// Each variant carries the configured `limit` and (where
156/// meaningful) the `observed` value at the moment the cap
157/// tripped. Pattern-match on this enum to surface the specific
158/// budget in CLI / LSP / MCP diagnostics.
159///
160/// # Examples
161///
162/// ```
163/// use noyalib::{BudgetBreach, Error};
164/// let breach = BudgetBreach::MaxNodes { limit: 250_000, observed: 250_001 };
165/// let _e = Error::Budget(breach);
166/// ```
167#[derive(Debug, Clone, PartialEq)]
168#[non_exhaustive]
169pub enum BudgetBreach {
170    /// Total parser events exceeded `ParserConfig::max_events`.
171    MaxEvents {
172        /// The configured cap.
173        limit: usize,
174        /// The observed event count when the cap tripped.
175        observed: usize,
176    },
177    /// Total `Value` nodes in the AST exceeded
178    /// `ParserConfig::max_nodes`.
179    MaxNodes {
180        /// The configured cap.
181        limit: usize,
182        /// The observed node count when the cap tripped.
183        observed: usize,
184    },
185    /// Cumulative scalar byte count exceeded
186    /// `ParserConfig::max_total_scalar_bytes`.
187    MaxTotalScalarBytes {
188        /// The configured cap, in bytes.
189        limit: usize,
190        /// The observed cumulative scalar bytes when the cap tripped.
191        observed: usize,
192    },
193    /// Multi-document stream exceeded
194    /// `ParserConfig::max_documents`.
195    MaxDocuments {
196        /// The configured cap.
197        limit: usize,
198        /// The observed document count when the cap tripped.
199        observed: usize,
200    },
201    /// Include resolution exceeded the configured source-count budget.
202    MaxIncludeSources {
203        /// The configured cap.
204        limit: usize,
205        /// The observed source count when the cap tripped.
206        observed: usize,
207    },
208    /// Include resolution exceeded the configured cumulative byte budget.
209    MaxIncludeBytes {
210        /// The configured cap, in bytes.
211        limit: usize,
212        /// The observed cumulative bytes when the cap tripped.
213        observed: usize,
214    },
215    /// Merge-key (`<<`) count exceeded
216    /// `ParserConfig::max_merge_keys`.
217    MaxMergeKeys {
218        /// The configured cap.
219        limit: usize,
220        /// The observed merge-key count when the cap tripped.
221        observed: usize,
222    },
223    /// Alias-to-anchor ratio exceeded
224    /// `ParserConfig::alias_anchor_ratio` — heuristic for
225    /// billion-laughs-style amplification.
226    AliasAnchorRatio {
227        /// The configured ratio cap.
228        ratio: f64,
229        /// Number of anchors observed at the moment the cap tripped.
230        anchors: usize,
231        /// Number of aliases observed at the moment the cap tripped.
232        aliases: usize,
233    },
234    /// A single sequence exceeded `ParserConfig::max_sequence_length`.
235    MaxSequenceLength {
236        /// The configured cap.
237        limit: usize,
238        /// The item count that would have overflowed the cap.
239        observed: usize,
240    },
241    /// A single mapping exceeded `ParserConfig::max_mapping_keys`.
242    MaxMappingKeys {
243        /// The configured cap.
244        limit: usize,
245        /// The key count that would have overflowed the cap.
246        observed: usize,
247    },
248}
249
250impl fmt::Display for BudgetBreach {
251    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
252        match self {
253            Self::MaxEvents { limit, observed } => write!(
254                f,
255                "max_events budget exceeded: observed {observed} > limit {limit}"
256            ),
257            Self::MaxNodes { limit, observed } => write!(
258                f,
259                "max_nodes budget exceeded: observed {observed} > limit {limit}"
260            ),
261            Self::MaxTotalScalarBytes { limit, observed } => write!(
262                f,
263                "max_total_scalar_bytes budget exceeded: observed {observed} > limit {limit}"
264            ),
265            Self::MaxDocuments { limit, observed } => write!(
266                f,
267                "max_documents budget exceeded: observed {observed} > limit {limit}"
268            ),
269            Self::MaxIncludeSources { limit, observed } => write!(
270                f,
271                "max_include_sources budget exceeded: observed {observed} > limit {limit}"
272            ),
273            Self::MaxIncludeBytes { limit, observed } => write!(
274                f,
275                "max_total_include_bytes budget exceeded: observed {observed} > limit {limit}"
276            ),
277            Self::MaxMergeKeys { limit, observed } => write!(
278                f,
279                "max_merge_keys budget exceeded: observed {observed} > limit {limit}"
280            ),
281            Self::AliasAnchorRatio {
282                ratio,
283                anchors,
284                aliases,
285            } => write!(
286                f,
287                "alias_anchor_ratio heuristic tripped: {aliases} aliases / {anchors} anchors > {ratio}"
288            ),
289            Self::MaxSequenceLength { limit, observed } => write!(
290                f,
291                "max_sequence_length budget exceeded: sequence length limit {limit}, observed {observed}"
292            ),
293            Self::MaxMappingKeys { limit, observed } => write!(
294                f,
295                "max_mapping_keys budget exceeded: mapping key limit {limit}, observed {observed}"
296            ),
297        }
298    }
299}
300
301/// Coarse-grained classification of an [`Error`], exposed for
302/// callers that need to route errors without pattern-matching
303/// every variant. Prefer this over inventing per-variant boolean
304/// accessors (`is_syntax()`, `is_io()`, …): one classifier scales
305/// as new variants land under [`Error`]'s `#[non_exhaustive]`
306/// attribute.
307///
308/// This enum is itself `#[non_exhaustive]` so future variants
309/// (e.g. a dedicated Timeout kind) can be added without a semver
310/// break.
311///
312/// # Examples
313///
314/// ```
315/// use noyalib::{from_str, ErrorKind, Value};
316/// let err = from_str::<Value>("1: a\n\"1\": b\n").unwrap_err();
317/// assert_eq!(err.kind(), ErrorKind::KeyCollision);
318/// ```
319#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
320#[non_exhaustive]
321pub enum ErrorKind {
322    /// The document was malformed: unterminated flow collection,
323    /// bad indentation, invalid escape, unexpected token, etc.
324    Syntax,
325    /// An I/O operation failed while reading the input.
326    Io,
327    /// A configurable DoS budget or hard security limit was
328    /// exceeded (recursion depth, alias expansion, mapping size,
329    /// merge-key count, …). Covers every `Error::Budget` breach
330    /// as well as [`Error::RecursionLimitExceeded`] and
331    /// [`Error::RepetitionLimitExceeded`].
332    Budget,
333    /// A user-supplied [`crate::policy::Policy`] rejected the
334    /// document, or a policy-only structural rule (merge-value
335    /// shape, scalar-in-merge) tripped.
336    Policy,
337    /// Two distinct-typed YAML keys collapsed to the same string
338    /// key — see [`Error::KeyCollision`].
339    KeyCollision,
340    /// A plain decimal integer beyond `u64::MAX` was refused under
341    /// [`crate::ParserConfig::integer_overflow_errors`] — see
342    /// [`Error::IntegerOverflow`].
343    IntegerOverflow,
344    /// A non-scalar mapping key was refused under
345    /// [`crate::NonScalarKeyPolicy::Error`] — see
346    /// [`Error::NonScalarKey`].
347    NonScalarKey,
348    /// A genuine duplicate key was refused under
349    /// [`crate::DuplicateKeyPolicy::Error`].
350    DuplicateKey,
351    /// The parser reached end-of-stream where more input was
352    /// required.
353    EndOfStream,
354    /// The document parsed but the requested target type could
355    /// not be built (missing field, unknown field, type mismatch,
356    /// bad tag, unknown anchor, …). Covers the whole
357    /// serde-facing "shape doesn't match" family.
358    Data,
359    /// The error came in via [`Error::Custom`] or [`Error::Message`]
360    /// (usually from a `serde_core::de::Error` bridge) and doesn't map
361    /// cleanly to any of the other kinds.
362    Other,
363}
364
365/// # Examples
366///
367/// ```
368/// use noyalib::{from_str, Error, Value};
369/// let err = from_str::<Value>("a: [unclosed").unwrap_err();
370/// assert!(matches!(err, Error::Parse(_) | Error::ParseWithLocation { .. }));
371/// ```
372#[derive(Debug)]
373#[non_exhaustive]
374pub enum Error {
375    /// Error during YAML parsing.
376    ///
377    /// # Examples
378    ///
379    /// ```
380    /// let _e = noyalib::Error::Parse("unexpected token".into());
381    /// ```
382    Parse(String),
383
384    /// Error during YAML parsing with location information.
385    ///
386    /// # Examples
387    ///
388    /// ```
389    /// use noyalib::{Error, Location};
390    /// let _e = Error::ParseWithLocation {
391    ///     message: "bad token".into(),
392    ///     location: Location::from_index("a: [", 3),
393    /// };
394    /// ```
395    ParseWithLocation {
396        /// The error message.
397        ///
398        /// # Examples
399        ///
400        /// ```
401        /// use noyalib::{Error, Location};
402        /// let e = Error::ParseWithLocation {
403        ///     message: "bad".into(),
404        ///     location: Location::default(),
405        /// };
406        /// if let Error::ParseWithLocation { message, .. } = e {
407        ///     assert_eq!(message, "bad");
408        /// }
409        /// ```
410        message: String,
411        /// The location in the source where the error occurred.
412        ///
413        /// # Examples
414        ///
415        /// ```
416        /// use noyalib::{Error, Location};
417        /// let e = Error::ParseWithLocation {
418        ///     message: "x".into(),
419        ///     location: Location::from_index("abc", 1),
420        /// };
421        /// if let Error::ParseWithLocation { location, .. } = e {
422        ///     assert_eq!(location.column(), 2);
423        /// }
424        /// ```
425        location: Location,
426    },
427
428    /// Error during serialization.
429    ///
430    /// # Examples
431    ///
432    /// ```
433    /// let _e = noyalib::Error::Serialize("bad value".into());
434    /// ```
435    Serialize(String),
436
437    /// Error during deserialization.
438    ///
439    /// # Examples
440    ///
441    /// ```
442    /// let _e = noyalib::Error::Deserialize("type mismatch".into());
443    /// ```
444    Deserialize(String),
445
446    /// Error during deserialization with location information.
447    ///
448    /// # Examples
449    ///
450    /// ```
451    /// use noyalib::{Error, Location};
452    /// let _e = Error::DeserializeWithLocation {
453    ///     message: "expected int".into(),
454    ///     location: Location::default(),
455    /// };
456    /// ```
457    DeserializeWithLocation {
458        /// The error message.
459        ///
460        /// # Examples
461        ///
462        /// ```
463        /// use noyalib::{Error, Location};
464        /// let e = Error::DeserializeWithLocation {
465        ///     message: "m".into(),
466        ///     location: Location::default(),
467        /// };
468        /// if let Error::DeserializeWithLocation { message, .. } = e {
469        ///     assert_eq!(message, "m");
470        /// }
471        /// ```
472        message: String,
473        /// The location in the source where the error occurred.
474        ///
475        /// # Examples
476        ///
477        /// ```
478        /// use noyalib::{Error, Location};
479        /// let e = Error::DeserializeWithLocation {
480        ///     message: "m".into(),
481        ///     location: Location::from_index("ab", 1),
482        /// };
483        /// if let Error::DeserializeWithLocation { location, .. } = e {
484        ///     assert_eq!(location.column(), 2);
485        /// }
486        /// ```
487        location: Location,
488    },
489
490    /// I/O error (requires std feature).
491    ///
492    /// # Examples
493    ///
494    /// ```
495    /// let ioe = std::io::Error::new(std::io::ErrorKind::Other, "nope");
496    /// let _e = noyalib::Error::Io(ioe);
497    /// ```
498    #[cfg(feature = "std")]
499    Io(std::io::Error),
500
501    /// Custom error message.
502    ///
503    /// # Examples
504    ///
505    /// ```
506    /// let _e = noyalib::Error::Custom("whatever".into());
507    /// ```
508    Custom(String),
509
510    /// Error when recursion depth limit is exceeded.
511    ///
512    /// # Examples
513    ///
514    /// ```
515    /// let _e = noyalib::Error::RecursionLimitExceeded { depth: 64 };
516    /// ```
517    RecursionLimitExceeded {
518        /// The current depth.
519        ///
520        /// # Examples
521        ///
522        /// ```
523        /// use noyalib::Error;
524        /// if let Error::RecursionLimitExceeded { depth } =
525        ///     (Error::RecursionLimitExceeded { depth: 10 })
526        /// {
527        ///     assert_eq!(depth, 10);
528        /// }
529        /// ```
530        depth: usize,
531    },
532
533    /// Error when a duplicate key is encountered.
534    ///
535    /// # Examples
536    ///
537    /// ```
538    /// let _e = noyalib::Error::DuplicateKey("name".into());
539    /// ```
540    DuplicateKey(String),
541
542    /// A duplicate mapping key, with where it is.
543    ///
544    /// The located form of [`Self::DuplicateKey`], raised under
545    /// [`crate::DuplicateKeyPolicy::Error`] by the parsers that know the
546    /// key's position -- every `from_str` entry point and the CST parser.
547    /// `path` is the dotted path of the entry (`site.name`; a sequence
548    /// index counts as a segment, `items.0.name`), `location` where the
549    /// second occurrence begins. [`Self::kind`] reports
550    /// [`ErrorKind::DuplicateKey`] for both forms and [`Self::location`]
551    /// returns the position.
552    ///
553    /// # Examples
554    ///
555    /// ```
556    /// use noyalib::{DuplicateKeyPolicy, Error, ParserConfig, Value, from_str_with_config};
557    /// let cfg = ParserConfig::new().duplicate_key_policy(DuplicateKeyPolicy::Error);
558    /// let err = from_str_with_config::<Value>("site:\n  name: a\n  name: b\n", &cfg).unwrap_err();
559    /// assert!(matches!(err, Error::DuplicateKeyAt { .. }));
560    /// assert_eq!(
561    ///     err.to_string(),
562    ///     "site.name: duplicate key \"name\" at line 3, column 3"
563    /// );
564    /// ```
565    DuplicateKeyAt {
566        /// The key that appears twice.
567        key: String,
568        /// The dotted path of the entry, the key included.
569        path: String,
570        /// Where the second occurrence begins.
571        location: Location,
572    },
573
574    /// Two distinct-typed keys collapsed to the same string key.
575    ///
576    /// The mapping key model is `Mapping<String, Value>`, so keys are
577    /// stringified. Distinct YAML keys that share a spelling — e.g. the
578    /// integer `1` and the string `"1"`, or `true` and `"true"` — would
579    /// silently overwrite each other, losing an entry. This is raised
580    /// instead, carrying the collapsed string key. Unlike
581    /// [`Self::DuplicateKey`], it fires regardless of `DuplicateKeyPolicy`
582    /// because it is data loss, not an authored duplicate.
583    ///
584    /// # Examples
585    ///
586    /// The construction shape:
587    ///
588    /// ```
589    /// let _e = noyalib::Error::KeyCollision("1".into());
590    /// ```
591    ///
592    /// Reproduces from real YAML. The integer key `1` and the
593    /// string key `"1"` both stringify to `"1"`, so parsing must
594    /// refuse rather than silently drop the first entry:
595    ///
596    /// ```
597    /// use noyalib::{Error, Value, from_str};
598    /// let err = from_str::<Value>("1: a\n\"1\": b\n").unwrap_err();
599    /// assert!(matches!(err, Error::KeyCollision(_) | Error::KeyCollisionAt { .. }));
600    /// ```
601    KeyCollision(String),
602
603    /// Two distinct-typed keys collapsed to the same string key, with
604    /// where the second one is.
605    ///
606    /// The located form of [`Self::KeyCollision`], raised by the parsers
607    /// that know the key's position. `path` is the dotted path of the
608    /// entry, `location` where the colliding key begins. [`Self::kind`]
609    /// reports [`ErrorKind::KeyCollision`] for both forms.
610    ///
611    /// # Examples
612    ///
613    /// ```
614    /// use noyalib::{Error, Value, from_str};
615    /// let err = from_str::<Value>("m:\n  1: a\n  \"1\": b\n").unwrap_err();
616    /// assert_eq!(err.kind(), noyalib::ErrorKind::KeyCollision);
617    /// assert_eq!(err.location().map(|l| (l.line(), l.column())), Some((3, 3)));
618    /// assert!(err.to_string().starts_with("m.1: distinct mapping keys collide"));
619    /// ```
620    KeyCollisionAt {
621        /// The collapsed string key.
622        key: String,
623        /// The dotted path of the entry, the key included.
624        path: String,
625        /// Where the colliding key begins.
626        location: Location,
627    },
628
629    /// Repetition limit exceeded (security limit against billion-laughs).
630    ///
631    /// # Examples
632    ///
633    /// ```
634    /// let _e = noyalib::Error::RepetitionLimitExceeded;
635    /// ```
636    RepetitionLimitExceeded,
637
638    /// A plain decimal integer beyond `u64::MAX` was refused under
639    /// [`crate::ParserConfig::integer_overflow_errors`].
640    ///
641    /// # Examples
642    ///
643    /// ```
644    /// use noyalib::{ParserConfig, Value, from_str_with_config};
645    /// let mut cfg = ParserConfig::new();
646    /// cfg.integer_overflow_errors = true;
647    /// let err = from_str_with_config::<Value>("18446744073709551616", &cfg).unwrap_err();
648    /// assert!(matches!(err, noyalib::Error::IntegerOverflow { .. }));
649    /// ```
650    IntegerOverflow {
651        /// Location of the overflowing literal's first byte.
652        location: Option<Location>,
653        /// Dotted path of the field holding the literal, when the
654        /// enclosing frames knew it (`server.port`, `items.3`).
655        path: Option<String>,
656    },
657
658    /// A non-scalar mapping key was refused under
659    /// [`crate::NonScalarKeyPolicy::Error`].
660    ///
661    /// # Examples
662    ///
663    /// ```
664    /// use noyalib::{NonScalarKeyPolicy, ParserConfig, Value, from_str_with_config};
665    /// let mut cfg = ParserConfig::new();
666    /// cfg.non_scalar_key_policy = NonScalarKeyPolicy::Error;
667    /// let err = from_str_with_config::<Value>("[a]: v", &cfg).unwrap_err();
668    /// assert!(matches!(err, noyalib::Error::NonScalarKey { .. }));
669    /// ```
670    NonScalarKey {
671        /// `"sequence"` or `"mapping"` — the key's YAML kind.
672        kind: &'static str,
673        /// Location of the key's first byte.
674        location: Option<Location>,
675    },
676
677    /// A configurable parser budget was exceeded.
678    ///
679    /// Carries a [`BudgetBreach`] identifying which limit fired,
680    /// the configured cap, and (where meaningful) the observed
681    /// value at the moment the cap tripped. Distinct from the
682    /// older [`Error::RecursionLimitExceeded`] /
683    /// [`Error::RepetitionLimitExceeded`] variants — those stay
684    /// for backwards compatibility on the depth / alias-expansion
685    /// limits; new budgets in the v0.0.2 expansion (`max_events`,
686    /// `max_nodes`, `max_total_scalar_bytes`, `max_documents`,
687    /// `max_merge_keys`, `alias_anchor_ratio`) all flow through
688    /// `Error::Budget`.
689    ///
690    /// # Examples
691    ///
692    /// ```
693    /// use noyalib::{BudgetBreach, Error};
694    /// let _e = Error::Budget(BudgetBreach::MaxDocuments {
695    ///     limit: 1_000,
696    ///     observed: 1_001,
697    /// });
698    /// ```
699    Budget(BudgetBreach),
700
701    /// Unknown anchor encountered.
702    ///
703    /// # Examples
704    ///
705    /// ```
706    /// let _e = noyalib::Error::UnknownAnchor("missing".into());
707    /// ```
708    UnknownAnchor(String),
709
710    /// Unknown anchor encountered at a specific location.
711    ///
712    /// # Examples
713    ///
714    /// ```
715    /// use noyalib::{Error, Location};
716    /// let _e = Error::UnknownAnchorAt {
717    ///     name: "x".into(),
718    ///     location: Location::default(),
719    ///     suggestion: None,
720    /// };
721    /// ```
722    UnknownAnchorAt {
723        /// The anchor name.
724        ///
725        /// # Examples
726        ///
727        /// ```
728        /// use noyalib::{Error, Location};
729        /// let e = Error::UnknownAnchorAt {
730        ///     name: "x".into(),
731        ///     location: Location::default(),
732        ///     suggestion: None,
733        /// };
734        /// if let Error::UnknownAnchorAt { name, .. } = e {
735        ///     assert_eq!(name, "x");
736        /// }
737        /// ```
738        name: String,
739        /// The location where it was used.
740        ///
741        /// # Examples
742        ///
743        /// ```
744        /// use noyalib::{Error, Location};
745        /// let e = Error::UnknownAnchorAt {
746        ///     name: "x".into(),
747        ///     location: Location::from_index("ab", 1),
748        ///     suggestion: None,
749        /// };
750        /// if let Error::UnknownAnchorAt { location, .. } = e {
751        ///     assert_eq!(location.column(), 2);
752        /// }
753        /// ```
754        location: Location,
755        /// Optional suggestion for a similar anchor.
756        ///
757        /// # Examples
758        ///
759        /// ```
760        /// use noyalib::{Error, Location};
761        /// let e = Error::UnknownAnchorAt {
762        ///     name: "x".into(),
763        ///     location: Location::default(),
764        ///     suggestion: Some(("y".into(), Location::default())),
765        /// };
766        /// if let Error::UnknownAnchorAt { suggestion: Some((s, _)), .. } = e {
767        ///     assert_eq!(s, "y");
768        /// }
769        /// ```
770        suggestion: Option<(String, Location)>,
771    },
772
773    /// Missing field in a mapping.
774    ///
775    /// # Examples
776    ///
777    /// ```
778    /// let _e = noyalib::Error::MissingField("name".into());
779    /// ```
780    MissingField(String),
781
782    /// Unknown field in a mapping (with `deny_unknown_fields`).
783    ///
784    /// # Examples
785    ///
786    /// ```
787    /// let _e = noyalib::Error::UnknownField("extra".into());
788    /// ```
789    UnknownField(String),
790
791    /// Scalar encountered where a mapping was expected during merge.
792    ///
793    /// # Examples
794    ///
795    /// ```
796    /// let _e = noyalib::Error::ScalarInMergeElement;
797    /// ```
798    ScalarInMergeElement,
799
800    /// Sequence encountered where a mapping was expected during merge.
801    ///
802    /// # Examples
803    ///
804    /// ```
805    /// let _e = noyalib::Error::SequenceInMergeElement;
806    /// ```
807    SequenceInMergeElement,
808
809    /// Tagged value encountered during merge.
810    ///
811    /// # Examples
812    ///
813    /// ```
814    /// let _e = noyalib::Error::TaggedInMerge;
815    /// ```
816    TaggedInMerge,
817
818    /// Generic invalid construct error.
819    ///
820    /// # Examples
821    ///
822    /// ```
823    /// let _e = noyalib::Error::Invalid("bad construct".into());
824    /// ```
825    Invalid(String),
826
827    /// A type mismatch error.
828    ///
829    /// # Examples
830    ///
831    /// ```
832    /// let _e = noyalib::Error::TypeMismatch {
833    ///     expected: "integer",
834    ///     found: "string".into(),
835    /// };
836    /// ```
837    TypeMismatch {
838        /// The expected type.
839        ///
840        /// # Examples
841        ///
842        /// ```
843        /// use noyalib::Error;
844        /// let e = Error::TypeMismatch { expected: "int", found: "str".into() };
845        /// if let Error::TypeMismatch { expected, .. } = e {
846        ///     assert_eq!(expected, "int");
847        /// }
848        /// ```
849        expected: &'static str,
850        /// The type that was actually found.
851        ///
852        /// # Examples
853        ///
854        /// ```
855        /// use noyalib::Error;
856        /// let e = Error::TypeMismatch { expected: "int", found: "str".into() };
857        /// if let Error::TypeMismatch { found, .. } = e {
858        ///     assert_eq!(found, "str");
859        /// }
860        /// ```
861        found: String,
862    },
863
864    /// Shared error instance (Arc-wrapped for cloning).
865    ///
866    /// # Examples
867    ///
868    /// ```
869    /// use std::sync::Arc;
870    /// let _e = noyalib::Error::Shared(Arc::new(noyalib::Error::EndOfStream));
871    /// ```
872    Shared(Arc<Self>),
873
874    /// End of stream reached unexpectedly.
875    ///
876    /// # Examples
877    ///
878    /// ```
879    /// let _e = noyalib::Error::EndOfStream;
880    /// ```
881    EndOfStream,
882
883    /// More than one document found where one was expected.
884    ///
885    /// # Examples
886    ///
887    /// ```
888    /// let _e = noyalib::Error::MoreThanOneDocument;
889    /// ```
890    MoreThanOneDocument,
891
892    /// Scalar in merge (legacy variant).
893    ///
894    /// # Examples
895    ///
896    /// ```
897    /// let _e = noyalib::Error::ScalarInMerge;
898    /// ```
899    ScalarInMerge,
900
901    /// Empty tag encountered.
902    ///
903    /// # Examples
904    ///
905    /// ```
906    /// let _e = noyalib::Error::EmptyTag;
907    /// ```
908    EmptyTag,
909
910    /// Failed to parse a number.
911    ///
912    /// # Examples
913    ///
914    /// ```
915    /// let _e = noyalib::Error::FailedToParseNumber("not-a-number".into());
916    /// ```
917    FailedToParseNumber(String),
918
919    /// A message error from Serde (compat variant).
920    ///
921    /// # Examples
922    ///
923    /// ```
924    /// let _e = noyalib::Error::Message("oops".into(), Some(42));
925    /// ```
926    Message(String, Option<usize>),
927}
928
929// ── Manual `Display` + `Error` impls ───────────────────────────────────
930//
931// noyalib does not depend on `thiserror` so the proc-macro
932// expansion cost stays out of every downstream crate's compile.
933// These impls reproduce the exact format strings the previous
934// `#[error("...")]` attributes generated, so the user-visible
935// `Display` output is byte-stable across the migration.
936
937impl fmt::Display for Error {
938    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
939        match self {
940            Self::Parse(msg) => write!(f, "YAML parse error: {msg}"),
941            Self::ParseWithLocation { message, location } => {
942                write!(f, "YAML parse error at {location}: {message}")
943            }
944            Self::Serialize(msg) => write!(f, "serialization error: {msg}"),
945            Self::Deserialize(msg) => write!(f, "deserialization error: {msg}"),
946            Self::DeserializeWithLocation { message, location } => {
947                write!(f, "deserialization error at {location}: {message}")
948            }
949            #[cfg(feature = "std")]
950            Self::Io(e) => write!(f, "I/O error: {e}"),
951            Self::Custom(msg) => f.write_str(msg),
952            Self::RecursionLimitExceeded { depth } => {
953                write!(f, "recursion depth limit exceeded: {depth}")
954            }
955            Self::DuplicateKey(name) => write!(f, "duplicate key: {name}"),
956            Self::DuplicateKeyAt {
957                key,
958                path,
959                location,
960            } => write!(f, "{path}: duplicate key \"{key}\" at {location}"),
961            Self::KeyCollision(name) => write!(
962                f,
963                "distinct mapping keys collide after string conversion: {name} \
964                 (e.g. `1` and `\"1\"`, or `true` and `\"true\"`)"
965            ),
966            Self::KeyCollisionAt {
967                key,
968                path,
969                location,
970            } => write!(
971                f,
972                "{path}: distinct mapping keys collide after string conversion: {key} \
973                 (e.g. `1` and `\"1\"`, or `true` and `\"true\"`) at {location}"
974            ),
975            Self::RepetitionLimitExceeded => f.write_str("alias expansion limit exceeded"),
976            Self::IntegerOverflow { .. } => {
977                f.write_str("integer does not fit in 64 bits (JSON number out of range)")
978            }
979            Self::NonScalarKey { kind, .. } => {
980                write!(f, "invalid type: {kind}, expected a string key")
981            }
982            Self::Budget(breach) => write!(f, "{breach}"),
983            Self::UnknownAnchor(name) => write!(f, "unknown anchor: {name}"),
984            Self::UnknownAnchorAt {
985                name,
986                location,
987                suggestion,
988            } => {
989                write!(f, "unknown anchor: {name} at {location}")?;
990                match suggestion {
991                    // The same name anchored earlier in the stream: the
992                    // alias reaches across a document boundary. An alias
993                    // to an anchor still being defined never reaches
994                    // here; the loader reports that as its own parse
995                    // error, because it is a cycle, not a lookup miss.
996                    Some((s, at)) if s == name => write!(
997                        f,
998                        " (`&{name}` is defined at {at}, in an earlier document; \
999                         anchors do not cross `---`)"
1000                    ),
1001                    Some((s, _)) => write!(f, " (did you mean `{s}`?)"),
1002                    None => Ok(()),
1003                }
1004            }
1005            Self::MissingField(name) => write!(f, "missing field: {name}"),
1006            Self::UnknownField(name) => write!(f, "unknown field: {name}"),
1007            Self::ScalarInMergeElement => f.write_str("scalar in merge element"),
1008            Self::SequenceInMergeElement => f.write_str("sequence in merge element"),
1009            Self::TaggedInMerge => f.write_str("tagged value in merge"),
1010            Self::Invalid(msg) => write!(f, "invalid YAML: {msg}"),
1011            Self::TypeMismatch { expected, found } => {
1012                write!(f, "type mismatch: expected {expected}, found {found}")
1013            }
1014            Self::Shared(arc) => fmt::Display::fmt(arc.as_ref(), f),
1015            Self::EndOfStream => f.write_str("unexpected end of stream"),
1016            Self::MoreThanOneDocument => f.write_str(
1017                "deserializing from YAML containing more than one document is not supported",
1018            ),
1019            Self::ScalarInMerge => f.write_str("scalar in merge"),
1020            Self::EmptyTag => f.write_str("empty tag"),
1021            Self::FailedToParseNumber(msg) => write!(f, "failed to parse number: {msg}"),
1022            Self::Message(msg, _) => write!(f, "serde error: {msg}"),
1023        }
1024    }
1025}
1026
1027// `core::error::Error` stabilised in Rust 1.81 — the crate MSRV
1028// is 1.89, so the trait implementation is unconditional. Only
1029// the `Io(std::io::Error)` arm needs an in-line `cfg` gate,
1030// because `Error::Io` itself is `#[cfg(feature = "std")]`. Under
1031// `no_std` callers keep full access to the trait for routing via
1032// `core::error::Error`-consuming ecosystems (`snafu-nostd`,
1033// `miette` no-std shim, embedded HAL error stacks).
1034impl core::error::Error for Error {
1035    fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
1036        match self {
1037            #[cfg(feature = "std")]
1038            Self::Io(e) => Some(e),
1039            Self::Shared(arc) => Some(arc.as_ref()),
1040            _ => None,
1041        }
1042    }
1043}
1044
1045#[cfg(feature = "std")]
1046impl From<std::io::Error> for Error {
1047    fn from(e: std::io::Error) -> Self {
1048        Self::Io(e)
1049    }
1050}
1051
1052impl Error {
1053    /// Get the location of the error, if any.
1054    ///
1055    /// # Examples
1056    ///
1057    /// ```
1058    /// use noyalib::{from_str, Value};
1059    /// let err = from_str::<Value>("a: [unclosed").unwrap_err();
1060    /// let _ = err.location();
1061    /// ```
1062    #[must_use]
1063    pub fn location(&self) -> Option<Location> {
1064        match self {
1065            Self::ParseWithLocation { location, .. } => Some(*location),
1066            Self::DeserializeWithLocation { location, .. } => Some(*location),
1067            Self::UnknownAnchorAt { location, .. } => Some(*location),
1068            Self::DuplicateKeyAt { location, .. } | Self::KeyCollisionAt { location, .. } => {
1069                Some(*location)
1070            }
1071            Self::IntegerOverflow { location, .. } | Self::NonScalarKey { location, .. } => {
1072                *location
1073            }
1074            Self::Shared(arc) => arc.location(),
1075            _ => None,
1076        }
1077    }
1078
1079    /// Move every location on this error from the slice of `source`
1080    /// that starts at byte `base` to `source` itself.
1081    ///
1082    /// The stream parsers parse each document from its own slice, so
1083    /// the locations they get back count from that slice. This
1084    /// re-anchors them on the whole input: the byte index shifts by
1085    /// `base`, and line and column are recomputed from `source` the
1086    /// way every located error computes them. A `base` of zero is the
1087    /// identity. Variants without a location, and a shared error, are
1088    /// returned unchanged.
1089    ///
1090    /// Gated like its only caller, the `cst` module: without `std` it
1091    /// would be dead code, which the crate denies.
1092    #[cfg(feature = "std")]
1093    pub(crate) fn relocate(self, source: &str, base: usize) -> Self {
1094        if base == 0 {
1095            return self;
1096        }
1097        let shift = |loc: Location| Location::from_index(source, base + loc.index());
1098        match self {
1099            Self::ParseWithLocation { message, location } => Self::ParseWithLocation {
1100                message,
1101                location: shift(location),
1102            },
1103            Self::DeserializeWithLocation { message, location } => Self::DeserializeWithLocation {
1104                message,
1105                location: shift(location),
1106            },
1107            Self::UnknownAnchorAt {
1108                name,
1109                location,
1110                suggestion,
1111            } => Self::UnknownAnchorAt {
1112                name,
1113                location: shift(location),
1114                suggestion: suggestion.map(|(name, loc)| (name, shift(loc))),
1115            },
1116            Self::DuplicateKeyAt {
1117                key,
1118                path,
1119                location,
1120            } => Self::DuplicateKeyAt {
1121                key,
1122                path,
1123                location: shift(location),
1124            },
1125            Self::KeyCollisionAt {
1126                key,
1127                path,
1128                location,
1129            } => Self::KeyCollisionAt {
1130                key,
1131                path,
1132                location: shift(location),
1133            },
1134            Self::IntegerOverflow { location, path } => Self::IntegerOverflow {
1135                location: location.map(shift),
1136                path,
1137            },
1138            Self::NonScalarKey { kind, location } => Self::NonScalarKey {
1139                kind,
1140                location: location.map(shift),
1141            },
1142            other => other,
1143        }
1144    }
1145
1146    /// Coarse-grained classification for routing without matching
1147    /// every variant of the `#[non_exhaustive]` [`Error`] enum.
1148    ///
1149    /// The mapping is stable across variant additions: new
1150    /// variants land under an existing [`ErrorKind`] whenever
1151    /// possible, so downstream `match err.kind()` sites keep
1152    /// compiling. When a new *category* is needed the enum grows
1153    /// (also `#[non_exhaustive]`).
1154    ///
1155    /// # Examples
1156    ///
1157    /// ```
1158    /// use noyalib::{from_str, ErrorKind, Value};
1159    ///
1160    /// let syntax = from_str::<Value>("a: [unclosed").unwrap_err();
1161    /// assert_eq!(syntax.kind(), ErrorKind::Syntax);
1162    ///
1163    /// let collision = from_str::<Value>("1: a\n\"1\": b\n").unwrap_err();
1164    /// assert_eq!(collision.kind(), ErrorKind::KeyCollision);
1165    /// ```
1166    #[must_use]
1167    pub fn kind(&self) -> ErrorKind {
1168        match self {
1169            Self::Parse(_) | Self::ParseWithLocation { .. } => ErrorKind::Syntax,
1170            Self::Serialize(_) => ErrorKind::Data,
1171            Self::Deserialize(_) | Self::DeserializeWithLocation { .. } => ErrorKind::Data,
1172            #[cfg(feature = "std")]
1173            Self::Io(_) => ErrorKind::Io,
1174            Self::Custom(_) => ErrorKind::Other,
1175            Self::RecursionLimitExceeded { .. } => ErrorKind::Budget,
1176            Self::DuplicateKey(_) | Self::DuplicateKeyAt { .. } => ErrorKind::DuplicateKey,
1177            Self::KeyCollision(_) | Self::KeyCollisionAt { .. } => ErrorKind::KeyCollision,
1178            Self::RepetitionLimitExceeded => ErrorKind::Budget,
1179            Self::IntegerOverflow { .. } => ErrorKind::IntegerOverflow,
1180            Self::NonScalarKey { .. } => ErrorKind::NonScalarKey,
1181            Self::Budget(_) => ErrorKind::Budget,
1182            Self::UnknownAnchor(_) | Self::UnknownAnchorAt { .. } => ErrorKind::Data,
1183            Self::MissingField(_) | Self::UnknownField(_) => ErrorKind::Data,
1184            Self::ScalarInMergeElement
1185            | Self::SequenceInMergeElement
1186            | Self::TaggedInMerge
1187            | Self::ScalarInMerge => ErrorKind::Policy,
1188            Self::Invalid(_) => ErrorKind::Data,
1189            Self::TypeMismatch { .. } => ErrorKind::Data,
1190            Self::Shared(arc) => arc.kind(),
1191            Self::EndOfStream => ErrorKind::EndOfStream,
1192            Self::MoreThanOneDocument => ErrorKind::Data,
1193            Self::EmptyTag => ErrorKind::Syntax,
1194            Self::FailedToParseNumber(_) => ErrorKind::Syntax,
1195            Self::Message(_, _) => ErrorKind::Other,
1196        }
1197    }
1198
1199    /// Convert this error into the canonical framework-neutral diagnostic.
1200    ///
1201    /// The returned payload carries a stable code, severity, rendered
1202    /// message, optional help, and ordered source labels. Terminal, editor,
1203    /// and service adapters should consume this method instead of maintaining
1204    /// their own error-variant mappings.
1205    ///
1206    /// # Examples
1207    ///
1208    /// ```
1209    /// use noyalib::{DiagnosticCode, Value, from_str};
1210    /// let err = from_str::<Value>("key: [unclosed").unwrap_err();
1211    /// let diagnostic = err.diagnostic();
1212    /// assert_eq!(diagnostic.code(), DiagnosticCode::Parse);
1213    /// assert!(diagnostic.primary_label().is_some());
1214    /// ```
1215    #[must_use]
1216    pub fn diagnostic(&self) -> Diagnostic {
1217        if let Self::Shared(error) = self {
1218            return error.diagnostic();
1219        }
1220
1221        let mut diagnostic = Diagnostic::new(
1222            self.diagnostic_code(),
1223            DiagnosticSeverity::Error,
1224            self.to_string(),
1225        );
1226        if let Some(help) = self.diagnostic_help() {
1227            diagnostic = diagnostic.with_help(help);
1228        }
1229
1230        match self {
1231            Self::ParseWithLocation { message, location }
1232            | Self::DeserializeWithLocation { message, location } => diagnostic.with_label(
1233                DiagnosticLabel::primary(SourceSpan::new(location.index(), 1), message.clone()),
1234            ),
1235            Self::UnknownAnchorAt {
1236                name,
1237                location,
1238                suggestion,
1239            } => {
1240                let mut diagnostic = diagnostic.with_label(DiagnosticLabel::primary(
1241                    SourceSpan::new(location.index(), 1),
1242                    format!("unknown anchor '{name}'"),
1243                ));
1244                if let Some((suggestion, suggestion_location)) = suggestion {
1245                    diagnostic = diagnostic.with_label(DiagnosticLabel::secondary(
1246                        SourceSpan::new(suggestion_location.index(), 1),
1247                        format!("did you mean '&{suggestion}'?"),
1248                    ));
1249                }
1250                diagnostic
1251            }
1252            Self::DuplicateKeyAt { key, location, .. } => {
1253                diagnostic.with_label(DiagnosticLabel::primary(
1254                    SourceSpan::new(location.index(), 1),
1255                    format!("duplicate key '{key}'"),
1256                ))
1257            }
1258            Self::KeyCollisionAt { key, location, .. } => {
1259                diagnostic.with_label(DiagnosticLabel::primary(
1260                    SourceSpan::new(location.index(), 1),
1261                    format!("key collision for '{key}'"),
1262                ))
1263            }
1264            Self::IntegerOverflow {
1265                location: Some(location),
1266                ..
1267            } => diagnostic.with_label(DiagnosticLabel::primary(
1268                SourceSpan::new(location.index(), 1),
1269                "integer is out of range",
1270            )),
1271            Self::NonScalarKey {
1272                kind,
1273                location: Some(location),
1274            } => diagnostic.with_label(DiagnosticLabel::primary(
1275                SourceSpan::new(location.index(), 1),
1276                format!("{kind} key is not scalar"),
1277            )),
1278            Self::Message(message, Some(offset)) => diagnostic.with_label(
1279                DiagnosticLabel::primary(SourceSpan::new(*offset, 1), message.clone()),
1280            ),
1281            _ => diagnostic,
1282        }
1283    }
1284
1285    /// Return the stable machine-readable diagnostic code.
1286    #[must_use]
1287    pub fn diagnostic_code(&self) -> DiagnosticCode {
1288        match self {
1289            Self::Parse(_) | Self::ParseWithLocation { .. } => DiagnosticCode::Parse,
1290            Self::Serialize(_) => DiagnosticCode::Serialize,
1291            Self::Deserialize(_) | Self::DeserializeWithLocation { .. } => {
1292                DiagnosticCode::Deserialize
1293            }
1294            Self::TypeMismatch { .. } => DiagnosticCode::TypeMismatch,
1295            Self::MissingField(_) => DiagnosticCode::MissingField,
1296            Self::UnknownField(_) => DiagnosticCode::UnknownField,
1297            Self::RecursionLimitExceeded { .. } => DiagnosticCode::RecursionLimit,
1298            Self::RepetitionLimitExceeded => DiagnosticCode::RepetitionLimit,
1299            Self::Budget(_) => DiagnosticCode::Budget,
1300            Self::UnknownAnchor(_) | Self::UnknownAnchorAt { .. } => DiagnosticCode::UnknownAnchor,
1301            Self::DuplicateKey(_) | Self::DuplicateKeyAt { .. } => DiagnosticCode::DuplicateKey,
1302            Self::KeyCollision(_) | Self::KeyCollisionAt { .. } => DiagnosticCode::KeyCollision,
1303            Self::IntegerOverflow { .. } => DiagnosticCode::IntegerOverflow,
1304            Self::NonScalarKey { .. } => DiagnosticCode::NonScalarKey,
1305            Self::EndOfStream => DiagnosticCode::EndOfStream,
1306            Self::MoreThanOneDocument => DiagnosticCode::MoreThanOneDocument,
1307            #[cfg(feature = "std")]
1308            Self::Io(_) => DiagnosticCode::Io,
1309            Self::Shared(error) => error.diagnostic_code(),
1310            Self::ScalarInMergeElement
1311            | Self::SequenceInMergeElement
1312            | Self::TaggedInMerge
1313            | Self::ScalarInMerge
1314            | Self::Invalid(_)
1315            | Self::EmptyTag
1316            | Self::FailedToParseNumber(_)
1317            | Self::Custom(_)
1318            | Self::Message(_, _) => DiagnosticCode::Other,
1319        }
1320    }
1321
1322    fn diagnostic_help(&self) -> Option<String> {
1323        match self {
1324            Self::UnknownAnchorAt {
1325                suggestion: Some((name, _)),
1326                ..
1327            } => Some(format!("did you mean '&{name}'?")),
1328            Self::UnknownAnchor(_) => {
1329                Some("define the anchor (&name) before referencing it".into())
1330            }
1331            Self::RecursionLimitExceeded { .. } => {
1332                Some("increase ParserConfig::max_depth or simplify nesting".into())
1333            }
1334            Self::RepetitionLimitExceeded => {
1335                Some("increase ParserConfig::max_alias_expansions or reduce alias usage".into())
1336            }
1337            Self::Budget(_) => {
1338                Some("raise the matching ParserConfig::max_* limit or simplify the input".into())
1339            }
1340            Self::DuplicateKey(_) | Self::DuplicateKeyAt { .. } => {
1341                Some("use DuplicateKeyPolicy::Last or ::Error to control behaviour".into())
1342            }
1343            Self::KeyCollision(_) | Self::KeyCollisionAt { .. } => Some(
1344                "give the colliding keys distinct spellings, or quote them consistently".into(),
1345            ),
1346            Self::MoreThanOneDocument => {
1347                Some("use noyalib::load_all() to parse multi-document streams".into())
1348            }
1349            Self::Shared(error) => error.diagnostic_help(),
1350            _ => None,
1351        }
1352    }
1353
1354    /// Format the error with source context. If the error carries a source
1355    /// location and the line is in range, the output includes a
1356    /// `line <n>:<col>` prefix, the offending line, and a caret (`^`)
1357    /// pointing at the column. Out-of-range lines fall back to plain
1358    /// `Display`.
1359    ///
1360    /// For rustc-style multi-line context with surrounding lines, use
1361    /// [`Self::format_with_source_radius`].
1362    ///
1363    /// # Examples
1364    ///
1365    /// ```
1366    /// use noyalib::{from_str, Value};
1367    /// let source = "a: [unclosed";
1368    /// let err = from_str::<Value>(source).unwrap_err();
1369    /// let formatted = err.format_with_source(source);
1370    /// assert!(formatted.contains("error"));
1371    /// ```
1372    #[must_use]
1373    pub fn format_with_source(&self, source: &str) -> String {
1374        let loc = match self.location() {
1375            Some(l) => l,
1376            None => return format!("{self}"),
1377        };
1378        let line_no = loc.line();
1379        let col = loc.column();
1380        let line_idx = line_no.saturating_sub(1);
1381        let line = match source.lines().nth(line_idx) {
1382            Some(l) => l,
1383            None if line_no == 0 => source.lines().next().unwrap_or(""),
1384            None => return format!("{self}"),
1385        };
1386        let caret_col = col.saturating_sub(1);
1387        let caret: String = core::iter::repeat_n(' ', caret_col)
1388            .chain(core::iter::once('^'))
1389            .collect();
1390        format!("error: {self}\n  --> line {line_no}:{col}\n  {line}\n  {caret}")
1391    }
1392
1393    /// Format the error with `radius` lines of context above and
1394    /// below the offending line — rustc-style. Each line gets a line
1395    /// number on the left; the caret line under the offending column
1396    /// is unnumbered. The output is byte-for-byte stable across
1397    /// minor releases (no terminal escape codes, no
1398    /// platform-conditional whitespace).
1399    ///
1400    /// Out-of-range locations fall back to plain `Display` (no
1401    /// snippet) — same contract as [`Self::format_with_source`].
1402    ///
1403    /// # Examples
1404    ///
1405    /// ```
1406    /// use noyalib::{from_str, Value};
1407    /// // Indentation-mismatch error — carries a concrete `(line,
1408    /// // column)` location, so the snippet renderer engages.
1409    /// let source = "\
1410    /// header: ok
1411    /// service:
1412    ///    nested: x
1413    ///   bad: y
1414    /// trailer: ok
1415    /// ";
1416    /// let e = from_str::<Value>(source).unwrap_err();
1417    /// let formatted = e.format_with_source_radius(source, 1);
1418    /// // Output includes the offending line plus a single line
1419    /// // of context above and below.
1420    /// assert!(formatted.contains("|"));
1421    /// assert!(formatted.contains("bad: y"));
1422    /// ```
1423    #[must_use]
1424    pub fn format_with_source_radius(&self, source: &str, radius: usize) -> String {
1425        let loc = match self.location() {
1426            Some(l) => l,
1427            None => return format!("{self}"),
1428        };
1429        let line_no = loc.line();
1430        let col = loc.column();
1431        let line_idx = line_no.saturating_sub(1);
1432
1433        let lines: Vec<&str> = source.lines().collect();
1434        if lines.is_empty() {
1435            return format!("{self}");
1436        }
1437        let target = match lines.get(line_idx) {
1438            Some(_) => line_idx,
1439            None if line_no == 0 => 0,
1440            None => return format!("{self}"),
1441        };
1442
1443        let lo = target.saturating_sub(radius);
1444        let hi = (target + radius).min(lines.len().saturating_sub(1));
1445        // Width of the highest line number we'll print, for column
1446        // alignment of the gutter.
1447        let gutter_w = (hi + 1).to_string().len();
1448        let caret_col = col.saturating_sub(1);
1449
1450        let mut out = format!("error: {self}\n");
1451        out.push_str(&format!("  --> line {line_no}:{col}\n"));
1452
1453        // Top spacer
1454        out.push_str(&format!("{:>w$} |\n", "", w = gutter_w));
1455        for (i, idx) in (lo..=hi).enumerate() {
1456            let n = idx + 1;
1457            let line_text = lines[idx];
1458            out.push_str(&format!("{n:>gutter_w$} | {line_text}\n"));
1459            if idx == target {
1460                // Caret line — gutter is blank, then `|`, then
1461                // spaces up to the column, then `^`.
1462                let pad = " ".repeat(caret_col);
1463                out.push_str(&format!("{:>w$} | {pad}^\n", "", w = gutter_w, pad = pad));
1464            }
1465            let _ = i;
1466        }
1467        // Bottom spacer
1468        out.push_str(&format!("{:>w$} |\n", "", w = gutter_w));
1469        out
1470    }
1471
1472    /// Format the error with source context, capped at `max_chars`
1473    /// **ASCII characters** — the bridged-channel-friendly variant
1474    /// of [`Self::format_with_source`]. Use when the diagnostic is
1475    /// destined for a Slack message, a Sentry tag, a structured
1476    /// log field, or any sink with a hard length budget.
1477    ///
1478    /// # Truncation contract
1479    ///
1480    /// 1. The output is plain ASCII (the renderer already emits no
1481    ///    ANSI escapes, so this is a no-op for that axis).
1482    /// 2. If the rendered string is `<= max_chars`, returns it
1483    ///    unchanged.
1484    /// 3. Otherwise truncates at a UTF-8 character boundary
1485    ///    `<= max_chars - 3` and appends an `...` ellipsis so the
1486    ///    final length is at most `max_chars`.
1487    /// 4. `max_chars` smaller than 3 keeps as much of the prefix
1488    ///    as fits and drops the ellipsis (so a `max_chars = 2`
1489    ///    yields exactly two characters of the message).
1490    ///
1491    /// # Examples
1492    ///
1493    /// ```
1494    /// use noyalib::{from_str, Value};
1495    /// let source = "a: [unclosed";
1496    /// let err = from_str::<Value>(source).unwrap_err();
1497    /// let short = err.format_with_source_truncated(source, 60);
1498    /// assert!(short.len() <= 60);
1499    /// // Untrimmed output is the same as `format_with_source`:
1500    /// let full = err.format_with_source(source);
1501    /// let unbounded = err.format_with_source_truncated(source, full.len() + 100);
1502    /// assert_eq!(unbounded, full);
1503    /// ```
1504    #[must_use]
1505    pub fn format_with_source_truncated(&self, source: &str, max_chars: usize) -> String {
1506        let full = self.format_with_source(source);
1507        truncate_with_ellipsis(full, max_chars)
1508    }
1509
1510    /// Format the error with multi-line `radius` context, capped
1511    /// at `max_chars`. Same truncation contract as
1512    /// [`Self::format_with_source_truncated`].
1513    ///
1514    /// # Examples
1515    ///
1516    /// ```
1517    /// use noyalib::{from_str, Value};
1518    /// let source = "a:\n  b:\n    c: [unclosed";
1519    /// let err = from_str::<Value>(source).unwrap_err();
1520    /// let s = err.format_with_source_radius_truncated(source, 1, 80);
1521    /// assert!(s.len() <= 80);
1522    /// ```
1523    #[must_use]
1524    pub fn format_with_source_radius_truncated(
1525        &self,
1526        source: &str,
1527        radius: usize,
1528        max_chars: usize,
1529    ) -> String {
1530        let full = self.format_with_source_radius(source, radius);
1531        truncate_with_ellipsis(full, max_chars)
1532    }
1533
1534    /// Convert the error into a shared Arc pointer. If the error is
1535    /// already `Error::Shared`, the inner `Arc` is reused without
1536    /// double-wrapping.
1537    ///
1538    /// # Examples
1539    ///
1540    /// ```
1541    /// let shared = noyalib::Error::EndOfStream.into_shared();
1542    /// assert!(matches!(&*shared, noyalib::Error::EndOfStream));
1543    /// ```
1544    #[must_use]
1545    pub fn into_shared(self) -> Arc<Self> {
1546        match self {
1547            Self::Shared(arc) => arc,
1548            other => Arc::new(other),
1549        }
1550    }
1551
1552    /// Check if the error is a shared error.
1553    ///
1554    /// # Examples
1555    ///
1556    /// ```
1557    /// use std::sync::Arc;
1558    /// let e = noyalib::Error::Shared(Arc::new(noyalib::Error::EndOfStream));
1559    /// assert!(e.is_shared());
1560    /// ```
1561    #[must_use]
1562    pub fn is_shared(&self) -> bool {
1563        matches!(self, Self::Shared(_))
1564    }
1565
1566    /// Access the inner error if this is a shared error.
1567    ///
1568    /// # Examples
1569    ///
1570    /// ```
1571    /// use std::sync::Arc;
1572    /// let e = noyalib::Error::Shared(Arc::new(noyalib::Error::EndOfStream));
1573    /// assert!(e.as_inner().is_some());
1574    /// ```
1575    #[must_use]
1576    pub fn as_inner(&self) -> Option<&Self> {
1577        match self {
1578            Self::Shared(arc) => Some(&**arc),
1579            _ => None,
1580        }
1581    }
1582
1583    /// Create a new parse error at the given index.
1584    ///
1585    /// # Examples
1586    ///
1587    /// ```
1588    /// let e = noyalib::Error::parse_at("bad", "a: x", 3);
1589    /// assert!(matches!(e, noyalib::Error::ParseWithLocation { .. }));
1590    /// ```
1591    pub fn parse_at(message: impl Into<String>, source: &str, index: usize) -> Self {
1592        Self::ParseWithLocation {
1593            message: message.into(),
1594            location: Location::from_index(source, index),
1595        }
1596    }
1597
1598    /// Create a new deserialization error at the given index.
1599    ///
1600    /// # Examples
1601    ///
1602    /// ```
1603    /// let e = noyalib::Error::deserialize_at("bad", "a: x", 3);
1604    /// assert!(matches!(e, noyalib::Error::DeserializeWithLocation { .. }));
1605    /// ```
1606    pub fn deserialize_at(message: impl Into<String>, source: &str, index: usize) -> Self {
1607        Self::DeserializeWithLocation {
1608            message: message.into(),
1609            location: Location::from_index(source, index),
1610        }
1611    }
1612
1613    /// Create a new error from a shared error pointer.
1614    ///
1615    /// # Examples
1616    ///
1617    /// ```
1618    /// use std::sync::Arc;
1619    /// let e = noyalib::Error::from_shared(Arc::new(noyalib::Error::EndOfStream));
1620    /// assert!(e.is_shared());
1621    /// ```
1622    #[must_use]
1623    pub fn from_shared(arc: Arc<Self>) -> Self {
1624        Self::Shared(arc)
1625    }
1626
1627    /// Render the error in rustc-style with default options.
1628    ///
1629    /// Equivalent to
1630    /// `self.render_with_options(source, &RenderOptions::default())`.
1631    ///
1632    /// Issue #2 entry point — supersedes [`Self::format_with_source`]
1633    /// for new code; that method is preserved for backwards
1634    /// compatibility.
1635    ///
1636    /// # Examples
1637    ///
1638    /// ```
1639    /// use noyalib::{from_str, Value};
1640    /// let source = "a:\n  b: 1\n   c: 2\n";  // misaligned indent
1641    /// let err = from_str::<Value>(source).unwrap_err();
1642    /// let rendered = err.render(source);
1643    /// assert!(rendered.contains("error"));
1644    /// ```
1645    #[must_use]
1646    pub fn render(&self, source: &str) -> String {
1647        self.render_with_options(source, &RenderOptions::default())
1648    }
1649
1650    /// Render the error with caller-controlled options.
1651    ///
1652    /// `RenderOptions::crop_radius` sets how many lines of context
1653    /// surround the offending line; `RenderOptions::color` enables
1654    /// terminal ANSI colour codes. The default
1655    /// (`RenderOptions::default()`) is `crop_radius = 2`,
1656    /// `color = false`.
1657    ///
1658    /// # Examples
1659    ///
1660    /// ```
1661    /// use noyalib::{from_str, RenderOptions, Value};
1662    /// let source = "a: [unclosed";
1663    /// let err = from_str::<Value>(source).unwrap_err();
1664    /// let opts = RenderOptions { crop_radius: 1, color: false };
1665    /// let rendered = err.render_with_options(source, &opts);
1666    /// assert!(rendered.contains("error"));
1667    /// ```
1668    #[must_use]
1669    pub fn render_with_options(&self, source: &str, opts: &RenderOptions) -> String {
1670        let plain = if opts.crop_radius == 0 {
1671            self.format_with_source(source)
1672        } else {
1673            self.format_with_source_radius(source, opts.crop_radius)
1674        };
1675        if opts.color {
1676            colorize_render(&plain)
1677        } else {
1678            plain
1679        }
1680    }
1681}
1682
1683/// Caller-controlled rendering options for [`Error::render_with_options`].
1684///
1685/// Defaults to `crop_radius = 2` and `color = false` so the
1686/// stable byte-for-byte CI-friendly output stays the default.
1687/// Set `color = true` for interactive terminal use.
1688///
1689/// Construct directly with a struct literal — both fields are
1690/// public. Future field additions are tracked as a minor-version
1691/// event per the [SemVer policy](https://github.com/sebastienrousseau/noyalib/blob/main/docs/POLICIES.md#2-semver--api-stability).
1692///
1693/// # Examples
1694///
1695/// ```
1696/// use noyalib::RenderOptions;
1697/// let default = RenderOptions::default();
1698/// assert_eq!(default.crop_radius, 2);
1699/// assert!(!default.color);
1700///
1701/// // Custom — single-line, coloured.
1702/// let custom = RenderOptions { crop_radius: 0, color: true };
1703/// assert_eq!(custom.crop_radius, 0);
1704/// ```
1705#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1706pub struct RenderOptions {
1707    /// Number of source lines to include above and below the
1708    /// offending line. `0` collapses to a single-line render.
1709    /// Default `2` (rustc-style window).
1710    pub crop_radius: usize,
1711    /// When `true`, the rendered output includes terminal ANSI
1712    /// colour escapes (red `error:`, blue gutter, yellow caret).
1713    /// Default `false` so CI logs and golden tests stay stable.
1714    pub color: bool,
1715}
1716
1717impl Default for RenderOptions {
1718    fn default() -> Self {
1719        Self {
1720            crop_radius: 2,
1721            color: false,
1722        }
1723    }
1724}
1725
1726impl RenderOptions {
1727    /// Construct with all defaults (`crop_radius = 2`,
1728    /// `color = false`). Equivalent to [`RenderOptions::default()`].
1729    ///
1730    /// # Examples
1731    ///
1732    /// ```
1733    /// use noyalib::RenderOptions;
1734    /// let opts = RenderOptions::new();
1735    /// assert_eq!(opts.crop_radius, 2);
1736    /// assert!(!opts.color);
1737    /// ```
1738    #[must_use]
1739    pub fn new() -> Self {
1740        Self::default()
1741    }
1742
1743    /// Set the crop radius (lines of context on each side of
1744    /// the offending line). `0` collapses to a single-line render.
1745    ///
1746    /// # Examples
1747    ///
1748    /// ```
1749    /// use noyalib::RenderOptions;
1750    /// let opts = RenderOptions::new().crop_radius(4);
1751    /// assert_eq!(opts.crop_radius, 4);
1752    /// ```
1753    #[must_use]
1754    pub fn crop_radius(mut self, radius: usize) -> Self {
1755        self.crop_radius = radius;
1756        self
1757    }
1758
1759    /// Toggle ANSI colour escape codes on rendered output.
1760    ///
1761    /// # Examples
1762    ///
1763    /// ```
1764    /// use noyalib::RenderOptions;
1765    /// let opts = RenderOptions::new().color(true);
1766    /// assert!(opts.color);
1767    /// ```
1768    #[must_use]
1769    pub fn color(mut self, on: bool) -> Self {
1770        self.color = on;
1771        self
1772    }
1773}
1774
1775/// A windowed slice of source text around an error location.
1776///
1777/// Used internally by [`Error::render_with_options`] and exposed
1778/// for callers that want to extract the snippet without
1779/// formatting it themselves.
1780///
1781/// # Examples
1782///
1783/// ```
1784/// use noyalib::CroppedRegion;
1785/// let src = "line 1\nline 2 — error here\nline 3\nline 4\n";
1786/// let region = CroppedRegion::extract(src, 2, 1);
1787/// assert_eq!(region.lines.len(), 3);
1788/// assert!(region.lines[1].contains("error"));
1789/// ```
1790#[derive(Debug, Clone, PartialEq, Eq)]
1791#[non_exhaustive]
1792pub struct CroppedRegion<'a> {
1793    /// The lines extracted from the source — indices `low_line..=high_line`.
1794    pub lines: Vec<&'a str>,
1795    /// 0-based index in `lines` of the offending line (i.e., the
1796    /// line corresponding to the original `target_line` parameter).
1797    pub focus_index: usize,
1798    /// The 1-based line number of the first line in `lines`.
1799    pub low_line: usize,
1800    /// The 1-based line number of the offending (focus) line.
1801    pub focus_line: usize,
1802}
1803
1804impl<'a> CroppedRegion<'a> {
1805    /// Extract a `radius`-line window around `target_line` (1-based)
1806    /// from `source`. Out-of-range targets clamp to the available
1807    /// lines; an empty source yields an empty region with
1808    /// `focus_line = 0`.
1809    ///
1810    /// # Examples
1811    ///
1812    /// ```
1813    /// use noyalib::CroppedRegion;
1814    /// let src = "a\nb\nc\nd\ne\n";
1815    /// let r = CroppedRegion::extract(src, 3, 1);
1816    /// assert_eq!(r.lines, vec!["b", "c", "d"]);
1817    /// assert_eq!(r.focus_index, 1);
1818    /// assert_eq!(r.focus_line, 3);
1819    /// ```
1820    #[must_use]
1821    pub fn extract(source: &'a str, target_line: usize, radius: usize) -> Self {
1822        let all: Vec<&str> = source.lines().collect();
1823        if all.is_empty() {
1824            return CroppedRegion {
1825                lines: Vec::new(),
1826                focus_index: 0,
1827                low_line: 0,
1828                focus_line: 0,
1829            };
1830        }
1831        let target_idx = target_line.saturating_sub(1).min(all.len() - 1);
1832        let lo = target_idx.saturating_sub(radius);
1833        let hi = (target_idx + radius).min(all.len() - 1);
1834        let lines: Vec<&str> = all[lo..=hi].to_vec();
1835        CroppedRegion {
1836            lines,
1837            focus_index: target_idx - lo,
1838            low_line: lo + 1,
1839            focus_line: target_idx + 1,
1840        }
1841    }
1842}
1843
1844/// Wrap the rendered `plain` output with ANSI colour escapes —
1845/// red for the `error:` header, blue for the gutter, yellow for
1846/// the `^` caret. Implementation detail of
1847/// [`Error::render_with_options`] when `color = true`.
1848fn colorize_render(plain: &str) -> String {
1849    const RED: &str = "\x1b[31;1m";
1850    const BLUE: &str = "\x1b[34;1m";
1851    const YELLOW: &str = "\x1b[33;1m";
1852    const RESET: &str = "\x1b[0m";
1853
1854    let mut out = String::with_capacity(plain.len() + 64);
1855    for line in plain.split_inclusive('\n') {
1856        let trimmed = line.trim_end_matches('\n');
1857        if let Some(rest) = trimmed.strip_prefix("error:") {
1858            out.push_str(RED);
1859            out.push_str("error:");
1860            out.push_str(RESET);
1861            out.push_str(rest);
1862        } else if trimmed.trim_start().starts_with('|')
1863            || trimmed.starts_with("  --> ")
1864            || trimmed.contains(" | ")
1865        {
1866            out.push_str(BLUE);
1867            out.push_str(trimmed);
1868            out.push_str(RESET);
1869        } else if trimmed.trim_start().starts_with('^') {
1870            out.push_str(YELLOW);
1871            out.push_str(trimmed);
1872            out.push_str(RESET);
1873        } else {
1874            out.push_str(trimmed);
1875        }
1876        if line.ends_with('\n') {
1877            out.push('\n');
1878        }
1879    }
1880    out
1881}
1882
1883impl serde_core::ser::Error for Error {
1884    fn custom<T: fmt::Display>(msg: T) -> Self {
1885        Self::Custom(msg.to_string())
1886    }
1887}
1888
1889impl serde_core::de::Error for Error {
1890    fn custom<T: fmt::Display>(msg: T) -> Self {
1891        Self::Custom(msg.to_string())
1892    }
1893
1894    fn missing_field(field: &'static str) -> Self {
1895        Self::MissingField(field.to_string())
1896    }
1897
1898    fn unknown_field(field: &str, _expected: &'static [&'static str]) -> Self {
1899        Self::UnknownField(field.to_string())
1900    }
1901}
1902
1903/// Truncate `s` to at most `max_chars` characters, replacing the
1904/// dropped suffix with an ASCII `...` ellipsis. Used by the
1905/// `*_truncated` formatters to fit error reports into bounded
1906/// log / message-bus channels.
1907///
1908/// Truncation always lands on a UTF-8 character boundary so the
1909/// output is a valid `String`. When `max_chars < 3` the ellipsis
1910/// is dropped and the function returns whatever prefix fits.
1911fn truncate_with_ellipsis(s: String, max_chars: usize) -> String {
1912    let len = s.chars().count();
1913    if len <= max_chars {
1914        return s;
1915    }
1916    if max_chars < 3 {
1917        // No room for `...` — return the longest character-aligned
1918        // prefix that fits.
1919        let end = s.char_indices().nth(max_chars).map_or(s.len(), |(i, _)| i);
1920        return s[..end].to_owned();
1921    }
1922    let keep_chars = max_chars - 3;
1923    let end = s.char_indices().nth(keep_chars).map_or(s.len(), |(i, _)| i);
1924    let mut out = String::with_capacity(end + 3);
1925    out.push_str(&s[..end]);
1926    out.push_str("...");
1927    out
1928}
1929
1930/// A result type where the error is [`Error`].
1931///
1932/// # Examples
1933///
1934/// ```
1935/// use noyalib::Result;
1936/// fn parse() -> Result<noyalib::Value> {
1937///     noyalib::from_str("k: 1")
1938/// }
1939/// assert!(parse().is_ok());
1940/// ```
1941pub type Result<T> = core::result::Result<T, Error>;
1942
1943#[cfg(feature = "miette")]
1944impl miette::Diagnostic for Error {
1945    fn code<'a>(&'a self) -> Option<Box<dyn fmt::Display + 'a>> {
1946        Some(Box::new(self.diagnostic_code()))
1947    }
1948
1949    fn help<'a>(&'a self) -> Option<Box<dyn fmt::Display + 'a>> {
1950        self.diagnostic_help()
1951            .map(|help| -> Box<dyn fmt::Display + 'a> { Box::new(help) })
1952    }
1953
1954    fn source_code(&self) -> Option<&dyn miette::SourceCode> {
1955        if let Self::Shared(arc) = self {
1956            return arc.source_code();
1957        }
1958        None
1959    }
1960
1961    fn labels(&self) -> Option<Box<dyn Iterator<Item = miette::LabeledSpan> + '_>> {
1962        let diagnostic = self.diagnostic();
1963        if diagnostic.labels().is_empty() {
1964            return None;
1965        }
1966        let labels: Vec<_> = diagnostic
1967            .labels()
1968            .iter()
1969            .map(|label| {
1970                let span = label.span();
1971                miette::LabeledSpan::new(
1972                    Some(label.message().to_owned()),
1973                    span.offset(),
1974                    span.length(),
1975                )
1976            })
1977            .collect();
1978        Some(Box::new(labels.into_iter()))
1979    }
1980}
1981
1982/// Longest anchor name, in characters, that the "did you mean"
1983/// suggestion compares. Typos worth suggesting a fix for are in short
1984/// names; comparing long ones costs the product of their lengths.
1985const MAX_SUGGESTION_NAME_CHARS: usize = 64;
1986
1987/// Most anchor names the suggestion search examines.
1988const MAX_SUGGESTION_CANDIDATES: usize = 4096;
1989
1990/// Largest edit distance still offered as a suggestion.
1991const MAX_SUGGESTION_DISTANCE: usize = 2;
1992
1993/// The known name closest to `name` within two edits, if any.
1994///
1995/// Bounded so that an unknown alias in a document with many long
1996/// anchors costs little: names longer than
1997/// [`MAX_SUGGESTION_NAME_CHARS`] are not compared, candidates whose
1998/// length differs by more than two cannot be within two edits and are
1999/// skipped, and at most [`MAX_SUGGESTION_CANDIDATES`] are examined.
2000pub(crate) fn closest_name<'a>(
2001    name: &str,
2002    names: impl Iterator<Item = &'a str>,
2003) -> Option<&'a str> {
2004    let target_len = bounded_char_count(name)?;
2005    let mut best_dist = usize::MAX;
2006    let mut best_name = None;
2007    for n in names.take(MAX_SUGGESTION_CANDIDATES) {
2008        let Some(len) = bounded_char_count(n) else {
2009            continue;
2010        };
2011        if len.abs_diff(target_len) > MAX_SUGGESTION_DISTANCE {
2012            continue;
2013        }
2014        let dist = edit_distance(name, n);
2015        if dist < best_dist && dist <= MAX_SUGGESTION_DISTANCE {
2016            best_dist = dist;
2017            best_name = Some(n);
2018        }
2019    }
2020    best_name
2021}
2022
2023/// `s`'s length in characters, or `None` past
2024/// [`MAX_SUGGESTION_NAME_CHARS`]; never walks further than that.
2025fn bounded_char_count(s: &str) -> Option<usize> {
2026    let mut count = 0;
2027    for _ in s.chars() {
2028        count += 1;
2029        if count > MAX_SUGGESTION_NAME_CHARS {
2030            return None;
2031        }
2032    }
2033    Some(count)
2034}
2035
2036fn edit_distance(a: &str, b: &str) -> usize {
2037    let a_len = a.chars().count();
2038    let b_len = b.chars().count();
2039    if a_len == 0 {
2040        return b_len;
2041    }
2042    if b_len == 0 {
2043        return a_len;
2044    }
2045    let mut row: Vec<usize> = (0..=b_len).collect();
2046    for (i, ca) in a.chars().enumerate() {
2047        let mut prev = i + 1;
2048        for (j, cb) in b.chars().enumerate() {
2049            let mut next = row[j] + usize::from(ca != cb);
2050            if i + 1 < row[j + 1] + 1 && i + 1 < next {
2051                next = i + 1;
2052            }
2053            if prev + 1 < next {
2054                next = prev + 1;
2055            }
2056            row[j] = prev;
2057            prev = next;
2058        }
2059        row[b_len] = prev;
2060    }
2061    row[b_len]
2062}
2063
2064/// Panic helper for invariants the type system cannot express but
2065/// which the implementation has proved hold. Intended to replace
2066/// inline `unreachable!()` arms so the panic site is a single
2067/// `coverage(off)`-annotated function rather than an
2068/// arm-by-arm region in the coverage report.
2069///
2070/// `msg` is the human-readable invariant statement — quoted
2071/// verbatim into the panic message when the impossible happens
2072/// (e.g. when a future refactor accidentally breaks the
2073/// invariant). The tail-call shape lets call sites use it in any
2074/// position that expects a divergent expression.
2075#[track_caller]
2076#[cold]
2077#[inline(never)]
2078#[cfg_attr(noyalib_coverage, coverage(off))]
2079// `cargo kani` swaps the panic macros for its own assertion
2080// intrinsics, which drop the message formatting — under that
2081// compiler `msg` counts as unused and the deny-level `unused`
2082// lint would fail the proof build.
2083#[cfg_attr(kani, allow(unused_variables))]
2084pub(crate) fn invariant_violated(msg: &'static str) -> ! {
2085    unreachable!("invariant violated: {msg}")
2086}
2087
2088#[cfg(test)]
2089mod truncate_tests {
2090    use super::truncate_with_ellipsis;
2091
2092    #[test]
2093    fn under_budget_passthrough() {
2094        assert_eq!(truncate_with_ellipsis("hello".into(), 10), "hello");
2095        assert_eq!(truncate_with_ellipsis("hello".into(), 5), "hello");
2096    }
2097
2098    #[test]
2099    fn over_budget_truncates_with_ellipsis() {
2100        assert_eq!(truncate_with_ellipsis("hello world".into(), 8), "hello...");
2101        assert_eq!(truncate_with_ellipsis("0123456789".into(), 5), "01...");
2102    }
2103
2104    #[test]
2105    fn tiny_budget_drops_ellipsis() {
2106        assert_eq!(truncate_with_ellipsis("hello".into(), 0), "");
2107        assert_eq!(truncate_with_ellipsis("hello".into(), 1), "h");
2108        assert_eq!(truncate_with_ellipsis("hello".into(), 2), "he");
2109    }
2110
2111    #[test]
2112    fn utf8_aligned_at_char_boundary() {
2113        // Multi-byte chars — truncation must not split codepoints.
2114        let s = "café au lait — décaféiné".to_string();
2115        let t = truncate_with_ellipsis(s, 10);
2116        assert!(t.is_char_boundary(t.len()));
2117        assert_eq!(t.chars().count(), 10);
2118    }
2119}
2120
2121#[cfg(all(test, feature = "std"))]
2122mod relocate_tests {
2123    //! `relocate` re-anchors a located error onto the whole input.
2124    //!
2125    //! The stream readers parse each document from its own slice, so an
2126    //! error they raise counts its bytes from that slice. `relocate`
2127    //! shifts it onto the full source, and every located variant has to
2128    //! be shifted — a variant that falls through unshifted reports a
2129    //! position in the wrong document, which is worse than no position
2130    //! at all.
2131    //!
2132    //! It is `pub(crate)` and reached only from the `cst` module, so
2133    //! this exercises it directly rather than hoping a CST test happens
2134    //! to produce each variant.
2135
2136    use super::{Error, Location};
2137
2138    /// `source` is laid out so a shift of `base` lands on line 3.
2139    const SOURCE: &str = "first\nsecond\nthird line here\n";
2140    const BASE: usize = 13; // start of "third line here"
2141
2142    /// Build one of each located variant, all pointing at byte 0 of
2143    /// their own slice.
2144    fn located_variants() -> Vec<(&'static str, Error)> {
2145        let at0 = Location::from_index("third line here\n", 0);
2146        vec![
2147            (
2148                "ParseWithLocation",
2149                Error::ParseWithLocation {
2150                    message: "m".into(),
2151                    location: at0,
2152                },
2153            ),
2154            (
2155                "DeserializeWithLocation",
2156                Error::DeserializeWithLocation {
2157                    message: "m".into(),
2158                    location: at0,
2159                },
2160            ),
2161            (
2162                "UnknownAnchorAt",
2163                Error::UnknownAnchorAt {
2164                    name: "a".into(),
2165                    location: at0,
2166                    suggestion: Some(("b".into(), at0)),
2167                },
2168            ),
2169            (
2170                "DuplicateKeyAt",
2171                Error::DuplicateKeyAt {
2172                    key: "k".into(),
2173                    path: "p".into(),
2174                    location: at0,
2175                },
2176            ),
2177            (
2178                "KeyCollisionAt",
2179                Error::KeyCollisionAt {
2180                    key: "k".into(),
2181                    path: "p".into(),
2182                    location: at0,
2183                },
2184            ),
2185            (
2186                "IntegerOverflow",
2187                Error::IntegerOverflow {
2188                    location: Some(at0),
2189                    path: Some("p".into()),
2190                },
2191            ),
2192            (
2193                "NonScalarKey",
2194                Error::NonScalarKey {
2195                    kind: "sequence",
2196                    location: Some(at0),
2197                },
2198            ),
2199        ]
2200    }
2201
2202    #[test]
2203    fn every_located_variant_is_shifted_onto_the_whole_source() {
2204        for (name, err) in located_variants() {
2205            let before = err.location().expect("the fixture variant is located");
2206            assert_eq!(before.index(), 0, "{name}: fixture is not at byte 0");
2207
2208            let after = err
2209                .relocate(SOURCE, BASE)
2210                .location()
2211                .unwrap_or_else(|| panic!("{name}: relocate dropped the location"));
2212
2213            assert_eq!(after.index(), BASE, "{name}: byte index not shifted");
2214            assert_eq!(
2215                after.line(),
2216                3,
2217                "{name}: line not recomputed from the whole source"
2218            );
2219            assert_eq!(after.column(), 1, "{name}: column not recomputed");
2220        }
2221    }
2222
2223    #[test]
2224    fn a_base_of_zero_is_the_identity() {
2225        for (name, err) in located_variants() {
2226            let before = err.to_string();
2227            let after = err.relocate(SOURCE, 0);
2228            assert_eq!(
2229                after.to_string(),
2230                before,
2231                "{name}: base 0 changed the error"
2232            );
2233        }
2234    }
2235
2236    #[test]
2237    fn an_unlocated_variant_survives_relocation_unchanged() {
2238        for (name, err) in [
2239            ("Parse", Error::Parse("m".into())),
2240            ("Serialize", Error::Serialize("m".into())),
2241            ("Custom", Error::Custom("m".into())),
2242            ("EndOfStream", Error::EndOfStream),
2243            ("RepetitionLimitExceeded", Error::RepetitionLimitExceeded),
2244            ("DuplicateKey", Error::DuplicateKey("k".into())),
2245            ("UnknownAnchor", Error::UnknownAnchor("a".into())),
2246        ] {
2247            let before = err.to_string();
2248            let kind = err.kind();
2249            let after = err.relocate(SOURCE, BASE);
2250            assert_eq!(after.to_string(), before, "{name}: message changed");
2251            assert_eq!(after.kind(), kind, "{name}: kind changed");
2252            assert!(after.location().is_none(), "{name}: gained a location");
2253        }
2254    }
2255
2256    /// The suggestion inside `UnknownAnchorAt` carries its own location
2257    /// and has to move too — it is the one nested location in the enum,
2258    /// and a shift that misses it points at the wrong anchor.
2259    #[test]
2260    fn the_nested_suggestion_location_is_shifted_too() {
2261        let at0 = Location::from_index("third line here\n", 0);
2262        let err = Error::UnknownAnchorAt {
2263            name: "a".into(),
2264            location: at0,
2265            suggestion: Some(("b".into(), at0)),
2266        };
2267        let Error::UnknownAnchorAt { suggestion, .. } = err.relocate(SOURCE, BASE) else {
2268            panic!("relocate changed the variant");
2269        };
2270        let (_, loc) = suggestion.expect("the suggestion was dropped");
2271        assert_eq!(
2272            loc.index(),
2273            BASE,
2274            "the suggestion's location was not shifted"
2275        );
2276        assert_eq!(loc.line(), 3, "the suggestion's line was not recomputed");
2277    }
2278}