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}