Skip to main content

serde_saphyr/de/
error.rs

1use crate::Location;
2use crate::budget::BudgetBreach;
3use crate::de_snippet::{
4    fmt_snippet_window_offset_or_fallback, snippet_window_frame_prefix_offset,
5};
6use crate::input_source::IncludeResolveError;
7use crate::localizer::{DEFAULT_ENGLISH_LOCALIZER, ExternalMessageSource, Localizer};
8use crate::location::Locations;
9use crate::parse_scalars::{
10    parse_int_signed, parse_yaml11_bool, parse_yaml12_float, scalar_is_nullish,
11};
12#[cfg(feature = "garde")]
13use crate::path_map::path_key_from_garde;
14use crate::properties_redaction::{
15    redact_custom_message, redact_dynamic_identifier, redact_dynamic_value,
16};
17use crate::tags::SfTag;
18#[cfg(any(feature = "garde", feature = "validator"))]
19use crate::{
20    localizer::ExternalMessage,
21    path_map::{PathKey, PathMap, format_path_with_resolved_leaf},
22};
23use annotate_snippets::Level;
24use granit_parser::{ErrorKind, ScalarStyle, ScanError};
25use serde_core::de::{self};
26use std::borrow::Cow;
27use std::cell::Cell;
28use std::fmt;
29
30#[cfg(all(feature = "properties", any(feature = "garde", feature = "validator")))]
31use crate::properties_redaction::{redact_with_ctxs, with_interp_redaction};
32
33#[cfg(feature = "validator")]
34use validator::{ValidationErrors, ValidationErrorsKind};
35
36/// Formats error *messages* (not including locations/snippets).
37///
38/// This is the core customization hook for deferred rendering. The error value remains
39/// structured data; the formatter decides what message text to show (developer-oriented,
40/// user-oriented, localized, etc.).
41///
42/// Important: implementations must NOT call `err.to_string()` / `Display` for `Error` to
43/// avoid recursion once `Display` delegates to `Error::render()`.
44///
45/// # Example
46///
47/// Override a couple of messages, returning `Cow::Borrowed` for a fixed string and
48/// `Cow::Owned` for a formatted message, while delegating all other cases to
49/// `UserMessageFormatter`.
50///
51/// ```rust
52/// use serde_saphyr::{Error, Location, MessageFormatter, UserMessageFormatter};
53/// use std::borrow::Cow;
54///
55/// struct PoliteFormatter;
56///
57/// impl MessageFormatter for PoliteFormatter {
58///     fn format_message<'a>(&self, err: &'a Error) -> Cow<'a, str> {
59///         // `UserMessageFormatter` is a zero-sized type, so it is cheap to instantiate.
60///         let fallback = UserMessageFormatter;
61///
62///         match err {
63///             // Fixed string => `Cow::Borrowed`
64///             Error::Eof { .. } => Cow::Borrowed("could you please provide a YAML document?"),
65///
66///             // Formatted string => `Cow::Owned`
67///             Error::UnknownAnchor { .. } => {
68///                 Cow::Borrowed("sorry but unknown reference")
69///             }
70///
71///             // Everything else => delegate
72///             _ => fallback.format_message(err),
73///         }
74///     }
75/// }
76///
77/// let err = serde_saphyr::from_str::<String>("").unwrap_err();
78/// assert!(err.render_with_formatter(&PoliteFormatter).contains("please provide"));
79///
80/// let err = Error::UnknownAnchor {
81///     location: Location::UNKNOWN,
82/// };
83/// assert!(err
84///     .render_with_formatter(&PoliteFormatter)
85///     .contains("unknown reference"));
86/// ```
87pub trait MessageFormatter {
88    /// Return the [`Localizer`] used by the renderer.
89    ///
90    /// This controls wording that is produced outside of [`MessageFormatter::format_message`],
91    /// such as location suffixes and snippet/validation labels.
92    fn localizer(&self) -> &dyn Localizer {
93        &DEFAULT_ENGLISH_LOCALIZER
94    }
95
96    /// Return the message text for `err`.
97    ///
98    /// The returned string should NOT include location suffixes like
99    /// `"at line X, column Y"`; those are added by the renderer.
100    fn format_message<'a>(&self, err: &'a Error) -> Cow<'a, str>;
101}
102
103/// User-facing message formatter.
104///
105/// This formatter simplifies technical errors and removes internal details.
106/// ```
107/// use serde_saphyr::UserMessageFormatter;
108///
109/// let err = serde_saphyr::from_str::<String>("").unwrap_err();
110/// let msg = err.render_with_formatter(&UserMessageFormatter);
111///
112/// assert_eq!(msg, "unexpected end of file at line 1, column 1");
113/// ```
114#[derive(Debug, Default, Clone, Copy)]
115pub struct UserMessageFormatter;
116
117/// Controls whether snippet output is included when available.
118#[non_exhaustive]
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120pub enum SnippetMode {
121    /// Render snippets when the error is wrapped in `Error::WithSnippet`.
122    Auto,
123    /// Never render snippets; render a plain (location-suffixed) message instead.
124    Off,
125}
126
127/// Options for deferred error rendering.
128///
129/// Prefer constructing this via the [`render_options!`](crate::render_options!) macro
130/// instead of a struct literal. This keeps call sites stable even if new fields are added
131/// in the future (this type is `#[non_exhaustive]`).
132///
133/// # Example (using the `render_options!` macro)
134///
135/// ```rust
136/// use serde_saphyr::{DefaultMessageFormatter, SnippetMode};
137///
138/// let dev = DefaultMessageFormatter;
139/// // Customize how an error is rendered later (formatter + snippet mode).
140/// let render_opts = serde_saphyr::render_options! {
141///     formatter: &dev,
142///     snippets: SnippetMode::Off,
143/// };
144///
145/// let err = serde_saphyr::from_str::<String>("").unwrap_err();
146/// let rendered = err.render_with_options(render_opts);
147/// assert!(rendered.contains("unexpected"));
148/// ```
149#[non_exhaustive]
150#[derive(Clone, Copy)]
151pub struct RenderOptions<'a> {
152    /// Message formatter used to produce the core error message text.
153    pub formatter: &'a dyn MessageFormatter,
154    /// Snippet rendering mode.
155    pub snippets: SnippetMode,
156}
157
158impl Default for RenderOptions<'_> {
159    #[inline]
160    fn default() -> Self {
161        // Keep the default formatter reference valid even if `RenderOptions` is stored.
162        static DEFAULT_FMT: crate::message_formatters::DefaultMessageFormatter =
163            crate::message_formatters::DefaultMessageFormatter;
164
165        Self::new(&DEFAULT_FMT)
166    }
167}
168
169impl<'a> RenderOptions<'a> {
170    /// Construct render options with the given message `formatter` and default values
171    /// for all other fields.
172    ///
173    /// Defaults:
174    /// - `snippets`: [`SnippetMode::Auto`]
175    #[inline]
176    #[must_use]
177    pub fn new(formatter: &'a dyn MessageFormatter) -> Self {
178        Self {
179            formatter,
180            snippets: SnippetMode::Auto,
181        }
182    }
183}
184
185/// Cropped YAML source window stored inside [`Error::WithSnippet`].
186///
187/// The window is described in terms of the original (absolute) 1-based line numbers.
188/// This allows selecting the best-matching region for a particular error location.
189#[non_exhaustive]
190#[derive(Debug, Clone, PartialEq, Eq)]
191pub struct CroppedRegion {
192    /// Cropped source text used for snippet rendering.
193    pub text: String,
194    /// Source name/path displayed in snippet headers.
195    pub source_name: String,
196    /// The 1-based line number in the *original* input where `text` starts.
197    pub start_line: usize,
198    /// The 1-based line number in the *original* input where `text` ends (inclusive).
199    pub end_line: usize,
200    /// The location to point to in this region.
201    pub location: Location,
202}
203
204impl CroppedRegion {
205    /// Construct a cropped source region for deferred snippet rendering.
206    #[must_use]
207    pub fn new(
208        text: impl Into<String>,
209        source_name: impl Into<String>,
210        start_line: usize,
211        end_line: usize,
212        location: Location,
213    ) -> Self {
214        Self {
215            text: text.into(),
216            source_name: source_name.into(),
217            start_line,
218            end_line,
219            location,
220        }
221    }
222
223    fn covers_exact_source(&self, location: &Location) -> bool {
224        if location == &Location::UNKNOWN {
225            return false;
226        }
227        let source_id = location.source_id();
228        source_id != 0 && self.location.source_id() == source_id && self.covers_line(location)
229    }
230
231    fn covers_line(&self, location: &Location) -> bool {
232        let line = location.line as usize;
233        self.start_line <= line && line <= self.end_line
234    }
235
236    fn covers(&self, location: &Location) -> bool {
237        if location == &Location::UNKNOWN {
238            return false;
239        }
240        if !self.covers_line(location) {
241            return false;
242        }
243        let region_source_id = self.location.source_id();
244        let location_source_id = location.source_id();
245        region_source_id == 0 || location_source_id == 0 || region_source_id == location_source_id
246    }
247}
248
249fn line_count_including_trailing_empty_line(text: &str) -> usize {
250    let mut lines = text.split_terminator('\n').count().max(1);
251    if text.ends_with('\n') {
252        lines = lines.saturating_add(1);
253    }
254    lines
255}
256
257fn sanitize_snippet_source_name(name: &str) -> Cow<'_, str> {
258    if !name.chars().any(char::is_control) {
259        return Cow::Borrowed(name);
260    }
261
262    let sanitized: String = name
263        .chars()
264        .map(|ch| if ch.is_control() { ' ' } else { ch })
265        .collect();
266    Cow::Owned(sanitized)
267}
268
269fn cropped_region_for_location(
270    text: &str,
271    source_name: &str,
272    location: &Location,
273    mapping: crate::de_snippet::LineMapping,
274    crop_radius: usize,
275) -> Option<CroppedRegion> {
276    if crop_radius == 0 || *location == Location::UNKNOWN {
277        return None;
278    }
279
280    let (cropped, start_line) =
281        crate::de_snippet::crop_source_window(text, location, mapping, crop_radius);
282    if cropped.is_empty() {
283        return None;
284    }
285
286    let lines = line_count_including_trailing_empty_line(cropped.as_str());
287    let end_line = start_line.saturating_add(lines.saturating_sub(1));
288    Some(CroppedRegion {
289        text: cropped,
290        source_name: source_name.to_string(),
291        start_line,
292        end_line,
293        location: *location,
294    })
295}
296
297fn push_region_for_location(
298    regions: &mut Vec<CroppedRegion>,
299    text: &str,
300    source_name: &str,
301    location: &Location,
302    mapping: crate::de_snippet::LineMapping,
303    crop_radius: usize,
304) {
305    if let Some(region) =
306        cropped_region_for_location(text, source_name, location, mapping, crop_radius)
307    {
308        regions.push(region);
309    }
310}
311
312fn push_regions_for_locations(
313    regions: &mut Vec<CroppedRegion>,
314    text: &str,
315    source_name: &str,
316    locations: Locations,
317    mapping: crate::de_snippet::LineMapping,
318    crop_radius: usize,
319) {
320    push_region_for_location(
321        regions,
322        text,
323        source_name,
324        &locations.reference_location,
325        mapping,
326        crop_radius,
327    );
328    if locations.defined_location != locations.reference_location {
329        push_region_for_location(
330            regions,
331            text,
332            source_name,
333            &locations.defined_location,
334            mapping,
335            crop_radius,
336        );
337    }
338}
339
340#[cfg(any(feature = "garde", feature = "validator"))]
341fn push_validation_issue_regions(
342    regions: &mut Vec<CroppedRegion>,
343    issues: &[ValidationIssue],
344    locations: &PathMap,
345    text: &str,
346    source_name: &str,
347    mapping: crate::de_snippet::LineMapping,
348    crop_radius: usize,
349) {
350    for issue in issues {
351        let (locs, _) = locations
352            .search_with_ancestor_fallback(&issue.path)
353            .unwrap_or((Locations::UNKNOWN, String::new()));
354        push_regions_for_locations(regions, text, source_name, locs, mapping, crop_radius);
355    }
356}
357
358fn collect_snippet_regions(
359    inner: &Error,
360    text: &str,
361    source_name: &str,
362    mapping: crate::de_snippet::LineMapping,
363    crop_radius: usize,
364) -> Vec<CroppedRegion> {
365    let mut regions = Vec::new();
366
367    // Validation errors may contain multiple independent issue locations; pre-crop
368    // one region per issue so we can later pick the region that covers the issue.
369    #[cfg(any(feature = "garde", feature = "validator"))]
370    if let Error::ValidationError {
371        issues, locations, ..
372    } = inner
373    {
374        push_validation_issue_regions(
375            &mut regions,
376            issues,
377            locations,
378            text,
379            source_name,
380            mapping,
381            crop_radius,
382        );
383    }
384
385    // Fallback: crop around the top-level error locations (including dual-location
386    // errors such as AliasError).
387    if regions.is_empty() {
388        if let Some(locs) = inner.locations() {
389            push_regions_for_locations(&mut regions, text, source_name, locs, mapping, crop_radius);
390        } else if let Some(loc) = inner.location() {
391            push_region_for_location(&mut regions, text, source_name, &loc, mapping, crop_radius);
392        }
393    }
394
395    regions
396}
397
398#[cfg(any(feature = "garde", feature = "validator"))]
399/// A structured issue reported by a validation library.
400///
401/// Use [`ValidationIssue::new`] to construct synthetic issues when testing custom
402/// formatters or localizers.
403#[non_exhaustive]
404#[derive(Debug, Clone)]
405pub struct ValidationIssue {
406    /// Path to the value that failed validation.
407    pub path: PathKey,
408    /// Validation-library error code.
409    pub code: String,
410    /// Human-readable validation message, when provided.
411    pub message: Option<String>,
412    /// Structured parameters supplied by the validation library.
413    pub params: Vec<(String, String)>,
414}
415
416#[cfg(any(feature = "garde", feature = "validator"))]
417#[non_exhaustive]
418#[derive(Debug, Clone, Copy, PartialEq, Eq)]
419pub enum ValidationSource {
420    Garde,
421    Validator,
422}
423
424#[cfg(any(feature = "garde", feature = "validator"))]
425impl ValidationSource {
426    pub(crate) fn external_message_source(self) -> ExternalMessageSource {
427        match self {
428            ValidationSource::Garde => ExternalMessageSource::Garde,
429            ValidationSource::Validator => ExternalMessageSource::Validator,
430        }
431    }
432}
433
434#[cfg(any(feature = "garde", feature = "validator"))]
435impl ValidationIssue {
436    /// Construct a validation issue without a message or structured parameters.
437    #[must_use]
438    pub fn new(path: PathKey, code: impl Into<String>) -> Self {
439        Self {
440            path,
441            code: code.into(),
442            message: None,
443            params: Vec::new(),
444        }
445    }
446
447    /// Attach a human-readable validation message.
448    #[must_use]
449    pub fn with_message(mut self, message: impl Into<String>) -> Self {
450        self.message = Some(message.into());
451        self
452    }
453
454    /// Attach structured validation parameters.
455    #[must_use]
456    pub fn with_params(mut self, params: Vec<(String, String)>) -> Self {
457        self.params = params;
458        self
459    }
460
461    pub(crate) fn display_entry(&self) -> String {
462        if let Some(msg) = &self.message {
463            return msg.clone();
464        }
465
466        if self.params.is_empty() {
467            return self.code.clone();
468        }
469
470        let mut params = String::new();
471        for (i, (k, v)) in self.params.iter().enumerate() {
472            if i > 0 {
473                params.push_str(", ");
474            }
475            params.push_str(k);
476            params.push('=');
477            params.push_str(v);
478        }
479        format!("{} ({params})", self.code)
480    }
481
482    pub(crate) fn display_entry_overridden(
483        &self,
484        l10n: &dyn Localizer,
485        source: ExternalMessageSource,
486    ) -> String {
487        let raw = self.display_entry();
488        let overridden = l10n
489            .override_external_message(ExternalMessage {
490                source,
491                original: raw.as_str(),
492                code: Some(self.code.as_str()),
493                params: &self.params,
494            })
495            .unwrap_or(Cow::Borrowed(raw.as_str()));
496        overridden.into_owned()
497    }
498}
499
500#[cfg(all(feature = "properties", any(feature = "garde", feature = "validator")))]
501fn replace_known_effectives(
502    mut text: String,
503    ctxs: &[crate::properties_redaction::ScalarRedactionCtx],
504) -> String {
505    let mut pairs: Vec<&crate::properties_redaction::ScalarRedactionCtx> = ctxs
506        .iter()
507        .filter(|ctx| !ctx.effective.is_empty())
508        .collect();
509
510    pairs.sort_by_key(|ctx| std::cmp::Reverse(ctx.effective.len()));
511
512    for ctx in pairs {
513        if text.contains(&ctx.effective) {
514            text = text.replace(&ctx.effective, &ctx.raw);
515        }
516    }
517
518    text
519}
520
521#[cfg(all(feature = "properties", any(feature = "garde", feature = "validator")))]
522pub(crate) fn redact_issue(mut issue: ValidationIssue) -> ValidationIssue {
523    with_interp_redaction(|pairs| {
524        if pairs.is_empty() {
525            return issue;
526        }
527
528        if let Some(msg) = issue.message.take() {
529            issue.message = Some(redact_with_ctxs(msg, pairs, "invalid interpolated value"));
530        }
531
532        issue.code = replace_known_effectives(std::mem::take(&mut issue.code), pairs);
533
534        for (key, value) in &mut issue.params {
535            *key = replace_known_effectives(std::mem::take(key), pairs);
536            *value = redact_with_ctxs(std::mem::take(value), pairs, "<redacted>");
537        }
538
539        issue
540    })
541}
542
543#[cfg(all(
544    not(feature = "properties"),
545    any(feature = "garde", feature = "validator")
546))]
547pub(crate) fn redact_issue(issue: ValidationIssue) -> ValidationIssue {
548    issue
549}
550
551// Fallback location for Serde's static error constructors (`unknown_field`, `missing_field`,
552// etc.) which have no `&self` and cannot access deserializer state. Thread-local because
553// that is the only side-channel available. `Cell` suffices since `Location` is `Copy`.
554//
555// Set to the current key's location before each key deserialization via
556// [`MissingFieldLocationGuard`]; read by [`maybe_attach_fallback_location`].
557// The guard saves/restores the previous value on drop for correct nesting.
558thread_local! {
559    static MISSING_FIELD_FALLBACK: Cell<Option<Location>> = const { Cell::new(None) };
560}
561
562/// RAII guard for [`MISSING_FIELD_FALLBACK`]. Saves the previous value on creation,
563/// restores it on drop.
564pub(crate) struct MissingFieldLocationGuard {
565    prev: Option<Location>,
566}
567
568impl MissingFieldLocationGuard {
569    pub(crate) fn new(location: Location) -> Self {
570        let prev = MISSING_FIELD_FALLBACK.with(|c| c.replace(Some(location)));
571        Self { prev }
572    }
573
574    /// Update the fallback location in place, reusing the existing guard's restore point.
575    #[allow(clippy::unused_self)] // The receiver ties this update to the lifetime of a live guard.
576    pub(crate) fn replace_location(&mut self, location: Location) {
577        MISSING_FIELD_FALLBACK.with(|c| c.set(Some(location)));
578    }
579}
580
581impl Drop for MissingFieldLocationGuard {
582    fn drop(&mut self) {
583        MISSING_FIELD_FALLBACK.with(|c| c.set(self.prev));
584    }
585}
586
587/// The reason why a string value was transformed during parsing and cannot be borrowed.
588///
589/// When deserializing to `&str`, the value must exist verbatim in the input. However,
590/// certain YAML constructs require string transformation, making borrowing impossible.
591#[non_exhaustive]
592#[derive(Debug, Clone, Copy, PartialEq, Eq)]
593pub enum TransformReason {
594    /// Escape sequences were processed (e.g., `\n`, `\t`, `\uXXXX` in double-quoted strings).
595    EscapeSequence,
596    /// Line folding was applied (folded block scalar `>`).
597    LineFolding,
598    /// Multi-line plain or quoted scalar with whitespace normalization.
599    MultiLineNormalization,
600    /// Block scalar processing (literal `|` or folded `>` with chomping/indentation).
601    BlockScalarProcessing,
602    /// Single-quoted string with `''` escape processing.
603    SingleQuoteEscape,
604    /// Borrowing is not supported because the deserializer does not have access to the full input
605    /// buffer (for example, when deserializing from a `Read`er), or because the parser did not
606    /// provide a slice that is a subslice of the original input.
607    InputNotBorrowable,
608
609    /// The parser returned an owned string for this scalar.
610    ///
611    /// In newer `granit-parser` versions, zero-copy is represented directly as `Cow::Borrowed`.
612    /// If a scalar comes through as `Cow::Owned`, the deserializer cannot safely fabricate a
613    /// borrow, because it would not refer to the original input buffer.
614    ParserReturnedOwned,
615
616    /// Property interpolation transformed the scalar.
617    VariableInterpolation,
618}
619
620impl fmt::Display for TransformReason {
621    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
622        match self {
623            TransformReason::EscapeSequence => write!(f, "escape sequence processing"),
624            TransformReason::LineFolding => write!(f, "line folding"),
625            TransformReason::MultiLineNormalization => {
626                write!(f, "multi-line whitespace normalization")
627            }
628            TransformReason::BlockScalarProcessing => write!(f, "block scalar processing"),
629            TransformReason::SingleQuoteEscape => write!(f, "single-quote escape processing"),
630            TransformReason::InputNotBorrowable => {
631                write!(f, "input is not available for borrowing")
632            }
633            TransformReason::ParserReturnedOwned => write!(f, "parser returned an owned string"),
634            TransformReason::VariableInterpolation => write!(f, "variable interpolation"),
635        }
636    }
637}
638
639/// Error type compatible with `serde::de::Error`.
640#[non_exhaustive]
641pub enum Error {
642    /// Free-form error with optional source location.
643    Message {
644        msg: String,
645        location: Location,
646    },
647
648    /// Invalid deserializer options were provided.
649    InvalidOptions {
650        msg: String,
651        location: Location,
652    },
653
654    /// Text primarily produced by a dependency (parser / validators).
655    ///
656    /// Renderers should call [`Localizer::override_external_message`] to allow callers
657    /// to replace or translate this text.
658    ExternalMessage {
659        /// Dependency that produced the message and any structured source error.
660        ///
661        /// This is boxed to keep [`Error`] below Clippy's large-error threshold.
662        source: Box<ExternalMessageSource>,
663        msg: String,
664        /// Stable-ish identifier when available (e.g. validator error code).
665        code: Option<String>,
666        /// Optional structured parameters when available.
667        params: Vec<(String, String)>,
668        location: Location,
669    },
670    /// Unexpected end of input.
671    Eof {
672        location: Location,
673    },
674    /// More than one YAML document was found when a single document was expected.
675    ///
676    /// This is typically returned by single-document entrypoints like `from_str*` / `from_slice*`
677    /// / `read_to_end*` when the input stream contains multiple `---`-delimited documents.
678    MultipleDocuments {
679        /// Developer-facing hint (may mention specific APIs).
680        hint: &'static str,
681        location: Location,
682    },
683    /// Structural/type mismatch — something else than the expected token/value was seen.
684    Unexpected {
685        expected: &'static str,
686        location: Location,
687    },
688
689    /// YAML merge (`<<`) value was not a mapping or a sequence of mappings.
690    MergeValueNotMapOrSeqOfMaps {
691        location: Location,
692    },
693
694    /// YAML merge keys (`<<`) are disabled by policy.
695    MergeKeyNotAllowed {
696        location: Location,
697    },
698
699    /// `!!binary` scalar could not be decoded as base64.
700    InvalidBinaryBase64 {
701        location: Location,
702    },
703
704    /// `!!binary` scalar decoded successfully but was not valid UTF-8 when a string was expected.
705    BinaryNotUtf8 {
706        location: Location,
707    },
708
709    /// A scalar was explicitly tagged but could not be deserialized into a string.
710    TaggedScalarCannotDeserializeIntoString {
711        location: Location,
712    },
713
714    /// Encountered a sequence end where it was not expected.
715    UnexpectedSequenceEnd {
716        location: Location,
717    },
718
719    /// Encountered a mapping end where it was not expected.
720    UnexpectedMappingEnd {
721        location: Location,
722    },
723
724    /// Invalid boolean literal in strict mode.
725    InvalidBooleanStrict {
726        location: Location,
727    },
728
729    /// Invalid char: null cannot be deserialized into `char`.
730    InvalidCharNull {
731        location: Location,
732    },
733
734    /// Invalid char: expected a single Unicode scalar value.
735    InvalidCharNotSingleScalar {
736        location: Location,
737    },
738
739    /// Cannot deserialize null into string.
740    NullIntoString {
741        location: Location,
742    },
743
744    /// Bytes (`&[u8]` / `Vec<u8>`) are not supported unless the scalar is tagged as `!!binary`.
745    BytesNotSupportedMissingBinaryTag {
746        location: Location,
747    },
748
749    /// Unexpected value for unit (`()`).
750    UnexpectedValueForUnit {
751        location: Location,
752    },
753
754    /// Unit struct expected an empty mapping.
755    ExpectedEmptyMappingForUnitStruct {
756        location: Location,
757    },
758
759    /// While skipping a node, a container end event was encountered unexpectedly.
760    UnexpectedContainerEndWhileSkippingNode {
761        location: Location,
762    },
763
764    /// Internal error: a seed was reused for a map key.
765    InternalSeedReusedForMapKey {
766        location: Location,
767    },
768
769    /// Internal error: value requested before key.
770    ValueRequestedBeforeKey {
771        location: Location,
772    },
773
774    /// Externally tagged enum: expected a string key.
775    ExpectedStringKeyForExternallyTaggedEnum {
776        location: Location,
777    },
778
779    /// Externally tagged enum: expected either a scalar or a mapping.
780    ExternallyTaggedEnumExpectedScalarOrMapping {
781        location: Location,
782    },
783
784    /// Unexpected value for unit enum variant.
785    UnexpectedValueForUnitEnumVariant {
786        location: Location,
787    },
788
789    /// Input was not valid UTF-8.
790    InvalidUtf8Input,
791
792    /// Alias replay counter overflow.
793    AliasReplayCounterOverflow {
794        location: Location,
795    },
796
797    /// Alias replay total event limit exceeded.
798    AliasReplayLimitExceeded {
799        total_replayed_events: usize,
800        max_total_replayed_events: usize,
801        location: Location,
802    },
803
804    /// Alias expansion limit exceeded for a single anchor.
805    AliasExpansionLimitExceeded {
806        anchor_id: usize,
807        expansions: usize,
808        max_expansions_per_anchor: usize,
809        location: Location,
810    },
811
812    /// Alias replay stack depth limit exceeded.
813    AliasReplayStackDepthExceeded {
814        depth: usize,
815        max_depth: usize,
816        location: Location,
817    },
818
819    /// Folded block scalars must indent their content.
820    FoldedBlockScalarMustIndentContent {
821        location: Location,
822    },
823
824    /// Internal: depth counter underflow.
825    InternalDepthUnderflow {
826        location: Location,
827    },
828
829    /// Internal: recursion stack empty.
830    InternalRecursionStackEmpty {
831        location: Location,
832    },
833
834    /// recursive references require weak recursion types.
835    RecursiveReferencesRequireWeakTypes {
836        location: Location,
837    },
838
839    /// Scalar parsing failed for the requested target type.
840    InvalidScalar {
841        ty: &'static str,
842        location: Location,
843    },
844
845    /// In a typeless position (e.g. `deserialize_any` targeting `serde_json::Value`), a
846    /// scalar resolved to a non-finite float — NaN, ±Inf, or a decimal literal that
847    /// overflows `f64` to infinity (e.g. `1e999`) — and
848    /// [`Options::reject_non_finite_typeless_float`](crate::options::Options::reject_non_finite_typeless_float)
849    /// is enabled, rejecting it instead of converting it to a canonical string.
850    NonFiniteFloat {
851        /// The offending scalar text as it appeared in the source (e.g. `.nan`, `1e999`).
852        value: String,
853        location: Location,
854    },
855
856    /// Serde-generated: invalid type.
857    SerdeInvalidType {
858        unexpected: String,
859        expected: String,
860        location: Location,
861    },
862
863    /// Serde-generated: invalid value.
864    SerdeInvalidValue {
865        unexpected: String,
866        expected: String,
867        location: Location,
868    },
869
870    /// Serde-generated: unknown enum variant.
871    SerdeUnknownVariant {
872        variant: String,
873        expected: Vec<&'static str>,
874        location: Location,
875    },
876
877    /// Serde-generated: unknown field.
878    SerdeUnknownField {
879        field: String,
880        expected: Vec<&'static str>,
881        location: Location,
882    },
883
884    /// Serde-generated: missing required field.
885    SerdeMissingField {
886        field: &'static str,
887        location: Location,
888    },
889
890    /// Encountered the end of a sequence or mapping while reading a key node.
891    ///
892    /// This indicates a structural mismatch in the input.
893    UnexpectedContainerEndWhileReadingKeyNode {
894        location: Location,
895    },
896
897    /// Duplicate key in a mapping.
898    ///
899    /// When the duplicate key can be rendered as a string-like scalar, `key` is provided.
900    DuplicateMappingKey {
901        key: Option<String>,
902        location: Location,
903    },
904
905    /// Tagged enum name does not match the target enum.
906    TaggedEnumMismatch {
907        tagged: String,
908        target: &'static str,
909        location: Location,
910    },
911
912    /// Serde-generated error while deserializing an enum variant identifier.
913    SerdeVariantId {
914        msg: String,
915        location: Location,
916    },
917
918    /// Expected the end of a mapping after an externally tagged enum variant value.
919    ExpectedMappingEndAfterEnumVariantValue {
920        location: Location,
921    },
922    ContainerEndMismatch {
923        location: Location,
924    },
925    /// Alias references a non-existent anchor.
926    UnknownAnchor {
927        location: Location,
928    },
929    /// Cyclic include detected.
930    CyclicInclude {
931        id: String,
932        stack: Vec<String>,
933        location: Location,
934    },
935    /// `!include` currently only supports the scalar form: `!include <path>`
936    UnsupportedIncludeForm {
937        location: Location,
938    },
939    /// Failed to resolve include
940    ResolverError {
941        target: String,
942        error: IncludeResolveError,
943        stack: Vec<String>,
944        location: Location,
945    },
946    /// Error related to an alias, with both reference (use-site) and defined (anchor) locations.
947    ///
948    /// This variant allows reporting both where an alias is used and where the anchor is defined,
949    /// which is useful for errors that occur when deserializing aliased values.
950    AliasError {
951        msg: String,
952        locations: Locations,
953    },
954    /// Error when parsing robotic and other extensions beyond standard YAML.
955    /// (error in extension hook).
956    HookError {
957        msg: String,
958        location: Location,
959    },
960    /// A `${NAME}` property reference could not be resolved from the configured property map.
961    UnresolvedProperty {
962        /// Property name that was requested.
963        name: String,
964        location: Location,
965    },
966    /// A `${...}` property candidate used an invalid property name.
967    InvalidPropertyName {
968        /// The invalid `${...}` candidate as it appeared in the YAML source.
969        name: String,
970        location: Location,
971    },
972    /// A `${NAME?text}` or `${NAME:?text}` reference required a value but the property was unset.
973    /// `message` may be empty.
974    PropertyRequiredButUnset {
975        name: String,
976        message: String,
977        location: Location,
978    },
979    /// A `${NAME:?text}` reference required a non-empty value but the property was present and empty.
980    /// `message` may be empty.
981    PropertyRequiredButEmpty {
982        name: String,
983        message: String,
984        location: Location,
985    },
986    /// A YAML budget limit was exceeded.
987    Budget {
988        breach: BudgetBreach,
989        location: Location,
990    },
991    /// Unexpected I/O error. This may happen only when deserializing from a reader.
992    IOError {
993        cause: std::io::Error,
994    },
995    /// The value is targeted to the string field but can be interpreted as a number or boolean.
996    /// This error can only happen if `no_schema` set true.
997    QuotingRequired {
998        value: String, // sanitized (checked) value that must be quoted
999        location: Location,
1000    },
1001
1002    /// The target type requires a borrowed string (`&str`), but the value was transformed
1003    /// during parsing (e.g., through escape processing, line folding, or multi-line normalization)
1004    /// and cannot be borrowed from the input.
1005    ///
1006    /// Use `String` or `Cow<str>` instead of `&str` to handle transformed values.
1007    CannotBorrowTransformedString {
1008        /// The reason why the string had to be transformed and cannot be borrowed.
1009        reason: TransformReason,
1010        location: Location,
1011    },
1012
1013    /// Indentation does not meet the configured [`RequireIndent`](crate::RequireIndent) requirement.
1014    IndentationError {
1015        /// The indentation requirement that was violated.
1016        required: crate::indentation::RequireIndent,
1017        /// The actual indentation (in spaces) that was found.
1018        actual: usize,
1019        location: Location,
1020    },
1021
1022    /// Wrap an error with the full input text, enabling rustc-like snippet rendering.
1023    WithSnippet {
1024        /// Cropped source windows used for snippet rendering.
1025        ///
1026        /// This intentionally does NOT store the full input text, to avoid retaining
1027        /// large YAML inputs inside errors.
1028        regions: Vec<CroppedRegion>,
1029        crop_radius: usize,
1030        error: Box<Error>,
1031    },
1032
1033    /// Validation failure.
1034    #[cfg(any(feature = "garde", feature = "validator"))]
1035    ValidationError {
1036        source: ValidationSource,
1037        issues: Vec<ValidationIssue>,
1038        locations: PathMap,
1039    },
1040
1041    /// Validation failures (multiple, if multiple validations fail)
1042    #[cfg(any(feature = "garde", feature = "validator"))]
1043    ValidationErrors {
1044        source: ValidationSource,
1045        errors: Vec<Error>,
1046    },
1047}
1048
1049impl Error {
1050    #[cold]
1051    #[inline(never)]
1052    pub(crate) fn with_snippet(self, text: &str, crop_radius: usize) -> Self {
1053        self.with_snippet_named(text, "<input>", crop_radius)
1054    }
1055
1056    #[cold]
1057    #[inline(never)]
1058    pub(crate) fn with_snippet_named(
1059        self,
1060        text: &str,
1061        source_name: &str,
1062        crop_radius: usize,
1063    ) -> Self {
1064        let source_name = sanitize_snippet_source_name(source_name);
1065
1066        // Avoid nesting snippet wrappers: keep the innermost error and rebuild the
1067        // wrapper with freshly cropped source window.
1068        let inner = match self {
1069            Error::WithSnippet { error, .. } => *error,
1070            other => other,
1071        };
1072
1073        // Keep snippet coordinates aligned with parsers that ignore a leading UTF-8 BOM.
1074        let text = text.strip_prefix('\u{FEFF}').unwrap_or(text);
1075
1076        let regions = collect_snippet_regions(
1077            &inner,
1078            text,
1079            source_name.as_ref(),
1080            crate::de_snippet::LineMapping::Identity,
1081            crop_radius,
1082        );
1083
1084        Error::WithSnippet {
1085            regions,
1086            crop_radius,
1087            error: Box::new(inner),
1088        }
1089    }
1090
1091    #[cfg(feature = "include")]
1092    #[cold]
1093    #[inline(never)]
1094    pub(crate) fn with_additional_snippet_named(
1095        mut self,
1096        text: &str,
1097        source_name: &str,
1098        location: &Location,
1099        crop_radius: usize,
1100    ) -> Self {
1101        let source_name = sanitize_snippet_source_name(source_name);
1102
1103        if crop_radius == 0 || *location == Location::UNKNOWN {
1104            return self;
1105        }
1106
1107        let text = text.strip_prefix('\u{FEFF}').unwrap_or(text);
1108        let mapping = crate::de_snippet::LineMapping::Identity;
1109
1110        let Some(region) =
1111            cropped_region_for_location(text, source_name.as_ref(), location, mapping, crop_radius)
1112        else {
1113            return self;
1114        };
1115
1116        if let Error::WithSnippet {
1117            ref mut regions, ..
1118        } = self
1119        {
1120            regions.push(region);
1121        }
1122        self
1123    }
1124
1125    #[cfg(feature = "include")]
1126    #[cold]
1127    #[inline(never)]
1128    pub(crate) fn with_additional_snippet_offset_named(
1129        mut self,
1130        text: &str,
1131        start_line: usize,
1132        source_name: &str,
1133        location: &Location,
1134        crop_radius: usize,
1135    ) -> Self {
1136        let source_name = sanitize_snippet_source_name(source_name);
1137
1138        if crop_radius == 0 || *location == Location::UNKNOWN {
1139            return self;
1140        }
1141
1142        let text = text.strip_prefix('\u{FEFF}').unwrap_or(text);
1143        let mapping = crate::de_snippet::LineMapping::Offset { start_line };
1144
1145        let Some(region) =
1146            cropped_region_for_location(text, source_name.as_ref(), location, mapping, crop_radius)
1147        else {
1148            return self;
1149        };
1150
1151        if let Error::WithSnippet {
1152            ref mut regions, ..
1153        } = self
1154        {
1155            regions.push(region);
1156        }
1157        self
1158    }
1159
1160    #[cold]
1161    #[inline(never)]
1162    pub(crate) fn with_snippet_offset_named(
1163        self,
1164        text: &str,
1165        start_line: usize,
1166        source_name: &str,
1167        crop_radius: usize,
1168    ) -> Self {
1169        let source_name = sanitize_snippet_source_name(source_name);
1170
1171        let inner = match self {
1172            Error::WithSnippet { error, .. } => *error,
1173            other => other,
1174        };
1175
1176        // Keep snippet coordinates aligned with parsers that ignore a leading UTF-8 BOM.
1177        let text = text.strip_prefix('\u{FEFF}').unwrap_or(text);
1178
1179        let regions = collect_snippet_regions(
1180            &inner,
1181            text,
1182            source_name.as_ref(),
1183            crate::de_snippet::LineMapping::Offset { start_line },
1184            crop_radius,
1185        );
1186
1187        Error::WithSnippet {
1188            regions,
1189            crop_radius,
1190            error: Box::new(inner),
1191        }
1192    }
1193
1194    /// Provide "no snippet" version for cases when snippet rendering is not desired.
1195    #[must_use]
1196    pub fn without_snippet(&self) -> &Self {
1197        match self {
1198            Error::WithSnippet { error, .. } => error,
1199            other => other,
1200        }
1201    }
1202
1203    /// Render this error using the built-in developer formatter.
1204    ///
1205    /// This is the deferred-rendering entrypoint. It is equivalent to `Display`/`to_string()`
1206    /// output, but also allows callers to choose a custom [`MessageFormatter`] via
1207    /// [`Error::render_with_options`].
1208    #[must_use]
1209    pub fn render(&self) -> String {
1210        self.render_with_options(RenderOptions::default())
1211    }
1212
1213    /// Render this error using a custom message formatter.
1214    #[must_use]
1215    pub fn render_with_formatter(&self, formatter: &dyn MessageFormatter) -> String {
1216        self.render_with_options(RenderOptions {
1217            formatter,
1218            snippets: SnippetMode::Auto,
1219        })
1220    }
1221
1222    /// Render this error using the provided options.
1223    #[must_use]
1224    pub fn render_with_options(&self, options: RenderOptions<'_>) -> String {
1225        struct RenderDisplay<'a> {
1226            err: &'a Error,
1227            options: RenderOptions<'a>,
1228        }
1229
1230        impl fmt::Display for RenderDisplay<'_> {
1231            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1232                fmt_error_rendered(f, self.err, self.options)
1233            }
1234        }
1235
1236        RenderDisplay { err: self, options }.to_string()
1237    }
1238
1239    /// Construct a `Message` error with no known location.
1240    ///
1241    /// Arguments:
1242    /// - `s`: human-readable message.
1243    ///
1244    /// Returns:
1245    /// - `Error::Message` pointing at [`Location::UNKNOWN`].
1246    ///
1247    /// Called by:
1248    /// - Scalar parsers and helpers throughout this module.
1249    #[cold]
1250    #[inline(never)]
1251    pub(crate) fn msg<S: Into<String>>(s: S) -> Self {
1252        Error::Message {
1253            msg: s.into(),
1254            location: Location::UNKNOWN,
1255        }
1256    }
1257
1258    /// Construct an `InvalidOptions` error with no known source location.
1259    #[cold]
1260    #[inline(never)]
1261    pub(crate) fn invalid_options<S: Into<String>>(s: S) -> Self {
1262        Error::InvalidOptions {
1263            msg: s.into(),
1264            location: Location::UNKNOWN,
1265        }
1266    }
1267
1268    /// Construct a `QuotingRequired` error with no known location.
1269    /// Called by:
1270    /// - Deserializer, when deserializing into string if `no_schema` set to true.
1271    #[cold]
1272    #[inline(never)]
1273    pub(crate) fn quoting_required(value: &str, interpolated: bool) -> Self {
1274        // Ensure the value really is like number or boolean (do not reflect back content
1275        // that may be used for attack)
1276        let location = Location::UNKNOWN;
1277        let value = if !interpolated
1278            && (parse_yaml12_float::<f64>(value, location, SfTag::None, false).is_ok()
1279                || parse_int_signed::<i128>(value, "i128", location, false).is_ok()
1280                || parse_yaml11_bool(value).is_ok()
1281                || scalar_is_nullish(value, &ScalarStyle::Plain))
1282        {
1283            value.to_string()
1284        } else {
1285            String::new()
1286        };
1287        Error::QuotingRequired { value, location }
1288    }
1289
1290    /// Convenience for an `Unexpected` error pre-filled with a human phrase.
1291    ///
1292    /// Arguments:
1293    /// - `what`: short description like "sequence start".
1294    ///
1295    /// Returns:
1296    /// - `Error::Unexpected` at unknown location.
1297    ///
1298    /// Called by:
1299    /// - Deserializer methods that validate the next event kind.
1300    #[cold]
1301    #[inline(never)]
1302    pub(crate) fn unexpected(what: &'static str) -> Self {
1303        Error::Unexpected {
1304            expected: what,
1305            location: Location::UNKNOWN,
1306        }
1307    }
1308
1309    /// Construct an unexpected end-of-input error with unknown location.
1310    ///
1311    /// Used by:
1312    /// - Lookahead and pull methods when `None` appears prematurely.
1313    #[cold]
1314    #[inline(never)]
1315    pub(crate) fn eof() -> Self {
1316        Error::Eof {
1317            location: Location::UNKNOWN,
1318        }
1319    }
1320
1321    #[cold]
1322    #[inline(never)]
1323    pub(crate) fn multiple_documents(hint: &'static str) -> Self {
1324        Error::MultipleDocuments {
1325            hint,
1326            location: Location::UNKNOWN,
1327        }
1328    }
1329
1330    #[cfg(any(feature = "garde", feature = "validator"))]
1331    pub(crate) fn validation_error(
1332        source: ValidationSource,
1333        issues: Vec<ValidationIssue>,
1334        locations: PathMap,
1335    ) -> Self {
1336        Error::ValidationError {
1337            source,
1338            issues,
1339            locations,
1340        }
1341    }
1342
1343    #[cfg(any(feature = "garde", feature = "validator"))]
1344    pub(crate) fn validation_errors(source: ValidationSource, errors: Vec<Error>) -> Self {
1345        Error::ValidationErrors { source, errors }
1346    }
1347
1348    #[cfg(any(feature = "garde", feature = "validator"))]
1349    pub(crate) fn is_validation_error(&self) -> bool {
1350        matches!(self, Error::ValidationError { .. })
1351    }
1352
1353    /// Construct an `UnknownAnchor` error (unknown location).
1354    ///
1355    /// Called by:
1356    /// - Alias replay logic in the live event source.
1357    #[cold]
1358    #[inline(never)]
1359    pub(crate) fn unknown_anchor() -> Self {
1360        Error::UnknownAnchor {
1361            location: Location::UNKNOWN,
1362        }
1363    }
1364
1365    /// Construct a `CannotBorrowTransformedString` error for the given reason.
1366    ///
1367    /// This error is returned when deserializing to `&str` but the string value
1368    /// was transformed during parsing and cannot be borrowed from the input.
1369    #[cold]
1370    #[inline(never)]
1371    #[must_use]
1372    pub fn cannot_borrow_transformed(reason: TransformReason) -> Self {
1373        Error::CannotBorrowTransformedString {
1374            reason,
1375            location: Location::UNKNOWN,
1376        }
1377    }
1378
1379    /// Attach/override a concrete location to this error and return it.
1380    ///
1381    /// Arguments:
1382    /// - `set_location`: location to store in the error.
1383    ///
1384    /// Returns:
1385    /// - The same `Error` with location updated.
1386    ///
1387    /// Called by:
1388    /// - Most error paths once the event position becomes known.
1389    #[cold]
1390    #[inline(never)]
1391    pub(crate) fn with_location(mut self, set_location: Location) -> Self {
1392        match &mut self {
1393            Error::Message { location, .. }
1394            | Error::InvalidOptions { location, .. }
1395            | Error::ExternalMessage { location, .. }
1396            | Error::Eof { location }
1397            | Error::MultipleDocuments { location, .. }
1398            | Error::Unexpected { location, .. }
1399            | Error::MergeValueNotMapOrSeqOfMaps { location }
1400            | Error::MergeKeyNotAllowed { location }
1401            | Error::InvalidBinaryBase64 { location }
1402            | Error::BinaryNotUtf8 { location }
1403            | Error::TaggedScalarCannotDeserializeIntoString { location }
1404            | Error::UnexpectedSequenceEnd { location }
1405            | Error::UnexpectedMappingEnd { location }
1406            | Error::InvalidBooleanStrict { location }
1407            | Error::InvalidCharNull { location }
1408            | Error::InvalidCharNotSingleScalar { location }
1409            | Error::NullIntoString { location }
1410            | Error::BytesNotSupportedMissingBinaryTag { location }
1411            | Error::UnexpectedValueForUnit { location }
1412            | Error::ExpectedEmptyMappingForUnitStruct { location }
1413            | Error::UnexpectedContainerEndWhileSkippingNode { location }
1414            | Error::InternalSeedReusedForMapKey { location }
1415            | Error::ValueRequestedBeforeKey { location }
1416            | Error::ExpectedStringKeyForExternallyTaggedEnum { location }
1417            | Error::ExternallyTaggedEnumExpectedScalarOrMapping { location }
1418            | Error::UnexpectedValueForUnitEnumVariant { location }
1419            | Error::AliasReplayCounterOverflow { location }
1420            | Error::AliasReplayLimitExceeded { location, .. }
1421            | Error::AliasExpansionLimitExceeded { location, .. }
1422            | Error::AliasReplayStackDepthExceeded { location, .. }
1423            | Error::FoldedBlockScalarMustIndentContent { location }
1424            | Error::InternalDepthUnderflow { location }
1425            | Error::InternalRecursionStackEmpty { location }
1426            | Error::RecursiveReferencesRequireWeakTypes { location }
1427            | Error::InvalidScalar { location, .. }
1428            | Error::NonFiniteFloat { location, .. }
1429            | Error::SerdeInvalidType { location, .. }
1430            | Error::SerdeInvalidValue { location, .. }
1431            | Error::SerdeUnknownVariant { location, .. }
1432            | Error::SerdeUnknownField { location, .. }
1433            | Error::SerdeMissingField { location, .. }
1434            | Error::UnexpectedContainerEndWhileReadingKeyNode { location }
1435            | Error::DuplicateMappingKey { location, .. }
1436            | Error::TaggedEnumMismatch { location, .. }
1437            | Error::SerdeVariantId { location, .. }
1438            | Error::ExpectedMappingEndAfterEnumVariantValue { location }
1439            | Error::HookError { location, .. }
1440            | Error::UnresolvedProperty { location, .. }
1441            | Error::InvalidPropertyName { location, .. }
1442            | Error::PropertyRequiredButUnset { location, .. }
1443            | Error::PropertyRequiredButEmpty { location, .. }
1444            | Error::ContainerEndMismatch { location, .. }
1445            | Error::UnknownAnchor { location, .. }
1446            | Error::CyclicInclude { location, .. }
1447            | Error::UnsupportedIncludeForm { location, .. }
1448            | Error::ResolverError { location, .. }
1449            | Error::QuotingRequired { location, .. }
1450            | Error::Budget { location, .. }
1451            | Error::CannotBorrowTransformedString { location, .. }
1452            | Error::IndentationError { location, .. } => {
1453                *location = set_location;
1454            }
1455            Error::InvalidUtf8Input => {}
1456            Error::IOError { .. } => {} // this error does not support location
1457            Error::AliasError { .. } => {
1458                // AliasError carries its own Locations; don't override with a single location.
1459            }
1460            Error::WithSnippet { error, .. } => {
1461                let inner = *std::mem::replace(error, Box::new(Error::eof()));
1462                **error = inner.with_location(set_location);
1463            }
1464            #[cfg(any(feature = "garde", feature = "validator"))]
1465            Error::ValidationError { .. } => {
1466                // Validation errors carry their own per-path locations.
1467            }
1468            #[cfg(any(feature = "garde", feature = "validator"))]
1469            Error::ValidationErrors { .. } => {
1470                // Aggregate validation errors carry their own per-entry locations.
1471            }
1472        }
1473        self
1474    }
1475
1476    /// If the error has a known location, return it.
1477    ///
1478    /// Returns:
1479    /// - `Some(Location)` when coordinates are known; `None` otherwise.
1480    ///
1481    /// Used by:
1482    /// - Callers that want to surface precise positions to users.
1483    #[must_use]
1484    pub fn location(&self) -> Option<Location> {
1485        #[cfg(any(feature = "garde", feature = "validator"))]
1486        if let Error::ValidationErrors { errors, .. } = self {
1487            // Preserve aggregate behavior: use the first child that has a location,
1488            // rather than requiring the first child itself to have one.
1489            return errors.iter().find_map(Error::location);
1490        }
1491
1492        self.locations().and_then(Locations::primary_location)
1493    }
1494    /// Return a pair of locations associated with this error.
1495    ///
1496    /// - For syntax and other errors that carry a single [`Location`], this returns two
1497    ///   identical locations.
1498    /// - For validation errors (when the `garde` / `validator` feature is enabled), this returns
1499    ///   the `(reference_location, defined_location)` pair for the *first* validation entry.
1500    ///
1501    ///   These two locations may differ when YAML anchors/aliases are involved.
1502    /// - Returns `None` when no meaningful location information is available.
1503    #[must_use]
1504    pub fn locations(&self) -> Option<Locations> {
1505        match self {
1506            Error::Message { location, .. }
1507            | Error::InvalidOptions { location, .. }
1508            | Error::ExternalMessage { location, .. }
1509            | Error::Eof { location }
1510            | Error::MultipleDocuments { location, .. }
1511            | Error::Unexpected { location, .. }
1512            | Error::MergeValueNotMapOrSeqOfMaps { location }
1513            | Error::MergeKeyNotAllowed { location }
1514            | Error::InvalidBinaryBase64 { location }
1515            | Error::BinaryNotUtf8 { location }
1516            | Error::TaggedScalarCannotDeserializeIntoString { location }
1517            | Error::UnexpectedSequenceEnd { location }
1518            | Error::UnexpectedMappingEnd { location }
1519            | Error::InvalidBooleanStrict { location }
1520            | Error::InvalidCharNull { location }
1521            | Error::InvalidCharNotSingleScalar { location }
1522            | Error::NullIntoString { location }
1523            | Error::BytesNotSupportedMissingBinaryTag { location }
1524            | Error::UnexpectedValueForUnit { location }
1525            | Error::ExpectedEmptyMappingForUnitStruct { location }
1526            | Error::UnexpectedContainerEndWhileSkippingNode { location }
1527            | Error::InternalSeedReusedForMapKey { location }
1528            | Error::ValueRequestedBeforeKey { location }
1529            | Error::ExpectedStringKeyForExternallyTaggedEnum { location }
1530            | Error::ExternallyTaggedEnumExpectedScalarOrMapping { location }
1531            | Error::UnexpectedValueForUnitEnumVariant { location }
1532            | Error::AliasReplayCounterOverflow { location }
1533            | Error::AliasReplayLimitExceeded { location, .. }
1534            | Error::AliasExpansionLimitExceeded { location, .. }
1535            | Error::AliasReplayStackDepthExceeded { location, .. }
1536            | Error::FoldedBlockScalarMustIndentContent { location }
1537            | Error::InternalDepthUnderflow { location }
1538            | Error::InternalRecursionStackEmpty { location }
1539            | Error::RecursiveReferencesRequireWeakTypes { location }
1540            | Error::InvalidScalar { location, .. }
1541            | Error::NonFiniteFloat { location, .. }
1542            | Error::SerdeInvalidType { location, .. }
1543            | Error::SerdeInvalidValue { location, .. }
1544            | Error::SerdeUnknownVariant { location, .. }
1545            | Error::SerdeUnknownField { location, .. }
1546            | Error::SerdeMissingField { location, .. }
1547            | Error::UnexpectedContainerEndWhileReadingKeyNode { location }
1548            | Error::DuplicateMappingKey { location, .. }
1549            | Error::TaggedEnumMismatch { location, .. }
1550            | Error::SerdeVariantId { location, .. }
1551            | Error::ExpectedMappingEndAfterEnumVariantValue { location }
1552            | Error::HookError { location, .. }
1553            | Error::UnresolvedProperty { location, .. }
1554            | Error::InvalidPropertyName { location, .. }
1555            | Error::PropertyRequiredButUnset { location, .. }
1556            | Error::PropertyRequiredButEmpty { location, .. }
1557            | Error::ContainerEndMismatch { location, .. }
1558            | Error::UnknownAnchor { location, .. }
1559            | Error::CyclicInclude { location, .. }
1560            | Error::UnsupportedIncludeForm { location, .. }
1561            | Error::ResolverError { location, .. }
1562            | Error::QuotingRequired { location, .. }
1563            | Error::Budget { location, .. }
1564            | Error::CannotBorrowTransformedString { location, .. }
1565            | Error::IndentationError { location, .. } => Locations::same(location),
1566            Error::InvalidUtf8Input => None,
1567            Error::IOError { .. } => None,
1568            Error::AliasError { locations, .. } => Some(*locations),
1569            Error::WithSnippet { error, .. } => error.locations(),
1570            #[cfg(any(feature = "garde", feature = "validator"))]
1571            Error::ValidationError {
1572                issues, locations, ..
1573            } => issues
1574                .first()
1575                .and_then(|issue| locations.search_with_ancestor_fallback(&issue.path))
1576                .map(|(locs, _)| locs),
1577            #[cfg(any(feature = "garde", feature = "validator"))]
1578            Error::ValidationErrors { errors, .. } => errors.first().and_then(Error::locations),
1579        }
1580    }
1581
1582    /// Map a `granit_parser::ScanError` into our error type with location.
1583    ///
1584    /// Called by:
1585    /// - The live events adapter when the underlying parser fails.
1586    #[cold]
1587    #[inline(never)]
1588    pub(crate) fn from_scan_error(err: ScanError) -> Self {
1589        let err = match err.try_into_input_io_error() {
1590            Ok(error) => {
1591                let cause = match error.try_into_io_error() {
1592                    Ok(error) => error,
1593                    Err(error) => {
1594                        let kind = error
1595                            .io_error()
1596                            .map_or(std::io::ErrorKind::Other, std::io::Error::kind);
1597                        std::io::Error::new(kind, error)
1598                    }
1599                };
1600                return Error::IOError { cause };
1601            }
1602            Err(err) => err,
1603        };
1604
1605        let mark = err.marker();
1606        let location = Location::new(mark.line(), mark.col() + 1)
1607            .with_span(crate::Span::new(mark.index() as u64, 1));
1608
1609        match err.kind() {
1610            ErrorKind::InputDecoding { message } => {
1611                return Error::IOError {
1612                    cause: std::io::Error::new(std::io::ErrorKind::InvalidData, message.clone()),
1613                };
1614            }
1615            ErrorKind::InputByteLimitExceeded { limit } => {
1616                return Error::IOError {
1617                    cause: std::io::Error::new(
1618                        std::io::ErrorKind::FileTooLarge,
1619                        format!("input size limit of {limit} bytes exceeded"),
1620                    ),
1621                };
1622            }
1623            ErrorKind::MultipleDocumentsUnsupported => {
1624                return Error::MultipleDocuments {
1625                    hint: "only one document is supported in this context",
1626                    location,
1627                };
1628            }
1629            ErrorKind::UnknownAnchor => return Error::UnknownAnchor { location },
1630            _ => {}
1631        }
1632
1633        let message = err.info();
1634        Error::ExternalMessage {
1635            source: Box::new(ExternalMessageSource::Parser(err)),
1636            msg: message,
1637            code: None,
1638            params: Vec::new(),
1639            location,
1640        }
1641    }
1642}
1643
1644fn fmt_error_plain_with_formatter(
1645    f: &mut fmt::Formatter<'_>,
1646    err: &Error,
1647    formatter: &dyn MessageFormatter,
1648) -> fmt::Result {
1649    let err = err.without_snippet();
1650
1651    let msg = formatter.format_message(err);
1652
1653    // Validation errors embed per-issue locations in their formatted message (potentially
1654    // multiple distinct locations). Do not attach a single top-level location suffix here,
1655    // or we'd duplicate location wording.
1656    #[cfg(any(feature = "garde", feature = "validator"))]
1657    if matches!(err, Error::ValidationError { .. }) {
1658        return write!(f, "{msg}");
1659    }
1660
1661    if let Some(loc) = err.location() {
1662        fmt_with_location(f, formatter.localizer(), msg.as_ref(), &loc)?;
1663    } else {
1664        write!(f, "{msg}")?;
1665    }
1666
1667    #[cfg(any(feature = "garde", feature = "validator"))]
1668    if let Error::ValidationErrors { errors, .. } = err {
1669        for err in errors {
1670            writeln!(f)?;
1671            writeln!(f)?;
1672            fmt_error_plain_with_formatter(f, err, formatter)?;
1673        }
1674    }
1675
1676    Ok(())
1677}
1678
1679fn pick_cropped_region<'a>(
1680    regions: &'a [CroppedRegion],
1681    location: &Location,
1682) -> Option<&'a CroppedRegion> {
1683    let source_id = location.source_id();
1684
1685    if source_id != 0 {
1686        if let Some(region) = regions.iter().find(|r| r.covers_exact_source(location)) {
1687            return Some(region);
1688        }
1689        if let Some(region) = regions.iter().find(|r| r.location.source_id() == source_id) {
1690            return Some(region);
1691        }
1692        if let Some(region) = regions
1693            .iter()
1694            .find(|r| r.location.source_id() == 0 && r.covers(location))
1695        {
1696            return Some(region);
1697        }
1698        return None;
1699    }
1700
1701    regions
1702        .iter()
1703        .find(|r| r.covers(location))
1704        .or_else(|| regions.first())
1705}
1706
1707fn writeln_anchor_intro(
1708    f: &mut fmt::Formatter<'_>,
1709    l10n: &dyn Localizer,
1710    def_loc: Location,
1711    def_region: &CroppedRegion,
1712) -> fmt::Result {
1713    let line = l10n.value_comes_from_the_anchor(def_loc);
1714    let Some(prefix) = snippet_window_frame_prefix_offset(
1715        def_region.text.as_str(),
1716        def_region.start_line,
1717        &def_loc,
1718    ) else {
1719        return writeln!(f, "{line}");
1720    };
1721
1722    match line.strip_prefix("  |") {
1723        Some(rest) => writeln!(f, "{prefix}{rest}"),
1724        None => writeln!(f, "{line}"),
1725    }
1726}
1727
1728fn fmt_error_rendered(
1729    f: &mut fmt::Formatter<'_>,
1730    err: &Error,
1731    options: RenderOptions<'_>,
1732) -> fmt::Result {
1733    if options.snippets == SnippetMode::Off {
1734        return fmt_error_plain_with_formatter(f, err, options.formatter);
1735    }
1736
1737    match err {
1738        #[cfg(any(feature = "garde", feature = "validator"))]
1739        Error::ValidationErrors { errors, .. } => {
1740            let msg = options.formatter.format_message(err);
1741            if !msg.is_empty() {
1742                writeln!(f, "{msg}")?;
1743            }
1744            let mut first = true;
1745            for err in errors {
1746                if !first {
1747                    writeln!(f)?;
1748                    writeln!(f)?;
1749                }
1750                first = false;
1751                fmt_error_rendered(f, err, options)?;
1752            }
1753            Ok(())
1754        }
1755
1756        Error::WithSnippet {
1757            regions,
1758            crop_radius,
1759            error,
1760        } => {
1761            if *crop_radius == 0 {
1762                // Treat as "snippet disabled".
1763                return fmt_error_plain_with_formatter(f, error, options.formatter);
1764            }
1765
1766            if regions.is_empty() {
1767                return fmt_error_plain_with_formatter(f, error, options.formatter);
1768            }
1769
1770            // Validation errors have custom snippet formatting (paths, alias context, and
1771            // messages without location duplication).
1772            #[cfg(any(feature = "garde", feature = "validator"))]
1773            if let Error::ValidationError {
1774                source,
1775                issues,
1776                locations,
1777            } = error.as_ref()
1778            {
1779                return fmt_validation_error_with_snippets_offset(
1780                    f,
1781                    options.formatter.localizer(),
1782                    &source.external_message_source(),
1783                    issues,
1784                    locations,
1785                    regions,
1786                    *crop_radius,
1787                );
1788            }
1789            #[cfg(any(feature = "garde", feature = "validator"))]
1790            if let Error::ValidationErrors { errors, .. } = error.as_ref() {
1791                let msg = options.formatter.format_message(error);
1792                if !msg.is_empty() {
1793                    writeln!(f, "{msg}")?;
1794                }
1795                let mut first = true;
1796                for err in errors {
1797                    if !first {
1798                        writeln!(f)?;
1799                        writeln!(f)?;
1800                    }
1801                    first = false;
1802                    fmt_error_with_snippets_offset(
1803                        f,
1804                        err,
1805                        regions,
1806                        *crop_radius,
1807                        options.formatter,
1808                    )?;
1809                }
1810                return Ok(());
1811            }
1812
1813            // Render a snippet from the cropped source window. If anything is missing,
1814            // fall back to the plain nested error.
1815            let Some(location) = error.location() else {
1816                return fmt_error_plain_with_formatter(f, error, options.formatter);
1817            };
1818            if location == Location::UNKNOWN {
1819                return fmt_error_plain_with_formatter(f, error, options.formatter);
1820            }
1821
1822            let l10n = options.formatter.localizer();
1823
1824            let Some(region) = pick_cropped_region(regions, &location) else {
1825                return fmt_error_plain_with_formatter(f, error, options.formatter);
1826            };
1827
1828            // Dual-location rendering: show both the reference and the definition window.
1829            let dual_locations = error.locations().filter(|locs| {
1830                locs.reference_location != Location::UNKNOWN
1831                    && locs.defined_location != Location::UNKNOWN
1832                    && locs.reference_location != locs.defined_location
1833            });
1834
1835            let mut msg = options.formatter.format_message(error);
1836
1837            // Renderer-level de-duplication for AliasError:
1838            // when we are about to show a secondary “defined here” window, drop the
1839            // default message suffix " (defined at …)" if present.
1840            if dual_locations.is_some()
1841                && let Error::AliasError { locations, .. } = error.as_ref()
1842            {
1843                let suffix = l10n.alias_defined_at(locations.defined_location);
1844                if let Some(stripped) = msg.as_ref().strip_suffix(&suffix) {
1845                    msg = Cow::Owned(stripped.to_string());
1846                }
1847            }
1848
1849            if let Some(locs) = dual_locations {
1850                let ref_loc = locs.reference_location;
1851                let def_loc = locs.defined_location;
1852
1853                let used_region = pick_cropped_region(regions, &ref_loc).unwrap_or(region);
1854                let label = l10n.value_used_here();
1855                let ctx = crate::de_snippet::Snippet::new(
1856                    used_region.text.as_str(),
1857                    used_region.source_name.as_str(),
1858                    *crop_radius,
1859                )
1860                .with_offset(used_region.start_line);
1861                ctx.fmt_or_fallback_with_label(
1862                    f,
1863                    Level::ERROR,
1864                    l10n,
1865                    msg.as_ref(),
1866                    label.as_ref(),
1867                    &ref_loc,
1868                )?;
1869
1870                let def_region = pick_cropped_region(regions, &def_loc).unwrap_or(region);
1871                writeln!(f)?;
1872                writeln_anchor_intro(f, l10n, def_loc, def_region)?;
1873                fmt_snippet_window_offset_or_fallback(
1874                    f,
1875                    l10n,
1876                    &def_loc,
1877                    def_region.text.as_str(),
1878                    def_region.start_line,
1879                    l10n.defined_window().as_ref(),
1880                    *crop_radius,
1881                )?;
1882                Ok(())
1883            } else {
1884                // Single location rendering.
1885                let ctx = crate::de_snippet::Snippet::new(
1886                    region.text.as_str(),
1887                    region.source_name.as_str(),
1888                    *crop_radius,
1889                )
1890                .with_offset(region.start_line);
1891                ctx.fmt_or_fallback(f, Level::ERROR, l10n, msg.as_ref(), &location)?;
1892
1893                for extra_region in regions {
1894                    if std::ptr::eq(extra_region, region) {
1895                        continue;
1896                    }
1897                    writeln!(f)?;
1898                    writeln!(f, "included from here:")?;
1899                    let extra_ctx = crate::de_snippet::Snippet::new(
1900                        extra_region.text.as_str(),
1901                        extra_region.source_name.as_str(),
1902                        *crop_radius,
1903                    )
1904                    .with_offset(extra_region.start_line);
1905                    extra_ctx.fmt_or_fallback(f, Level::NOTE, l10n, "", &extra_region.location)?;
1906                }
1907                Ok(())
1908            }
1909        }
1910        _ => fmt_error_plain_with_formatter(f, err, options.formatter),
1911    }
1912}
1913
1914impl fmt::Display for Error {
1915    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1916        fmt_error_rendered(f, self, RenderOptions::default())
1917    }
1918}
1919
1920impl fmt::Debug for Error {
1921    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1922        fmt::Display::fmt(self, f)
1923    }
1924}
1925
1926#[cfg(any(feature = "garde", feature = "validator"))]
1927fn fmt_validation_error_with_snippets_offset(
1928    f: &mut fmt::Formatter<'_>,
1929    l10n: &dyn Localizer,
1930    source: &ExternalMessageSource,
1931    issues: &[ValidationIssue],
1932    locations: &PathMap,
1933    regions: &[CroppedRegion],
1934    crop_radius: usize,
1935) -> fmt::Result {
1936    let mut first = true;
1937    for issue in issues {
1938        if !first {
1939            writeln!(f)?;
1940        }
1941        first = false;
1942
1943        let original_leaf = issue
1944            .path
1945            .leaf_string()
1946            .unwrap_or_else(|| l10n.root_path_label().into_owned());
1947
1948        let (locs, resolved_leaf) = locations
1949            .search_with_ancestor_fallback(&issue.path)
1950            .unwrap_or((Locations::UNKNOWN, original_leaf));
1951
1952        let ref_loc = locs.reference_location;
1953        let def_loc = locs.defined_location;
1954
1955        let resolved_path = format_path_with_resolved_leaf(&issue.path, &resolved_leaf);
1956        let entry = issue.display_entry_overridden(l10n, (*source).clone());
1957        let base_msg = l10n.validation_base_message(&entry, &resolved_path);
1958
1959        let mut rendered_regions = Vec::new();
1960
1961        match (ref_loc, def_loc) {
1962            (Location::UNKNOWN, Location::UNKNOWN) => {
1963                write!(f, "{base_msg}")?;
1964            }
1965            (r, d) if r != Location::UNKNOWN && (d == Location::UNKNOWN || d == r) => {
1966                let label = l10n.defined();
1967                if let Some(region) = pick_cropped_region(regions, &r) {
1968                    rendered_regions.push(std::ptr::from_ref(region));
1969                    let ctx = crate::de_snippet::Snippet::new(
1970                        region.text.as_str(),
1971                        label.as_ref(),
1972                        crop_radius,
1973                    )
1974                    .with_offset(region.start_line);
1975                    ctx.fmt_or_fallback(f, Level::ERROR, l10n, &base_msg, &r)?;
1976                } else {
1977                    fmt_with_location(f, l10n, &base_msg, &r)?;
1978                }
1979            }
1980            (r, d) if r == Location::UNKNOWN && d != Location::UNKNOWN => {
1981                let label = l10n.defined_here();
1982                if let Some(region) = pick_cropped_region(regions, &d) {
1983                    rendered_regions.push(std::ptr::from_ref(region));
1984                    let ctx = crate::de_snippet::Snippet::new(
1985                        region.text.as_str(),
1986                        label.as_ref(),
1987                        crop_radius,
1988                    )
1989                    .with_offset(region.start_line);
1990                    ctx.fmt_or_fallback(f, Level::ERROR, l10n, &base_msg, &d)?;
1991                } else {
1992                    fmt_with_location(f, l10n, &base_msg, &d)?;
1993                }
1994            }
1995            (r, d) => {
1996                let label = l10n.value_used_here();
1997                let invalid_here = l10n.invalid_here(&base_msg);
1998                if let Some(region) = pick_cropped_region(regions, &r) {
1999                    rendered_regions.push(std::ptr::from_ref(region));
2000                    let ctx = crate::de_snippet::Snippet::new(
2001                        region.text.as_str(),
2002                        region.source_name.as_str(),
2003                        crop_radius,
2004                    )
2005                    .with_offset(region.start_line);
2006                    ctx.fmt_or_fallback_with_label(
2007                        f,
2008                        Level::ERROR,
2009                        l10n,
2010                        &invalid_here,
2011                        label.as_ref(),
2012                        &r,
2013                    )?;
2014                } else {
2015                    fmt_with_location(f, l10n, &invalid_here, &r)?;
2016                }
2017                writeln!(f)?;
2018                if let Some(region) = pick_cropped_region(regions, &d) {
2019                    writeln_anchor_intro(f, l10n, d, region)?;
2020                    rendered_regions.push(std::ptr::from_ref(region));
2021                    crate::de_snippet::fmt_snippet_window_offset_or_fallback(
2022                        f,
2023                        l10n,
2024                        &d,
2025                        region.text.as_str(),
2026                        region.start_line,
2027                        l10n.defined_window().as_ref(),
2028                        crop_radius,
2029                    )?;
2030                } else {
2031                    writeln!(f, "{}", l10n.value_comes_from_the_anchor(d))?;
2032                    fmt_with_location(f, l10n, l10n.defined_window().as_ref(), &d)?;
2033                }
2034            }
2035        }
2036
2037        for extra_region in regions {
2038            if rendered_regions.contains(&std::ptr::from_ref(extra_region)) {
2039                continue;
2040            }
2041            writeln!(f)?;
2042            writeln!(f, "included from here:")?;
2043            let extra_ctx = crate::de_snippet::Snippet::new(
2044                extra_region.text.as_str(),
2045                extra_region.source_name.as_str(),
2046                crop_radius,
2047            )
2048            .with_offset(extra_region.start_line);
2049            extra_ctx.fmt_or_fallback(f, Level::NOTE, l10n, "", &extra_region.location)?;
2050        }
2051    }
2052    Ok(())
2053}
2054
2055#[cfg(any(feature = "garde", feature = "validator"))]
2056fn fmt_error_with_snippets_offset(
2057    f: &mut fmt::Formatter<'_>,
2058    err: &Error,
2059    regions: &[CroppedRegion],
2060    crop_radius: usize,
2061    formatter: &dyn MessageFormatter,
2062) -> fmt::Result {
2063    if crop_radius == 0 {
2064        return fmt_error_plain_with_formatter(f, err, formatter);
2065    }
2066
2067    // Keep existing snippet output if the nested error is already wrapped.
2068    if let Error::WithSnippet { .. } = err {
2069        return fmt_error_rendered(f, err, RenderOptions::new(formatter));
2070    }
2071
2072    #[cfg(any(feature = "garde", feature = "validator"))]
2073    if let Error::ValidationError {
2074        source,
2075        issues,
2076        locations,
2077    } = err
2078    {
2079        return fmt_validation_error_with_snippets_offset(
2080            f,
2081            formatter.localizer(),
2082            &source.external_message_source(),
2083            issues,
2084            locations,
2085            regions,
2086            crop_radius,
2087        );
2088    }
2089
2090    let msg = formatter.format_message(err);
2091    let Some(location) = err.location() else {
2092        return write!(f, "{msg}");
2093    };
2094    if location == Location::UNKNOWN {
2095        return write!(f, "{msg}");
2096    }
2097
2098    let Some(region) = pick_cropped_region(regions, &location) else {
2099        return fmt_with_location(f, formatter.localizer(), msg.as_ref(), &location);
2100    };
2101    let ctx = crate::de_snippet::Snippet::new(
2102        region.text.as_str(),
2103        region.source_name.as_str(),
2104        crop_radius,
2105    )
2106    .with_offset(region.start_line);
2107    ctx.fmt_or_fallback(
2108        f,
2109        Level::ERROR,
2110        formatter.localizer(),
2111        msg.as_ref(),
2112        &location,
2113    )
2114}
2115
2116#[cfg(feature = "validator")]
2117pub(crate) fn collect_validator_issues(errors: &ValidationErrors) -> Vec<ValidationIssue> {
2118    let mut out = Vec::new();
2119    let root = PathKey::empty();
2120    collect_validator_issues_inner(errors, &root, &mut out);
2121    out
2122}
2123
2124#[cfg(feature = "validator")]
2125fn collect_validator_issues_inner(
2126    errors: &ValidationErrors,
2127    path: &PathKey,
2128    out: &mut Vec<ValidationIssue>,
2129) {
2130    for (field, kind) in errors.errors() {
2131        let field_path = path.clone().join(field.as_ref());
2132        match kind {
2133            ValidationErrorsKind::Field(entries) => {
2134                for entry in entries {
2135                    let mut params = Vec::new();
2136                    for (k, v) in &entry.params {
2137                        params.push((k.to_string(), v.to_string()));
2138                    }
2139
2140                    out.push(ValidationIssue {
2141                        path: field_path.clone(),
2142                        code: entry.code.to_string(),
2143                        message: entry.message.as_ref().map(std::string::ToString::to_string),
2144                        params,
2145                    });
2146                }
2147            }
2148            ValidationErrorsKind::Struct(inner) => {
2149                collect_validator_issues_inner(inner, &field_path, out);
2150            }
2151            ValidationErrorsKind::List(list) => {
2152                for (idx, inner) in list {
2153                    let index_path = field_path.clone().join(*idx);
2154                    collect_validator_issues_inner(inner, &index_path, out);
2155                }
2156            }
2157        }
2158    }
2159}
2160
2161#[cfg(feature = "garde")]
2162pub(crate) fn collect_garde_issues(report: &garde::Report) -> Vec<ValidationIssue> {
2163    let mut out = Vec::new();
2164    for (path, entry) in report.iter() {
2165        out.push(ValidationIssue {
2166            path: path_key_from_garde(path),
2167            code: "garde".to_string(),
2168            message: Some(entry.message().to_string()),
2169            params: Vec::new(),
2170        });
2171    }
2172    out
2173}
2174impl std::error::Error for Error {}
2175
2176/// Attach the current [`MISSING_FIELD_FALLBACK`] location to `err`, if available.
2177#[cold]
2178#[inline(never)]
2179fn maybe_attach_fallback_location(mut err: Error) -> Error {
2180    let loc = MISSING_FIELD_FALLBACK.with(std::cell::Cell::get);
2181    if let Some(loc) = loc
2182        && loc != Location::UNKNOWN
2183    {
2184        err = err.with_location(loc);
2185    }
2186    err
2187}
2188
2189impl de::Error for Error {
2190    #[cold]
2191    #[inline(never)]
2192    fn custom<T: fmt::Display>(msg: T) -> Self {
2193        // Keep custom errors locationless by default; the deserializer should attach an explicit
2194        // location when it can. For Serde-generated errors, we override the relevant hooks below
2195        // and attach a best-effort fallback location.
2196        Error::msg(redact_custom_message(msg.to_string()))
2197    }
2198
2199    #[cold]
2200    #[inline(never)]
2201    fn invalid_type(unexp: de::Unexpected, exp: &dyn de::Expected) -> Self {
2202        // Mirror serde’s default formatting, but add a best-effort location.
2203        maybe_attach_fallback_location(Error::SerdeInvalidType {
2204            unexpected: redact_dynamic_value(unexp.to_string(), "an interpolated value"),
2205            expected: exp.to_string(),
2206            location: Location::UNKNOWN,
2207        })
2208    }
2209
2210    #[cold]
2211    #[inline(never)]
2212    fn invalid_value(unexp: de::Unexpected, exp: &dyn de::Expected) -> Self {
2213        maybe_attach_fallback_location(Error::SerdeInvalidValue {
2214            unexpected: redact_dynamic_value(unexp.to_string(), "an interpolated value"),
2215            expected: exp.to_string(),
2216            location: Location::UNKNOWN,
2217        })
2218    }
2219
2220    #[cold]
2221    #[inline(never)]
2222    fn invalid_length(len: usize, exp: &dyn de::Expected) -> Self {
2223        maybe_attach_fallback_location(Error::msg(format!("invalid length {len}, expected {exp}")))
2224    }
2225
2226    #[cold]
2227    #[inline(never)]
2228    fn unknown_variant(variant: &str, expected: &'static [&'static str]) -> Self {
2229        maybe_attach_fallback_location(Error::SerdeUnknownVariant {
2230            variant: redact_dynamic_identifier(variant, "an interpolated variant"),
2231            expected: expected.to_vec(),
2232            location: Location::UNKNOWN,
2233        })
2234    }
2235
2236    #[cold]
2237    #[inline(never)]
2238    fn unknown_field(field: &str, expected: &'static [&'static str]) -> Self {
2239        maybe_attach_fallback_location(Error::SerdeUnknownField {
2240            field: redact_dynamic_identifier(field, "an interpolated field"),
2241            expected: expected.to_vec(),
2242            location: Location::UNKNOWN,
2243        })
2244    }
2245
2246    #[cold]
2247    #[inline(never)]
2248    fn missing_field(field: &'static str) -> Self {
2249        maybe_attach_fallback_location(Error::SerdeMissingField {
2250            field,
2251            location: Location::UNKNOWN,
2252        })
2253    }
2254}
2255
2256/// Print a message optionally suffixed with "at line X, column Y".
2257///
2258/// Arguments:
2259/// - `f`: destination formatter.
2260/// - `msg`: main text.
2261/// - `location`: position to attach if known.
2262///
2263/// Returns:
2264/// - `fmt::Result` as required by `Display`.
2265#[cold]
2266#[inline(never)]
2267fn fmt_with_location(
2268    f: &mut fmt::Formatter<'_>,
2269    l10n: &dyn Localizer,
2270    msg: &str,
2271    location: &Location,
2272) -> fmt::Result {
2273    let out = l10n.attach_location(Cow::Borrowed(msg), *location);
2274    write!(f, "{out}")
2275}
2276
2277/// Convert a budget breach report into a user-facing error.
2278///
2279/// Arguments:
2280/// - `breach`: which limit was exceeded (from the streaming budget checker).
2281///
2282/// Returns:
2283/// - `Error::Message` with a formatted description.
2284///
2285/// Called by:
2286/// - The live events layer when enforcing budgets during/after parsing.
2287#[cold]
2288#[inline(never)]
2289pub(crate) fn budget_error(breach: BudgetBreach) -> Error {
2290    Error::Budget {
2291        breach,
2292        location: Location::UNKNOWN,
2293    }
2294}
2295
2296#[cfg(test)]
2297mod tests {
2298    use super::*;
2299
2300    #[test]
2301    fn message_only_input_io_scan_error_uses_portable_fallback() {
2302        let input = core::iter::once(Err::<char, _>(ErrorKind::InputIo {
2303            error: granit_parser::InputIoError::from_message("portable reader failure"),
2304        }));
2305        let scan_error = granit_parser::Parser::new_from_fallible_iter(input)
2306            .find_map(Result::err)
2307            .expect("the source error should be reported");
2308        let error = Error::from_scan_error(scan_error);
2309
2310        match error {
2311            Error::IOError { cause } => {
2312                assert_eq!(cause.kind(), std::io::ErrorKind::Other);
2313                assert_eq!(cause.to_string(), "portable reader failure");
2314            }
2315            other => panic!("expected reader I/O error, got {other:?}"),
2316        }
2317    }
2318
2319    #[rstest::rstest]
2320    #[case::unknown_anchor("while parsing node, found unknown anchor")]
2321    #[case::multiple_documents("multiple documents not supported here")]
2322    fn custom_scan_error_messages_are_not_reclassified_as_builtin_kinds(#[case] message: &str) {
2323        let scan_error = ScanError::new(granit_parser::Marker::new(0, 1, 0), message);
2324        let mapped = Error::from_scan_error(scan_error);
2325
2326        assert!(matches!(
2327            mapped,
2328            Error::ExternalMessage {
2329                ref source,
2330                ref msg,
2331                ..
2332            } if msg == message
2333                && matches!(source.as_ref(), ExternalMessageSource::Parser(error)
2334                    if matches!(error.kind(), ErrorKind::Custom(_)))
2335        ));
2336    }
2337
2338    #[test]
2339    fn sanitize_snippet_source_name_replaces_control_chars() {
2340        let sanitized = sanitize_snippet_source_name("evil.yaml\nINJECTED:\u{001b}[31m");
2341        assert_eq!(sanitized, "evil.yaml INJECTED: [31m");
2342    }
2343
2344    #[test]
2345    fn with_snippet_named_sanitizes_source_name() {
2346        let err = Error::Message {
2347            msg: "oops".to_owned(),
2348            location: Location::new(1, 1),
2349        }
2350        .with_snippet_named("x: y\n", "evil.yaml\nINJECTED", 2);
2351
2352        let Error::WithSnippet { regions, .. } = err else {
2353            panic!("expected Error::WithSnippet");
2354        };
2355
2356        assert_eq!(regions.len(), 1);
2357        assert_eq!(regions[0].source_name, "evil.yaml INJECTED");
2358    }
2359
2360    #[test]
2361    fn locations_for_basic_error_duplicates_location() {
2362        let l = Location::new(3, 7);
2363        let err = Error::Message {
2364            msg: "x".to_owned(),
2365            location: l,
2366        };
2367        assert_eq!(
2368            err.locations(),
2369            Some(Locations {
2370                reference_location: l,
2371                defined_location: l,
2372            })
2373        );
2374    }
2375
2376    #[test]
2377    fn merge_key_not_allowed_location_helpers() {
2378        let l = Location::new(4, 2);
2379        let err = Error::MergeKeyNotAllowed { location: l };
2380        assert_eq!(err.location(), Some(l));
2381        assert_eq!(
2382            err.locations(),
2383            Some(Locations {
2384                reference_location: l,
2385                defined_location: l,
2386            })
2387        );
2388
2389        let updated = err.with_location(Location::new(5, 9));
2390        assert_eq!(updated.location(), Some(Location::new(5, 9)));
2391    }
2392
2393    #[test]
2394    fn serde_invalid_type_location_helpers() {
2395        let l = Location::new(4, 2);
2396        let err = Error::SerdeInvalidType {
2397            unexpected: "string".to_owned(),
2398            expected: "an integer".to_owned(),
2399            location: l,
2400        };
2401        assert_eq!(err.location(), Some(l));
2402        assert_eq!(
2403            err.locations(),
2404            Some(Locations {
2405                reference_location: l,
2406                defined_location: l,
2407            })
2408        );
2409
2410        let updated = err.with_location(Location::new(5, 9));
2411        assert_eq!(updated.location(), Some(Location::new(5, 9)));
2412    }
2413
2414    #[test]
2415    fn locations_for_io_error_is_unknown() {
2416        let err = Error::IOError {
2417            cause: std::io::Error::other("x"),
2418        };
2419        assert_eq!(err.locations(), None);
2420    }
2421
2422    #[test]
2423    fn alias_error_returns_both_locations() {
2424        let ref_loc = Location::new(5, 10);
2425        let def_loc = Location::new(2, 3);
2426        let err = Error::AliasError {
2427            msg: "test error".to_owned(),
2428            locations: Locations {
2429                reference_location: ref_loc,
2430                defined_location: def_loc,
2431            },
2432        };
2433
2434        // location() should return the primary (reference) location
2435        assert_eq!(err.location(), Some(ref_loc));
2436
2437        // locations() should return both
2438        assert_eq!(
2439            err.locations(),
2440            Some(Locations {
2441                reference_location: ref_loc,
2442                defined_location: def_loc,
2443            })
2444        );
2445    }
2446
2447    #[test]
2448    fn alias_error_display_shows_both_locations() {
2449        let ref_loc = Location::new(5, 10);
2450        let def_loc = Location::new(2, 3);
2451        let err = Error::AliasError {
2452            msg: "invalid value".to_owned(),
2453            locations: Locations {
2454                reference_location: ref_loc,
2455                defined_location: def_loc,
2456            },
2457        };
2458
2459        let display = err.to_string();
2460        assert!(display.contains("invalid value"));
2461        assert!(display.contains("line 5"));
2462        assert!(display.contains("column 10"));
2463        assert!(display.contains("line 2"));
2464        assert!(display.contains("column 3"));
2465    }
2466
2467    #[test]
2468    fn alias_error_display_with_same_locations() {
2469        let loc = Location::new(3, 7);
2470        let err = Error::AliasError {
2471            msg: "test".to_owned(),
2472            locations: Locations {
2473                reference_location: loc,
2474                defined_location: loc,
2475            },
2476        };
2477
2478        let display = err.to_string();
2479        // When both locations are the same, should only show one
2480        assert!(display.contains("line 3"));
2481        assert!(display.contains("column 7"));
2482        // Should not contain "defined at" since locations are the same
2483        assert!(!display.contains("defined at"));
2484    }
2485
2486    #[test]
2487    fn with_snippet_counts_trailing_empty_line_for_end_line() {
2488        // `"a\n"` has two logical lines: "a" and a trailing empty line.
2489        let text = "a\n";
2490        let err = Error::Message {
2491            msg: "x".to_owned(),
2492            location: Location::new(2, 1),
2493        };
2494
2495        let wrapped = err.with_snippet(text, 50);
2496        let Error::WithSnippet { regions, .. } = wrapped else {
2497            panic!("expected WithSnippet wrapper");
2498        };
2499        assert_eq!(regions.len(), 1);
2500        assert_eq!(regions[0].start_line, 1);
2501        assert_eq!(regions[0].end_line, 2);
2502    }
2503
2504    #[test]
2505    fn with_snippet_offset_counts_trailing_empty_line_for_end_line() {
2506        // Fragment starts at line 10, and ends with a newline -> includes empty line 11.
2507        let text = "a\n";
2508        let err = Error::Message {
2509            msg: "x".to_owned(),
2510            location: Location::new(11, 1),
2511        };
2512
2513        let wrapped = err.with_snippet_offset_named(text, 10, "<input>", 50);
2514        let Error::WithSnippet { regions, .. } = wrapped else {
2515            panic!("expected WithSnippet wrapper");
2516        };
2517        assert_eq!(regions.len(), 1);
2518        assert_eq!(regions[0].start_line, 10);
2519        assert_eq!(regions[0].end_line, 11);
2520    }
2521
2522    #[cfg(feature = "validator")]
2523    #[test]
2524    fn locations_for_validator_error_uses_first_entry() {
2525        use validator::Validate;
2526
2527        #[derive(Debug, Validate)]
2528        struct Cfg {
2529            #[validate(length(min = 2))]
2530            second_string: String,
2531        }
2532
2533        let cfg = Cfg {
2534            second_string: "x".to_owned(),
2535        };
2536        let errors = cfg.validate().expect_err("validation error expected");
2537
2538        let referenced_loc = Location::new(3, 15);
2539        let defined_loc = Location::new(2, 18);
2540
2541        let mut locations = PathMap::new();
2542        locations.insert(
2543            PathKey::empty().join("secondString"),
2544            Locations {
2545                reference_location: referenced_loc,
2546                defined_location: defined_loc,
2547            },
2548        );
2549
2550        let err = Error::ValidationError {
2551            source: ValidationSource::Validator,
2552            issues: crate::de_error::collect_validator_issues(&errors),
2553            locations,
2554        };
2555        assert_eq!(
2556            err.locations(),
2557            Some(Locations {
2558                reference_location: referenced_loc,
2559                defined_location: defined_loc,
2560            })
2561        );
2562    }
2563
2564    #[cfg(feature = "validator")]
2565    #[test]
2566    fn validator_error_uses_ancestor_path_location_fallback() {
2567        let yaml = "parent:\n  child: bad\n";
2568        let referenced_loc = Location::new(1, 1);
2569        let defined_loc = Location::new(1, 1);
2570
2571        let mut locations = PathMap::new();
2572        locations.insert(
2573            PathKey::empty().join("parent"),
2574            Locations {
2575                reference_location: referenced_loc,
2576                defined_location: defined_loc,
2577            },
2578        );
2579
2580        let err = Error::ValidationError {
2581            source: ValidationSource::Validator,
2582            issues: vec![ValidationIssue {
2583                path: PathKey::empty().join("parent").join("child").join("value"),
2584                code: "custom".to_owned(),
2585                message: Some("custom validation failed".to_owned()),
2586                params: Vec::new(),
2587            }],
2588            locations,
2589        };
2590
2591        assert_eq!(err.location(), Some(referenced_loc));
2592        assert_eq!(
2593            err.locations(),
2594            Some(Locations {
2595                reference_location: referenced_loc,
2596                defined_location: defined_loc,
2597            })
2598        );
2599
2600        let rendered = err.with_snippet(yaml, 20).render();
2601        assert!(
2602            rendered.contains("custom validation failed"),
2603            "expected validation message, got: {rendered}"
2604        );
2605        assert!(
2606            rendered.contains("for `parent.child.value`"),
2607            "expected original validation path, got: {rendered}"
2608        );
2609        assert!(
2610            rendered.contains("line 1 column 1"),
2611            "expected ancestor location, got: {rendered}"
2612        );
2613        assert!(
2614            rendered.contains("1 | parent:"),
2615            "expected snippet around ancestor path, got: {rendered}"
2616        );
2617    }
2618
2619    #[test]
2620    fn nested_snippet_preserves_custom_formatter() {
2621        struct Custom;
2622        impl MessageFormatter for Custom {
2623            fn localizer(&self) -> &dyn Localizer {
2624                &DEFAULT_ENGLISH_LOCALIZER
2625            }
2626            fn format_message<'a>(&self, err: &'a Error) -> Cow<'a, str> {
2627                match err {
2628                    Error::Message { msg, .. } => Cow::Owned(format!("CUSTOM: {}", msg.as_str())),
2629                    _ => Cow::Borrowed(""),
2630                }
2631            }
2632        }
2633        let loc = Location::new(1, 1);
2634        let base = Error::Message {
2635            msg: "original".to_string(),
2636            location: loc,
2637        };
2638        let text = "input";
2639        let start_line = 1;
2640        let radius = 1;
2641        let inner = base.with_snippet_offset_named(text, start_line, "<input>", radius);
2642        let outer = inner.with_snippet_offset_named(text, start_line, "<input>", radius);
2643        let rendered = outer.render_with_options(RenderOptions::new(&Custom));
2644        assert!(rendered.contains("CUSTOM: original"));
2645    }
2646
2647    #[test]
2648    fn alias_error_dual_snippet_rendering() {
2649        // YAML with anchor on line 2 and alias usage on line 5
2650        let yaml = r#"config:
2651  anchor: &myval 42
2652  other: stuff
2653  more: data
2654  use_it: *myval
2655"#;
2656        // Reference location: line 5, column 11 (where *myval is used)
2657        let ref_loc = Location::new(5, 11);
2658        // Defined location: line 2, column 11 (where &myval is defined)
2659        let def_loc = Location::new(2, 11);
2660
2661        let err = Error::AliasError {
2662            msg: "invalid value type".to_owned(),
2663            locations: Locations {
2664                reference_location: ref_loc,
2665                defined_location: def_loc,
2666            },
2667        };
2668
2669        // Wrap with snippet
2670        let wrapped = err.with_snippet(yaml, 5);
2671        let rendered = wrapped.render();
2672
2673        // Should contain the error message
2674        assert!(
2675            rendered.contains("invalid value type"),
2676            "rendered: {}",
2677            rendered
2678        );
2679
2680        // When a secondary snippet window is shown, avoid duplicating the alias
2681        // "defined at …" suffix in the main message.
2682        assert!(
2683            !rendered.contains("(defined at line"),
2684            "did not expect alias defined-at suffix when secondary window is present: {}",
2685            rendered
2686        );
2687        // Should show "the value is used here" for the reference location
2688        assert!(
2689            rendered.contains("the value is used here") || rendered.contains("use_it"),
2690            "rendered should show reference location context: {}",
2691            rendered
2692        );
2693        // Should show "defined here" for the anchor location
2694        assert!(
2695            rendered.contains("defined here") || rendered.contains("anchor"),
2696            "rendered should show defined location context: {}",
2697            rendered
2698        );
2699        // Should mention both line numbers in some form
2700        assert!(
2701            rendered.contains('5') || rendered.contains("use_it"),
2702            "rendered should reference line 5: {}",
2703            rendered
2704        );
2705        assert!(
2706            rendered.contains('2') || rendered.contains("anchor"),
2707            "rendered should reference line 2: {}",
2708            rendered
2709        );
2710    }
2711
2712    #[test]
2713    fn alias_error_same_location_single_snippet() {
2714        let yaml = "value: &anchor 42\n";
2715        let loc = Location::new(1, 8);
2716
2717        let err = Error::AliasError {
2718            msg: "test error".to_owned(),
2719            locations: Locations {
2720                reference_location: loc,
2721                defined_location: loc,
2722            },
2723        };
2724
2725        let wrapped = err.with_snippet(yaml, 5);
2726        let rendered = wrapped.render();
2727
2728        // Should contain the error message
2729        assert!(rendered.contains("test error"), "rendered: {}", rendered);
2730        // Should NOT show dual-snippet labels when locations are the same
2731        assert!(
2732            !rendered.contains("defined here"),
2733            "should not show 'defined here' when locations are same: {}",
2734            rendered
2735        );
2736        assert!(
2737            !rendered.contains("the value is used here"),
2738            "should not show 'value used here' when locations are same: {}",
2739            rendered
2740        );
2741    }
2742}