Skip to main content

serde_saphyr/de/
error.rs

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