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 a location suffix to `base`.
119 ///
120 /// Renderers must use this instead of hard-coding English wording like
121 /// `" at line X, column Y"`.
122 ///
123 /// Default:
124 /// - If `loc == Location::UNKNOWN`: returns `base` unchanged.
125 /// - Otherwise: returns `"{base} at line {line}, column {column}"`.
126 fn attach_location<'a>(&self, base: Cow<'a, str>, loc: Location) -> Cow<'a, str> {
127 if loc == Location::UNKNOWN {
128 base
129 } else {
130 Cow::Owned(format!(
131 "{base} at line {}, column {}",
132 loc.line, loc.column
133 ))
134 }
135 }
136
137 /// Label used when a path has no leaf.
138 ///
139 /// Default `<root>`
140 fn root_path_label(&self) -> Cow<'static, str> {
141 Cow::Borrowed("<root>")
142 }
143
144 /// Suffix for alias-related errors when a distinct defined-location is available.
145 ///
146 /// Default wording matches the crate's historical English output:
147 /// `" (defined at line X, column Y)"`.
148 ///
149 /// Default: `format!(" (defined at line {line}, column {column})", ...)`.
150 fn alias_defined_at(&self, defined: Location) -> String {
151 format!(
152 " (defined at line {}, column {})",
153 defined.line, defined.column
154 )
155 }
156
157 // ---------------- Validation (plain text) glue ----------------
158
159 /// Render one validation issue line.
160 ///
161 /// The crate provides `resolved_path`, `entry` and the chosen `loc`.
162 ///
163 /// Default:
164 /// - Base text: `"validation error at {resolved_path}: {entry}"`.
165 /// - If `loc` is `Some` and not `Location::UNKNOWN`, appends a location suffix via
166 /// [`Localizer::attach_location`].
167 fn validation_issue_line(
168 &self,
169 resolved_path: &str,
170 entry: &str,
171 loc: Option<Location>,
172 ) -> String {
173 let base = format!("validation error at {resolved_path}: {entry}");
174 match loc {
175 Some(l) if l != Location::UNKNOWN => {
176 self.attach_location(Cow::Owned(base), l).into_owned()
177 }
178 _ => base,
179 }
180 }
181
182 /// Join multiple validation issues into one message.
183 ///
184 /// Default: joins `lines` with a single newline (`"\n"`).
185 fn join_validation_issues(&self, lines: &[String]) -> String {
186 lines.join("\n")
187 }
188
189 // ---------------- Validation snippets / diagnostic labels ----------------
190
191 /// Label used for a snippet window when the location is known and considered the
192 /// “definition” site.
193 ///
194 /// Default: `"(defined)"`.
195 fn defined(&self) -> Cow<'static, str> {
196 Cow::Borrowed("(defined)")
197 }
198
199 /// Label used for a snippet window when we only have a “defined here” location.
200 ///
201 /// Default: `"(defined here)"`.
202 fn defined_here(&self) -> Cow<'static, str> {
203 Cow::Borrowed("(defined here)")
204 }
205
206 /// Label used for the primary snippet window when an aliased/anchored value is used
207 /// at a different location than where it was defined.
208 ///
209 /// Default: `"the value is used here"`.
210 fn value_used_here(&self) -> Cow<'static, str> {
211 Cow::Borrowed("the value is used here")
212 }
213
214 /// Label used for the secondary snippet window that points at the anchor definition.
215 ///
216 /// Default: `"defined here"`.
217 fn defined_window(&self) -> Cow<'static, str> {
218 Cow::Borrowed("defined here")
219 }
220
221 /// Compose the base validation message used in snippet rendering.
222 ///
223 /// Default: `"validation error: {entry} for `{`resolved_path`}`"`.
224 fn validation_base_message(&self, entry: &str, resolved_path: &str) -> String {
225 format!("validation error: {entry} for `{resolved_path}`")
226 }
227
228 /// Compose the “invalid here” prefix for the primary snippet message.
229 ///
230 /// Default: `"invalid here, {base}"`.
231 fn invalid_here(&self, base: &str) -> String {
232 format!("invalid here, {base}")
233 }
234
235 /// Intro line printed between the primary and secondary snippet windows for
236 /// anchor/alias (“indirect value”) cases.
237 ///
238 /// Default:
239 /// `" | This value comes indirectly from the anchor at line {line} column {column}:"`.
240 fn value_comes_from_the_anchor(&self, def: Location) -> String {
241 format!(
242 " | This value comes indirectly from the anchor at line {} column {}:",
243 def.line, def.column
244 )
245 }
246
247 // ---------------- External overrides ----------------
248
249 /// Optional hook to override the location prefix used for snippet titles
250 ///
251 /// Default:
252 /// - If `loc == Location::UNKNOWN`: returns an empty string.
253 /// - Otherwise: returns `"line {line} column {column}"`.
254 fn snippet_location_prefix(&self, loc: Location) -> String {
255 if loc == Location::UNKNOWN {
256 String::new()
257 } else {
258 format!("line {} column {}", loc.line(), loc.column())
259 }
260 }
261
262 /// Best-effort hook to override/translate dependency-provided message text.
263 ///
264 /// Default: returns `None` (keep the external message as-is).
265 fn override_external_message<'a>(&self, _msg: ExternalMessage<'a>) -> Option<Cow<'a, str>> {
266 None
267 }
268}
269
270/// Default English localizer used by the crate.
271#[derive(Debug, Default, Clone, Copy)]
272pub struct DefaultEnglishLocalizer;
273
274impl Localizer for DefaultEnglishLocalizer {}
275
276/// A single shared instance of the default English localizer.
277///
278/// This avoids repeated instantiation and provides a convenient reference for wrappers.
279pub static DEFAULT_ENGLISH_LOCALIZER: DefaultEnglishLocalizer = DefaultEnglishLocalizer;