Skip to main content

citum_schema_style/locale/
mod.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! Locale definitions for Citum.
7//!
8//! Locales provide language-specific terms, date formats, and punctuation rules
9//! for citation formatting.
10
11mod date_patterns;
12/// Locator text normalization.
13pub mod locator;
14/// Message evaluation for parameterized locale strings.
15pub mod message;
16mod message_ids;
17/// Raw locale types used during locale file parsing.
18pub mod raw;
19mod raw_conversion;
20mod sort;
21mod terms;
22/// Structured locale types used by the processor.
23pub mod types;
24mod vocab;
25
26use crate::citation::LocatorType;
27use crate::template::ContributorRole;
28pub use message::{MessageArgs, MessageEvaluator, Mf2MessageEvaluator};
29pub use raw::{RawLocale, RawTermValue};
30#[cfg(feature = "schema")]
31use schemars::JsonSchema;
32use serde::{Deserialize, Serialize};
33use std::collections::HashMap;
34use std::fmt;
35use std::sync::Arc;
36pub use terms::ArchiveHierarchyField;
37pub use types::*;
38
39/// A list of month names (12 elements for Jan-Dec).
40pub type MonthList = Vec<String>;
41
42/// A locale definition containing language-specific terms and formatting rules.
43///
44/// The `evaluator` field holds the message evaluation engine, selected based on
45/// `evaluation.message_syntax`. This allows for trait-based swapping to ICU4X
46/// implementations in the future without changing call sites.
47#[derive(Clone, Deserialize, Serialize)]
48#[cfg_attr(feature = "schema", derive(JsonSchema))]
49#[serde(rename_all = "kebab-case")]
50pub struct Locale {
51    /// The locale identifier (e.g., "en-US", "de-DE").
52    #[cfg_attr(feature = "schema", schemars(skip))]
53    pub locale: String,
54    /// Date-related terms (months, seasons).
55    #[serde(default)]
56    pub dates: DateTerms,
57    /// Contributor role terms (editor, translator, etc.).
58    #[serde(default)]
59    #[cfg_attr(feature = "schema", schemars(skip))]
60    pub roles: HashMap<ContributorRole, ContributorTerm>,
61    /// Locator terms (page, chapter, etc.).
62    #[serde(default)]
63    #[cfg_attr(feature = "schema", schemars(skip))]
64    pub locators: HashMap<LocatorType, LocatorTerm>,
65    /// General terms (and, et al., etc.).
66    #[serde(default)]
67    pub terms: Terms,
68    /// Whether to place periods/commas inside quotation marks.
69    /// true = American style ("text."), false = British style ("text".)
70    #[serde(default)]
71    pub punctuation_in_quote: bool,
72    /// Articles to strip from titles when sorting (e.g., "the", "a", "an" for English).
73    /// These should be lowercase and will be matched case-insensitively.
74    #[serde(default, skip_serializing_if = "Vec::is_empty")]
75    pub sort_articles: Vec<String>,
76    /// Schema version from the source locale file (None = legacy v1).
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub locale_schema_version: Option<String>,
79    /// Runtime evaluation configuration.
80    #[serde(default)]
81    pub evaluation: EvaluationConfig,
82    /// ICU MF1 messages keyed by message ID (populated for v2 locales).
83    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
84    pub messages: HashMap<String, String>,
85    /// Named date format presets: symbolic name → CLDR pattern.
86    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
87    pub date_formats: HashMap<String, String>,
88    /// Number formatting options.
89    #[serde(default)]
90    pub number_formats: NumberFormats,
91    /// Grammar options.
92    #[serde(default)]
93    pub grammar_options: GrammarOptions,
94    /// Backwards-compatibility aliases: old term key → new message ID.
95    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
96    pub legacy_term_aliases: HashMap<String, String>,
97    /// Vocabulary maps for genre and medium display text.
98    #[serde(default, skip_serializing_if = "VocabMap::is_empty")]
99    pub vocab: VocabMap,
100    /// Reference-type description terms, keyed by CSL-style `ref_type`
101    /// spelling (e.g. `"dataset"`, `"article-journal"`). Used by the
102    /// `type-label` template component to resolve a localized fallback
103    /// label when a reference has no `genre`/`medium` override. See
104    /// `docs/specs/TYPE_CLASSIFICATION_CENTRALIZATION.md`.
105    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
106    pub type_terms: HashMap<String, SimpleTerm>,
107    /// Message evaluator implementation (not serialized; set during load).
108    #[serde(skip, default = "default_evaluator")]
109    #[cfg_attr(feature = "schema", schemars(skip))]
110    pub evaluator: Arc<dyn MessageEvaluator>,
111}
112
113/// Default message evaluator (MF2).
114fn default_evaluator() -> Arc<dyn MessageEvaluator> {
115    Arc::new(Mf2MessageEvaluator)
116}
117
118impl Default for Locale {
119    fn default() -> Self {
120        Self {
121            locale: String::default(),
122            dates: DateTerms::default(),
123            roles: HashMap::default(),
124            locators: HashMap::default(),
125            terms: Terms::default(),
126            punctuation_in_quote: false,
127            sort_articles: Vec::default(),
128            locale_schema_version: None,
129            evaluation: EvaluationConfig::default(),
130            messages: HashMap::default(),
131            date_formats: HashMap::default(),
132            number_formats: NumberFormats::default(),
133            grammar_options: GrammarOptions::default(),
134            legacy_term_aliases: HashMap::default(),
135            vocab: VocabMap::default(),
136            type_terms: HashMap::default(),
137            evaluator: default_evaluator(),
138        }
139    }
140}
141
142impl fmt::Debug for Locale {
143    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
144        f.debug_struct("Locale")
145            .field("locale", &self.locale)
146            .field("dates", &self.dates)
147            .field("roles", &self.roles)
148            .field("locators", &self.locators)
149            .field("terms", &self.terms)
150            .field("punctuation_in_quote", &self.punctuation_in_quote)
151            .field("sort_articles", &self.sort_articles)
152            .field("locale_schema_version", &self.locale_schema_version)
153            .field("evaluation", &self.evaluation)
154            .field("messages", &self.messages)
155            .field("date_formats", &self.date_formats)
156            .field("number_formats", &self.number_formats)
157            .field("grammar_options", &self.grammar_options)
158            .field("legacy_term_aliases", &self.legacy_term_aliases)
159            .field("vocab", &self.vocab)
160            .field("type_terms", &self.type_terms)
161            .field("evaluator", &"<MessageEvaluator>")
162            .finish()
163    }
164}
165
166impl Locale {
167    /// Create the English (US) locale, the fallback baseline every other
168    /// locale inherits from and the default for the majority of embedded
169    /// styles (which declare no `info.default-locale`).
170    ///
171    /// This parses the embedded canonical asset
172    /// (`embedded/locales/en-US.yaml`) so the YAML is the single source of
173    /// truth — there is no separate hand-maintained Rust copy to drift out
174    /// of sync with it. The parse is memoized in a `std::sync::OnceLock`
175    /// since it is pure and immutable; callers get a `clone()` of the cached
176    /// result (still a deep copy of its maps/vecs, but far cheaper than
177    /// re-parsing the YAML) rather than re-parsing on every call.
178    ///
179    /// Seeds from [`Locale::default()`] (not `from_raw`'s usual
180    /// `Locale::en_us()` seed) via `from_raw_with_base` to avoid infinite
181    /// recursion through this very function.
182    ///
183    /// # Panics
184    ///
185    /// Panics if the embedded `en-US.yaml` asset is missing, not valid
186    /// UTF-8, or fails to parse. This cannot happen at runtime: the asset is
187    /// embedded at compile time and covered by
188    /// `bundled_ar_ar_and_eu_es_locales_are_embedded_and_parseable`-style
189    /// tests, so a failure here indicates a broken build, not bad input.
190    #[allow(
191        clippy::expect_used,
192        reason = "Embedded en-US.yaml locale must parse; failure indicates a broken build, not bad input"
193    )]
194    pub fn en_us() -> Self {
195        static EN_US: std::sync::OnceLock<Locale> = std::sync::OnceLock::new();
196        EN_US
197            .get_or_init(|| {
198                let bytes = crate::embedded::get_locale_bytes("en-US")
199                    .expect("en-US is a compile-time embedded locale");
200                let yaml = std::str::from_utf8(bytes).expect("embedded en-US.yaml is valid UTF-8");
201                let raw: RawLocale =
202                    serde_yaml::from_str(yaml).expect("embedded en-US.yaml parses");
203                Self::from_raw_with_base(raw, Locale::default())
204            })
205            .clone()
206    }
207}
208
209#[cfg(test)]
210#[allow(
211    clippy::unwrap_used,
212    clippy::expect_used,
213    clippy::panic,
214    clippy::indexing_slicing,
215    clippy::todo,
216    clippy::unimplemented,
217    clippy::unreachable,
218    clippy::get_unwrap,
219    reason = "Panicking is acceptable and often desired in tests."
220)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn test_en_us_locale_model_defaults() {
226        let locale = Locale::en_us();
227        assert_eq!(locale.locale, "en-US");
228        assert!(locale.punctuation_in_quote);
229        assert_eq!(locale.sort_articles, ["the", "a", "an"]);
230        assert!(locale.roles.contains_key(&ContributorRole::Editor));
231        assert!(locale.locators.contains_key(&LocatorType::Page));
232    }
233
234    #[test]
235    fn test_locale_deserialization() {
236        let json = r#"{
237            "locale": "en-US",
238            "dates": {
239                "months": {
240                    "long": ["January", "February", "March", "April", "May", "June",
241                             "July", "August", "September", "October", "November", "December"],
242                    "short": ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
243                              "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
244                },
245                "seasons": ["Spring", "Summer", "Autumn", "Winter"]
246            },
247            "roles": {},
248            "terms": {
249                "and": "and",
250                "et-al": "et al."
251            }
252        }"#;
253
254        let locale: Locale = serde_json::from_str(json).unwrap();
255        assert_eq!(locale.locale, "en-US");
256        assert_eq!(locale.dates.months.long[0], "January");
257        assert_eq!(locale.terms.and.as_ref().unwrap(), "and");
258    }
259
260    #[test]
261    fn test_yaml_locale_loading() {
262        let yaml = r#"
263locale: de-DE
264dates:
265  months:
266    long:
267      - Januar
268      - Februar
269      - März
270      - April
271      - Mai
272      - Juni
273      - Juli
274      - August
275      - September
276      - Oktober
277      - November
278      - Dezember
279    short:
280      - Jan.
281      - Feb.
282      - März
283      - Apr.
284      - Mai
285      - Juni
286      - Juli
287      - Aug.
288      - Sep.
289      - Okt.
290      - Nov.
291      - Dez.
292  seasons:
293    - Frühling
294    - Sommer
295    - Herbst
296    - Winter
297terms:
298  and:
299    long: und
300    symbol: "&"
301  et_al:
302    long: "u. a."
303"#;
304
305        let locale = Locale::from_yaml_str(yaml).unwrap();
306        assert_eq!(locale.locale, "de-DE");
307        assert_eq!(locale.terms.and.as_deref(), Some("und"));
308        assert_eq!(locale.terms.et_al.as_deref(), Some("u. a."));
309        assert_eq!(locale.dates.months.long[0], "Januar");
310        assert_eq!(locale.dates.months.long[2], "März");
311    }
312
313    /// v2 locale with grammar-options overrides punctuation_in_quote correctly.
314    #[test]
315    fn test_v2_grammar_options_sync_punctuation_in_quote() {
316        let yaml = r#"
317locale-schema-version: "2"
318locale: en-GB
319grammar-options:
320  punctuation-in-quote: false
321"#;
322        let locale = Locale::from_yaml_str(yaml).unwrap();
323        // grammar_options is the authoritative source for v2 locales
324        assert!(!locale.grammar_options.punctuation_in_quote);
325        // legacy field is synced from grammar_options
326        assert!(!locale.punctuation_in_quote);
327    }
328
329    /// v1 locale (no grammar-options) derives punctuation_in_quote from locale ID.
330    #[test]
331    fn test_v1_locale_derives_punctuation_from_locale_id() {
332        let yaml = r#"
333locale: en-US
334"#;
335        let locale = Locale::from_yaml_str(yaml).unwrap();
336        // en-US uses American style (inside)
337        assert!(locale.punctuation_in_quote);
338        assert!(locale.grammar_options.punctuation_in_quote);
339    }
340
341    /// Partial locales inherit base messages, date formats, and aliases.
342    #[test]
343    fn test_partial_locale_merges_raw_maps_with_base() {
344        let yaml = r#"
345locale-schema-version: "2"
346locale: zz-ZZ
347messages:
348  pattern.in-container: "inside {$container}"
349date-formats:
350  numeric-short: "dd/MM/y"
351locators:
352  page:
353    long:
354      singular: page-localized
355      plural: pages-localized
356legacy-term-aliases:
357  page: term.page-label-long
358"#;
359        let locale = Locale::from_yaml_str(yaml).unwrap();
360
361        assert_eq!(
362            locale
363                .messages
364                .get("pattern.originally-published-as")
365                .map(String::as_str),
366            Some("originally published as {$title}")
367        );
368        assert_eq!(
369            locale
370                .messages
371                .get("pattern.in-container")
372                .map(String::as_str),
373            Some("inside {$container}")
374        );
375        assert_eq!(
376            locale.date_formats.get("textual-full").map(String::as_str),
377            Some("MMMM d, yyyy")
378        );
379        assert_eq!(
380            locale.date_formats.get("numeric-short").map(String::as_str),
381            Some("dd/MM/y")
382        );
383        assert_eq!(
384            locale.legacy_term_aliases.get("and").map(String::as_str),
385            Some("term.and")
386        );
387        assert_eq!(
388            locale.legacy_term_aliases.get("page").map(String::as_str),
389            Some("term.page-label-long")
390        );
391        assert_eq!(
392            locale.resolved_locator_term(&LocatorType::Page, false, &TermForm::Long, None),
393            Some("page-localized".to_string())
394        );
395    }
396
397    /// apply_override merges messages key-by-key into the base locale.
398    #[test]
399    fn test_apply_override_merges_messages() {
400        let mut locale = Locale::en_us();
401        locale
402            .messages
403            .insert("term.page-label".into(), "p.".into());
404        let ov = LocaleOverride {
405            messages: [("term.page-label".into(), "pg.".into())].into(),
406            ..Default::default()
407        };
408        locale.apply_override(&ov);
409        assert_eq!(
410            locale.messages.get("term.page-label").map(|s| s.as_str()),
411            Some("pg.")
412        );
413    }
414
415    /// The hardcoded en-US locale includes phrase messages used by style
416    /// `message:` components, not only legacy term compatibility messages.
417    #[test]
418    fn test_en_us_locale_resolves_phrase_messages() {
419        let locale = Locale::en_us();
420        let args = MessageArgs {
421            named: [("container".to_string(), "Book Title".to_string())].into(),
422            ..Default::default()
423        };
424
425        assert_eq!(
426            locale.resolve_message("pattern.in-container", &args),
427            Some("in Book Title".to_string())
428        );
429    }
430
431    /// apply_override with grammar_options replaces block and syncs punctuation_in_quote.
432    #[test]
433    fn test_apply_override_grammar_options_syncs_punctuation() {
434        let mut locale = Locale::en_us();
435        locale.punctuation_in_quote = false;
436        let ov = LocaleOverride {
437            grammar_options: Some(GrammarOptions {
438                punctuation_in_quote: true,
439                ..Default::default()
440            }),
441            ..Default::default()
442        };
443        locale.apply_override(&ov);
444        assert!(locale.punctuation_in_quote);
445        assert!(locale.grammar_options.punctuation_in_quote);
446    }
447
448    #[test]
449    fn embedded_locale_ids_include_all_bundled_locale_files() {
450        for id in [
451            "en-US", "ar-AR", "de-DE", "es-ES", "eu-ES", "fr-FR", "tr-TR",
452        ] {
453            assert!(
454                crate::embedded::EMBEDDED_LOCALE_IDS.contains(&id),
455                "{id} should be listed as an embedded locale"
456            );
457        }
458    }
459
460    #[test]
461    fn bundled_ar_ar_and_eu_es_locales_are_embedded_and_parseable() {
462        for id in ["ar-AR", "eu-ES"] {
463            let bytes = crate::embedded::get_locale_bytes(id).expect("locale should be embedded");
464            let yaml = std::str::from_utf8(bytes).expect("embedded locale should be utf-8");
465            let locale = Locale::from_yaml_str(yaml).expect("embedded locale should parse");
466
467            assert_eq!(locale.locale, id);
468        }
469    }
470
471    /// Round-trip regression guard for `Locale::en_us()` parsing the
472    /// embedded `en-US.yaml` asset: asserts the critical values a future
473    /// edit to that YAML could silently regress, since `en_us()` is the
474    /// fallback baseline for the large majority of embedded styles.
475    #[test]
476    fn en_us_locale_round_trip_carries_critical_values() {
477        let locale = Locale::en_us();
478
479        // Role labels (CSL reference: scripts/locales-en-US.xml).
480        assert_eq!(
481            locale.resolved_role_term(&ContributorRole::Translator, false, &TermForm::Short, None),
482            Some("trans.".to_string())
483        );
484
485        // Locator labels (CSL reference: chap./chaps.).
486        assert_eq!(
487            locale.locator_term(&LocatorType::Chapter, false, &TermForm::Short, None),
488            Some("chap.")
489        );
490        assert_eq!(
491            locale.locator_term(&LocatorType::Chapter, true, &TermForm::Short, None),
492            Some("chaps.")
493        );
494
495        // No-date term is form-aware (see general_term fix).
496        assert_eq!(
497            locale.general_term(&GeneralTerm::NoDate, &TermForm::Long, None),
498            Some("no date")
499        );
500        assert_eq!(
501            locale.general_term(&GeneralTerm::NoDate, &TermForm::Short, None),
502            Some("n.d.")
503        );
504
505        // Core general terms.
506        assert_eq!(locale.terms.and.as_deref(), Some("and"));
507        assert_eq!(locale.terms.et_al.as_deref(), Some("et al."));
508
509        // Month names.
510        assert_eq!(
511            locale.dates.months.long.first().map(String::as_str),
512            Some("January")
513        );
514
515        // Number formats (single-sourced explicitly in the YAML, Step 4).
516        assert_eq!(locale.number_formats.decimal_separator, ".");
517        assert_eq!(locale.number_formats.thousands_separator, ",");
518        assert_eq!(locale.number_formats.minimum_digits, 1);
519
520        // Sort articles.
521        assert_eq!(locale.sort_articles, ["the", "a", "an"]);
522    }
523}