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;