1mod date_patterns;
12pub mod locator;
14pub mod message;
16mod message_ids;
17pub mod raw;
19mod raw_conversion;
20mod sort;
21mod terms;
22pub 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
39pub type MonthList = Vec<String>;
41
42#[derive(Clone, Deserialize, Serialize)]
48#[cfg_attr(feature = "schema", derive(JsonSchema))]
49#[serde(rename_all = "kebab-case")]
50pub struct Locale {
51 #[cfg_attr(feature = "schema", schemars(skip))]
53 pub locale: String,
54 #[serde(default)]
56 pub dates: DateTerms,
57 #[serde(default)]
59 #[cfg_attr(feature = "schema", schemars(skip))]
60 pub roles: HashMap<ContributorRole, ContributorTerm>,
61 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
63 #[cfg_attr(feature = "schema", schemars(skip))]
64 pub role_combinations: HashMap<String, ContributorTerm>,
65 #[serde(default)]
67 #[cfg_attr(feature = "schema", schemars(skip))]
68 pub locators: HashMap<LocatorType, LocatorTerm>,
69 #[serde(default)]
71 pub terms: Terms,
72 #[serde(default)]
75 pub punctuation_in_quote: bool,
76 #[serde(default, skip_serializing_if = "Vec::is_empty")]
79 pub sort_articles: Vec<String>,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub locale_schema_version: Option<String>,
83 #[serde(default)]
85 pub evaluation: EvaluationConfig,
86 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
88 pub messages: HashMap<String, String>,
89 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
91 pub date_formats: HashMap<String, String>,
92 #[serde(default)]
94 pub number_formats: NumberFormats,
95 #[serde(default)]
97 pub grammar_options: GrammarOptions,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub punctuation_realization: Option<crate::options::PunctuationRealization>,
101 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
103 pub legacy_term_aliases: HashMap<String, String>,
104 #[serde(default, skip_serializing_if = "VocabMap::is_empty")]
106 pub vocab: VocabMap,
107 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
113 pub type_terms: HashMap<String, SimpleTerm>,
114 #[serde(skip, default = "default_evaluator")]
116 #[cfg_attr(feature = "schema", schemars(skip))]
117 pub evaluator: Arc<dyn MessageEvaluator>,
118}
119
120fn default_evaluator() -> Arc<dyn MessageEvaluator> {
122 Arc::new(Mf2MessageEvaluator)
123}
124
125impl Default for Locale {
126 fn default() -> Self {
127 Self {
128 locale: String::default(),
129 dates: DateTerms::default(),
130 roles: HashMap::default(),
131 role_combinations: HashMap::default(),
132 locators: HashMap::default(),
133 terms: Terms::default(),
134 punctuation_in_quote: false,
135 sort_articles: Vec::default(),
136 locale_schema_version: None,
137 evaluation: EvaluationConfig::default(),
138 messages: HashMap::default(),
139 date_formats: HashMap::default(),
140 number_formats: NumberFormats::default(),
141 grammar_options: GrammarOptions::default(),
142 punctuation_realization: None,
143 legacy_term_aliases: HashMap::default(),
144 vocab: VocabMap::default(),
145 type_terms: HashMap::default(),
146 evaluator: default_evaluator(),
147 }
148 }
149}
150
151impl fmt::Debug for Locale {
152 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
153 f.debug_struct("Locale")
154 .field("locale", &self.locale)
155 .field("dates", &self.dates)
156 .field("roles", &self.roles)
157 .field("role_combinations", &self.role_combinations)
158 .field("locators", &self.locators)
159 .field("terms", &self.terms)
160 .field("punctuation_in_quote", &self.punctuation_in_quote)
161 .field("sort_articles", &self.sort_articles)
162 .field("locale_schema_version", &self.locale_schema_version)
163 .field("evaluation", &self.evaluation)
164 .field("messages", &self.messages)
165 .field("date_formats", &self.date_formats)
166 .field("number_formats", &self.number_formats)
167 .field("grammar_options", &self.grammar_options)
168 .field("punctuation_realization", &self.punctuation_realization)
169 .field("legacy_term_aliases", &self.legacy_term_aliases)
170 .field("vocab", &self.vocab)
171 .field("type_terms", &self.type_terms)
172 .field("evaluator", &"<MessageEvaluator>")
173 .finish()
174 }
175}
176
177impl Locale {
178 #[allow(
202 clippy::expect_used,
203 reason = "Embedded en-US.yaml locale must parse; failure indicates a broken build, not bad input"
204 )]
205 pub fn en_us() -> Self {
206 static EN_US: std::sync::OnceLock<Locale> = std::sync::OnceLock::new();
207 EN_US
208 .get_or_init(|| {
209 let bytes = crate::embedded::get_locale_bytes("en-US")
210 .expect("en-US is a compile-time embedded locale");
211 let yaml = std::str::from_utf8(bytes).expect("embedded en-US.yaml is valid UTF-8");
212 let raw: RawLocale =
213 serde_yaml::from_str(yaml).expect("embedded en-US.yaml parses");
214 Self::from_raw_with_base(raw, Locale::default())
215 })
216 .clone()
217 }
218
219 #[allow(
227 clippy::expect_used,
228 reason = "Embedded French locale assets must parse; failure indicates a broken build"
229 )]
230 #[must_use]
231 pub fn fr_ca() -> Self {
232 let mut french = Self::from_yaml_str(include_str!("../../embedded/locales/fr-FR.yaml"))
233 .expect("embedded fr-FR.yaml parses");
234 let raw: RawLocale =
235 serde_yaml::from_str(include_str!("../../embedded/locales/fr-CA.yaml"))
236 .expect("embedded fr-CA.yaml parses");
237 french.locale = raw.locale;
238 french.locale_schema_version = raw.locale_schema_version;
239 french.date_formats.extend(raw.date_formats);
240 if let Some(grammar_options) = raw.grammar_options {
241 french.punctuation_in_quote = grammar_options.punctuation_in_quote;
242 french.grammar_options = grammar_options;
243 }
244 french.punctuation_realization = raw.punctuation_realization;
245 french
246 }
247
248 #[must_use]
260 pub fn with_term_surfaces_from(&self, item: &Locale) -> Locale {
261 Locale {
262 locale: self.locale.clone(),
267 punctuation_in_quote: self.punctuation_in_quote,
268 sort_articles: self.sort_articles.clone(),
269 locale_schema_version: self.locale_schema_version.clone(),
270 number_formats: self.number_formats.clone(),
271 grammar_options: self.grammar_options.clone(),
272 punctuation_realization: item.punctuation_realization.clone(),
273 dates: item.dates.clone(),
275 roles: item.roles.clone(),
276 role_combinations: item.role_combinations.clone(),
277 locators: item.locators.clone(),
278 terms: item.terms.clone(),
279 evaluation: item.evaluation.clone(),
280 messages: item.messages.clone(),
281 date_formats: item.date_formats.clone(),
282 legacy_term_aliases: item.legacy_term_aliases.clone(),
283 vocab: item.vocab.clone(),
284 type_terms: item.type_terms.clone(),
285 evaluator: item.evaluator.clone(),
286 }
287 }
288}
289
290#[cfg(test)]
291#[allow(
292 clippy::unwrap_used,
293 clippy::expect_used,
294 clippy::panic,
295 clippy::indexing_slicing,
296 clippy::todo,
297 clippy::unimplemented,
298 clippy::unreachable,
299 clippy::get_unwrap,
300 reason = "Panicking is acceptable and often desired in tests."
301)]
302mod tests {
303 use super::*;
304
305 #[test]
306 fn test_en_us_locale_model_defaults() {
307 let locale = Locale::en_us();
308 assert_eq!(locale.locale, "en-US");
309 assert!(locale.punctuation_in_quote);
310 assert_eq!(locale.sort_articles, ["the", "a", "an"]);
311 assert!(locale.roles.contains_key(&ContributorRole::Editor));
312 assert!(locale.locators.contains_key(&LocatorType::Page));
313 }
314
315 #[test]
316 fn test_locale_deserialization() {
317 let json = r#"{
318 "locale": "en-US",
319 "dates": {
320 "months": {
321 "long": ["January", "February", "March", "April", "May", "June",
322 "July", "August", "September", "October", "November", "December"],
323 "short": ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
324 "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
325 },
326 "seasons": ["Spring", "Summer", "Autumn", "Winter"]
327 },
328 "roles": {},
329 "terms": {
330 "and": "and",
331 "et-al": "et al."
332 }
333 }"#;
334
335 let locale: Locale = serde_json::from_str(json).unwrap();
336 assert_eq!(locale.locale, "en-US");
337 assert_eq!(locale.dates.months.long[0], "January");
338 assert_eq!(locale.terms.and.as_ref().unwrap(), "and");
339 }
340
341 #[test]
342 fn locale_punctuation_realization_deserializes_as_a_partial_table() {
343 let locale = Locale::from_yaml_str(
344 r#"
345locale: test
346punctuation-realization:
347 colon: "\u00A0: "
348"#,
349 )
350 .expect("locale punctuation realization should parse");
351
352 let realization = locale
353 .punctuation_realization
354 .expect("locale punctuation realization should be present");
355 assert_eq!(realization.colon.as_deref(), Some("\u{a0}: "));
356 assert_eq!(realization.semicolon, None);
357 }
358
359 #[test]
360 fn fr_ca_inherits_french_lexical_data_and_overrides_punctuation_realization() {
361 let locale = Locale::fr_ca();
362
363 assert_eq!(locale.locale, "fr-CA");
364 assert_eq!(
365 locale.dates.months.long.first().map(String::as_str),
366 Some("janvier")
367 );
368 assert_eq!(
369 locale
370 .punctuation_realization
371 .as_ref()
372 .and_then(|table| table.semicolon.as_deref()),
373 Some("; ")
374 );
375 }
376
377 #[test]
378 fn test_yaml_locale_loading() {
379 let yaml = r#"
380locale: de-DE
381dates:
382 months:
383 long:
384 - Januar
385 - Februar
386 - März
387 - April
388 - Mai
389 - Juni
390 - Juli
391 - August
392 - September
393 - Oktober
394 - November
395 - Dezember
396 short:
397 - Jan.
398 - Feb.
399 - März
400 - Apr.
401 - Mai
402 - Juni
403 - Juli
404 - Aug.
405 - Sep.
406 - Okt.
407 - Nov.
408 - Dez.
409 seasons:
410 - Frühling
411 - Sommer
412 - Herbst
413 - Winter
414terms:
415 and:
416 long: und
417 symbol: "&"
418 et_al:
419 long: "u. a."
420"#;
421
422 let locale = Locale::from_yaml_str(yaml).unwrap();
423 assert_eq!(locale.locale, "de-DE");
424 assert_eq!(locale.terms.and.as_deref(), Some("und"));
425 assert_eq!(locale.terms.et_al.as_deref(), Some("u. a."));
426 assert_eq!(locale.dates.months.long[0], "Januar");
427 assert_eq!(locale.dates.months.long[2], "März");
428 }
429
430 #[test]
432 fn test_v2_grammar_options_sync_punctuation_in_quote() {
433 let yaml = r#"
434locale-schema-version: "2"
435locale: en-GB
436grammar-options:
437 punctuation-in-quote: false
438"#;
439 let locale = Locale::from_yaml_str(yaml).unwrap();
440 assert!(!locale.grammar_options.punctuation_in_quote);
442 assert!(!locale.punctuation_in_quote);
444 }
445
446 #[test]
448 fn test_v1_locale_derives_punctuation_from_locale_id() {
449 let yaml = r#"
450locale: en-US
451"#;
452 let locale = Locale::from_yaml_str(yaml).unwrap();
453 assert!(locale.punctuation_in_quote);
455 assert!(locale.grammar_options.punctuation_in_quote);
456 }
457
458 #[test]
460 fn test_partial_locale_merges_raw_maps_with_base() {
461 let yaml = r#"
462locale-schema-version: "2"
463locale: zz-ZZ
464messages:
465 pattern.in-container: "inside {$container}"
466date-formats:
467 numeric-short: "dd/MM/y"
468locators:
469 page:
470 long:
471 singular: page-localized
472 plural: pages-localized
473legacy-term-aliases:
474 page: term.page-label-long
475"#;
476 let locale = Locale::from_yaml_str(yaml).unwrap();
477
478 assert_eq!(
479 locale
480 .messages
481 .get("pattern.originally-published-as")
482 .map(String::as_str),
483 Some("originally published as {$title}")
484 );
485 assert_eq!(
486 locale
487 .messages
488 .get("pattern.in-container")
489 .map(String::as_str),
490 Some("inside {$container}")
491 );
492 assert_eq!(
493 locale.date_formats.get("textual-full").map(String::as_str),
494 Some("MMMM d, yyyy")
495 );
496 assert_eq!(
497 locale.date_formats.get("numeric-short").map(String::as_str),
498 Some("dd/MM/y")
499 );
500 assert_eq!(
501 locale.legacy_term_aliases.get("and").map(String::as_str),
502 Some("term.and")
503 );
504 assert_eq!(
505 locale.legacy_term_aliases.get("page").map(String::as_str),
506 Some("term.page-label-long")
507 );
508 assert_eq!(
509 locale.resolved_locator_term(&LocatorType::Page, false, &TermForm::Long, None),
510 Some("page-localized".to_string())
511 );
512 }
513
514 #[test]
516 fn test_apply_override_merges_messages() {
517 let mut locale = Locale::en_us();
518 locale
519 .messages
520 .insert("term.page-label".into(), "p.".into());
521 let ov = LocaleOverride {
522 messages: [("term.page-label".into(), "pg.".into())].into(),
523 ..Default::default()
524 };
525 locale.apply_override(&ov);
526 assert_eq!(
527 locale.messages.get("term.page-label").map(|s| s.as_str()),
528 Some("pg.")
529 );
530 }
531
532 #[test]
535 fn test_en_us_locale_resolves_phrase_messages() {
536 let locale = Locale::en_us();
537 let args = MessageArgs {
538 named: [("container".to_string(), "Book Title".to_string())].into(),
539 ..Default::default()
540 };
541
542 assert_eq!(
543 locale.resolve_message("pattern.in-container", &args),
544 Some("in Book Title".to_string())
545 );
546 }
547
548 #[test]
550 fn test_apply_override_grammar_options_syncs_punctuation() {
551 let mut locale = Locale::en_us();
552 locale.punctuation_in_quote = false;
553 let ov = LocaleOverride {
554 grammar_options: Some(GrammarOptions {
555 punctuation_in_quote: true,
556 ..Default::default()
557 }),
558 ..Default::default()
559 };
560 locale.apply_override(&ov);
561 assert!(locale.punctuation_in_quote);
562 assert!(locale.grammar_options.punctuation_in_quote);
563 }
564
565 #[test]
566 fn embedded_locale_ids_include_all_bundled_locale_files() {
567 for id in [
568 "en-US", "ar-AR", "de-DE", "es-ES", "eu-ES", "fr-FR", "tr-TR", "zh-CN", "ja-JP",
569 "ko-KR", "ru-RU",
570 ] {
571 assert!(
572 crate::embedded::EMBEDDED_LOCALE_IDS.contains(&id),
573 "{id} should be listed as an embedded locale"
574 );
575 }
576 }
577
578 #[test]
579 fn bundled_ar_ar_and_eu_es_locales_are_embedded_and_parseable() {
580 for id in ["ar-AR", "eu-ES"] {
581 let bytes = crate::embedded::get_locale_bytes(id).expect("locale should be embedded");
582 let yaml = std::str::from_utf8(bytes).expect("embedded locale should be utf-8");
583 let locale = Locale::from_yaml_str(yaml).expect("embedded locale should parse");
584
585 assert_eq!(locale.locale, id);
586 }
587 }
588
589 #[test]
593 fn bundled_ja_jp_ko_kr_ru_ru_locales_are_embedded_and_parseable() {
594 for (id, editor_short, and_term) in [
595 ("ja-JP", "編", "と"),
596 ("ko-KR", "편", "및"),
597 ("ru-RU", "ред.", "и"),
598 ] {
599 let bytes = crate::embedded::get_locale_bytes(id).expect("locale should be embedded");
600 let yaml = std::str::from_utf8(bytes).expect("embedded locale should be utf-8");
601 let locale = Locale::from_yaml_str(yaml).expect("embedded locale should parse");
602
603 assert_eq!(locale.locale, id);
604 assert_eq!(
605 locale.resolved_role_term(&ContributorRole::Editor, false, &TermForm::Short, None),
606 Some(editor_short.to_string()),
607 "{id} editor short-form role term"
608 );
609 assert_eq!(
610 locale.resolved_general_term(&GeneralTerm::And, &TermForm::Long, None),
611 Some(and_term.to_string()),
612 "{id} 'and' term"
613 );
614 assert!(
615 locale.date_formats.contains_key("iso"),
616 "{id} should carry date-formats"
617 );
618 }
619 }
620
621 #[test]
628 fn embedded_v2_locales_pass_completeness_lint() {
629 for &id in crate::embedded::EMBEDDED_LOCALE_IDS {
630 let bytes = crate::embedded::get_locale_bytes(id).expect("locale should be embedded");
631 let raw: RawLocale =
632 serde_yaml::from_slice(bytes).expect("embedded locale should parse as RawLocale");
633
634 if raw.locale_schema_version.as_deref() != Some("2") {
635 continue;
636 }
637
638 let report = crate::lint::lint_raw_locale(&raw);
639 assert!(
640 !report
641 .findings
642 .iter()
643 .any(|finding| finding.path == "grammar-options"),
644 "{id} is missing grammar-options"
645 );
646 assert!(
647 !report
648 .findings
649 .iter()
650 .any(|finding| finding.path == "date-formats"),
651 "{id} is missing date-formats"
652 );
653 }
654 }
655
656 #[test]
661 fn en_us_locale_round_trip_carries_critical_values() {
662 let locale = Locale::en_us();
663
664 assert_eq!(
666 locale.resolved_role_term(&ContributorRole::Translator, false, &TermForm::Short, None),
667 Some("trans.".to_string())
668 );
669
670 assert_eq!(
672 locale.locator_term(&LocatorType::Chapter, false, &TermForm::Short, None),
673 Some("chap.")
674 );
675 assert_eq!(
676 locale.locator_term(&LocatorType::Chapter, true, &TermForm::Short, None),
677 Some("chaps.")
678 );
679
680 assert_eq!(
682 locale.general_term(&GeneralTerm::NoDate, &TermForm::Long, None),
683 Some("no date")
684 );
685 assert_eq!(
686 locale.general_term(&GeneralTerm::NoDate, &TermForm::Short, None),
687 Some("n.d.")
688 );
689
690 assert_eq!(locale.terms.and.as_deref(), Some("and"));
692 assert_eq!(locale.terms.et_al.as_deref(), Some("et al."));
693
694 assert_eq!(
696 locale.dates.months.long.first().map(String::as_str),
697 Some("January")
698 );
699
700 assert_eq!(locale.number_formats.decimal_separator, ".");
702 assert_eq!(locale.number_formats.thousands_separator, ",");
703 assert_eq!(locale.number_formats.minimum_digits, 1);
704 assert_eq!(locale.number_formats.digit_system, DigitSystem::Western);
705
706 assert_eq!(locale.sort_articles, ["the", "a", "an"]);
708 }
709
710 #[test]
711 fn locale_number_formats_accept_each_supported_digit_system() {
712 for (digit_system, expected) in [
713 ("western", DigitSystem::Western),
714 ("arabic-indic", DigitSystem::ArabicIndic),
715 ("extended-arabic-indic", DigitSystem::ExtendedArabicIndic),
716 ("devanagari", DigitSystem::Devanagari),
717 ] {
718 let locale = Locale::from_yaml_str(&format!(
719 "locale: test\nnumber-formats:\n digit-system: {digit_system}\n"
720 ))
721 .expect("locale should parse");
722
723 assert_eq!(locale.number_formats.digit_system, expected);
724 }
725 }
726}