Skip to main content

serde_saphyr/de/
error.rs

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