Skip to main content

serde_saphyr/de/
localizer.rs

1//! Localization / wording customization.
2//!
3//! The [`Localizer`] trait is the central hook for customizing *crate-authored wording*.
4//! It is intentionally designed to be low-boilerplate:
5//!
6//! - Every method has a reasonable English default.
7//! - You can override only the pieces you care about, while inheriting all other defaults.
8//!
9//! This crate may also show *external* message text coming from dependencies (for example
10//! `granit-parser` scan errors, or validator messages). Where such texts are used, the
11//! rendering pipeline should provide a best-effort opportunity to override them via
12//! [`Localizer::override_external_message`].
13//!
14//! ## Example: override a single phrase
15//!
16//! ```rust
17//! use serde_saphyr::{Error, Location};
18//! use serde_saphyr::localizer::{Localizer, DEFAULT_ENGLISH_LOCALIZER};
19//! use std::borrow::Cow;
20//!
21//! /// A wrapper that overrides only location suffix wording, delegating everything else.
22//! struct Pirate<'a> {
23//!     base: &'a dyn Localizer,
24//! }
25//!
26//! impl Localizer for Pirate<'_> {
27//!     fn attach_location<'b>(&self, base: Cow<'b, str>, loc: Location) -> Cow<'b, str> {
28//!         if loc == Location::UNKNOWN {
29//!             return base;
30//!         }
31//!         // Note: you can also delegate to `self.base.attach_location(...)` if you want.
32//!         Cow::Owned(format!(
33//!             "{base}. Bug lurks on line {}, then {} runes in",
34//!             loc.line(),
35//!             loc.column()
36//!         ))
37//!     }
38//! }
39//!
40//! // This snippet shows the customization building blocks; the crate's rendering APIs
41//! // obtain a `Localizer` via the `MessageFormatter`.
42//! # let _ = (Error::InvalidUtf8Input, &DEFAULT_ENGLISH_LOCALIZER);
43//! ```
44
45use crate::Location;
46use granit_parser::ScanError;
47use std::borrow::Cow;
48
49/// Where an “external” message comes from.
50///
51/// External messages are those primarily produced by dependencies (parser / validators).
52#[non_exhaustive]
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum ExternalMessageSource {
55    /// Text produced by the immediate YAML parser, with its structured error.
56    Parser(ScanError),
57    /// Text produced by `garde` validation rules.
58    Garde,
59    /// Text produced by `validator` validation rules.
60    Validator,
61}
62
63/// A best-effort description of an external message.
64///
65/// The crate should pass as much stable metadata as it has (e.g. `code` and `params` for
66/// `validator`) so the localizer can override *specific* messages without string matching.
67#[non_exhaustive]
68#[derive(Debug, Clone)]
69pub struct ExternalMessage<'a> {
70    pub source: ExternalMessageSource,
71    /// The original text as provided by the external library.
72    pub original: &'a str,
73    /// Stable-ish identifier when available (e.g. validator error code).
74    pub code: Option<&'a str>,
75    /// Optional structured parameters when available.
76    pub params: &'a [(String, String)],
77}
78
79impl<'a> ExternalMessage<'a> {
80    /// Construct an external message with no code or structured parameters.
81    #[must_use]
82    pub fn new(source: ExternalMessageSource, original: &'a str) -> Self {
83        Self {
84            source,
85            original,
86            code: None,
87            params: &[],
88        }
89    }
90
91    /// Attach a dependency-provided message code.
92    #[must_use]
93    pub fn with_code(mut self, code: &'a str) -> Self {
94        self.code = Some(code);
95        self
96    }
97
98    /// Attach structured dependency-provided message parameters.
99    #[must_use]
100    pub fn with_params(mut self, params: &'a [(String, String)]) -> Self {
101        self.params = params;
102        self
103    }
104}
105
106/// All crate-authored wording customization points.
107///
108/// Implementors should typically override *only a few* methods.
109/// Everything else should default to English (via the default method bodies).
110///
111/// [`Error::render`](crate::Error::render) and the other rendering entrypoints neutralize
112/// control characters in returned message and label text. Calling these low-level hooks
113/// directly bypasses that boundary; sanitize their output (for example with
114/// [`str::escape_debug`]) before writing it to a terminal or log.
115pub trait Localizer {
116    // ---------------- Common tiny building blocks ----------------
117
118    /// Attach an ordinary error-location suffix to `base`.
119    ///
120    /// Renderers must use this instead of hard-coding English wording like
121    /// `" at line X, column Y"`.
122    /// Plain alias use and definition locations have separate hooks:
123    /// [`Localizer::alias_used_at`] and [`Localizer::alias_defined_at`].
124    ///
125    /// Default:
126    /// - If `loc == Location::UNKNOWN`: returns `base` unchanged.
127    /// - Otherwise: returns `"{base} at line {line}, column {column}"`.
128    fn attach_location<'a>(&self, base: Cow<'a, str>, loc: Location) -> Cow<'a, str> {
129        if loc == Location::UNKNOWN {
130            base
131        } else {
132            Cow::Owned(format!(
133                "{base} at line {}, column {}",
134                loc.line, loc.column
135            ))
136        }
137    }
138
139    /// Label used when a path has no leaf.
140    ///
141    /// Default `<root>`
142    fn root_path_label(&self) -> Cow<'static, str> {
143        Cow::Borrowed("<root>")
144    }
145
146    /// Suffix for alias-related errors when a distinct defined-location is available.
147    ///
148    /// Default wording matches the crate's historical English output:
149    /// `" (defined at line X, column Y)"`.
150    ///
151    /// Default: `format!(" (defined at line {line}, column {column})", ...)`.
152    fn alias_defined_at(&self, defined: Location) -> String {
153        format!(
154            " (defined at line {}, column {})",
155            defined.line, defined.column
156        )
157    }
158
159    /// Suffix identifying where an aliased value is used in a plain-text diagnostic.
160    ///
161    /// Default: `" (used at line X, column Y)"`, or an empty string for an unknown location.
162    /// This replaces the generic [`Localizer::attach_location`] call for a known alias use.
163    /// Override this hook as well when customizing or suppressing alias-use coordinates;
164    /// return an empty string to omit the suffix.
165    fn alias_used_at(&self, used: Location) -> String {
166        if used == Location::UNKNOWN {
167            String::new()
168        } else {
169            format!(" (used at line {}, column {})", used.line, used.column)
170        }
171    }
172
173    // ---------------- Validation (plain text) glue ----------------
174
175    /// Render one validation issue line.
176    ///
177    /// The crate provides `resolved_path`, `entry` and the chosen `loc`.
178    ///
179    /// Default:
180    /// - Base text: `"validation error at {resolved_path}: {entry}"`.
181    /// - If `loc` is `Some` and not `Location::UNKNOWN`, appends a location suffix via
182    ///   [`Localizer::attach_location`].
183    fn validation_issue_line(
184        &self,
185        resolved_path: &str,
186        entry: &str,
187        loc: Option<Location>,
188    ) -> String {
189        let base = format!("validation error at {resolved_path}: {entry}");
190        match loc {
191            Some(l) if l != Location::UNKNOWN => {
192                self.attach_location(Cow::Owned(base), l).into_owned()
193            }
194            _ => base,
195        }
196    }
197
198    /// Join multiple validation issues into one message.
199    ///
200    /// Default: joins `lines` with a single newline (`"\n"`).
201    fn join_validation_issues(&self, lines: &[String]) -> String {
202        lines.join("\n")
203    }
204
205    /// Summary for a validation report whose issues are shown as related miette diagnostics.
206    ///
207    /// Default: `"validation failed"` for one issue, otherwise
208    /// `"validation failed (multiple errors)"`.
209    fn validation_failed(&self, issue_count: usize) -> Cow<'static, str> {
210        if issue_count == 1 {
211            Cow::Borrowed("validation failed")
212        } else {
213            Cow::Borrowed("validation failed (multiple errors)")
214        }
215    }
216
217    /// Summary for validation failures spanning multiple YAML documents.
218    ///
219    /// Default: `"validation failed for {document_count} document(s)"`.
220    fn validation_failed_documents(&self, document_count: usize) -> String {
221        format!("validation failed for {document_count} document(s)")
222    }
223
224    // ---------------- Validation snippets / diagnostic labels ----------------
225
226    /// Legacy label for a snippet window at the “definition” site.
227    ///
228    /// Retained for compatibility. Built-in validation snippets now use the actual source
229    /// name in their headers and do not call this hook.
230    ///
231    /// Default: `"(defined)"`.
232    fn defined(&self) -> Cow<'static, str> {
233        Cow::Borrowed("(defined)")
234    }
235
236    /// Legacy label for a snippet window with only a “defined here” location.
237    ///
238    /// Retained for compatibility. Built-in validation snippets now use the actual source
239    /// name in their headers and do not call this hook.
240    ///
241    /// Default: `"(defined here)"`.
242    fn defined_here(&self) -> Cow<'static, str> {
243        Cow::Borrowed("(defined here)")
244    }
245
246    /// Label used for the primary snippet window when an aliased/anchored value is used
247    /// at a different location than where it was defined, and for use sites in miette.
248    ///
249    /// Default: `"the value is used here"`.
250    fn value_used_here(&self) -> Cow<'static, str> {
251        Cow::Borrowed("the value is used here")
252    }
253
254    /// Label used for the secondary snippet window that points at the anchor definition.
255    /// Also used for validation definitions and definition-only locations in miette.
256    ///
257    /// For the separate anchor-definition label in miette alias diagnostics, also override
258    /// [`Localizer::anchor_defined_here`], which defaults to `"anchor defined here"`
259    /// independently of this method.
260    ///
261    /// Default: `"defined here"`.
262    fn defined_window(&self) -> Cow<'static, str> {
263        Cow::Borrowed("defined here")
264    }
265
266    /// Label for an alias's anchor definition in miette diagnostics.
267    ///
268    /// Default: `"anchor defined here"`.
269    fn anchor_defined_here(&self) -> Cow<'static, str> {
270        Cow::Borrowed("anchor defined here")
271    }
272
273    /// Message identifying an include site in a diagnostic.
274    ///
275    /// Used by miette related diagnostics and plain snippet headings. The plain snippet
276    /// renderer adds a trailing colon.
277    ///
278    /// Default: `"included from here"` (without a trailing colon).
279    fn included_from_here(&self) -> Cow<'static, str> {
280        Cow::Borrowed("included from here")
281    }
282
283    /// Label for the failing value when it differs from the alias definition and use.
284    ///
285    /// Default: `"the error occurred here"`.
286    fn error_here(&self) -> Cow<'static, str> {
287        Cow::Borrowed("the error occurred here")
288    }
289
290    /// Compose the base validation message used in snippets and miette diagnostics.
291    ///
292    /// Default: `"validation error: {entry} for `{`resolved_path`}`"`.
293    fn validation_base_message(&self, entry: &str, resolved_path: &str) -> String {
294        format!("validation error: {entry} for `{resolved_path}`")
295    }
296
297    /// Compose the “invalid here” prefix for the primary snippet message.
298    ///
299    /// Default: `"invalid here, {base}"`.
300    fn invalid_here(&self, base: &str) -> String {
301        format!("invalid here, {base}")
302    }
303
304    /// Intro line printed between the primary and secondary snippet windows for
305    /// anchor/alias (“indirect value”) cases.
306    ///
307    /// Default:
308    /// `"  | This value comes indirectly from the anchor at line {line} column {column}:"`.
309    fn value_comes_from_the_anchor(&self, def: Location) -> String {
310        format!(
311            "  | This value comes indirectly from the anchor at line {} column {}:",
312            def.line, def.column
313        )
314    }
315
316    // ---------------- External overrides ----------------
317
318    /// Optional hook to override the location prefix used for snippet titles
319    ///
320    /// Default:
321    /// - If `loc == Location::UNKNOWN`: returns an empty string.
322    /// - Otherwise: returns `"line {line} column {column}"`.
323    fn snippet_location_prefix(&self, loc: Location) -> String {
324        if loc == Location::UNKNOWN {
325            String::new()
326        } else {
327            format!("line {} column {}", loc.line(), loc.column())
328        }
329    }
330
331    /// Best-effort hook to override/translate dependency-provided message text.
332    ///
333    /// Default: returns `None` (keep the external message as-is).
334    fn override_external_message<'a>(&self, _msg: ExternalMessage<'a>) -> Option<Cow<'a, str>> {
335        None
336    }
337}
338
339/// Default English localizer used by the crate.
340#[derive(Debug, Default, Clone, Copy)]
341pub struct DefaultEnglishLocalizer;
342
343impl Localizer for DefaultEnglishLocalizer {}
344
345/// A single shared instance of the default English localizer.
346///
347/// This avoids repeated instantiation and provides a convenient reference for wrappers.
348pub static DEFAULT_ENGLISH_LOCALIZER: DefaultEnglishLocalizer = DefaultEnglishLocalizer;