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