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}