Skip to main content

serde_saphyr/
de_error.rs

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