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