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