//! Catalog loader and `{name}` substitution (ADR-0019 §8.4). //! //! The catalog is an `include_str!`-embedded YAML file parsed //! once on first access. Hierarchical keys flatten to //! dot-separated paths so lookups are a single `HashMap` hit. //! //! Substitution rejects format specifiers (`{name:…}`), //! unknown / missing names, unterminated `{`, and stray `}`. //! `{{` and `}}` escape to literal `{` / `}`. Everything that //! ends in a `panic!` should have been caught by the validator //! unit test (Step 7) at build time. use std::collections::HashMap; use std::fmt::{Display, Write}; use std::sync::OnceLock; const EN_US: &str = include_str!("strings/en-US.yaml"); /// Loaded catalog: a flat map from dot-separated key to template. pub struct Catalog { entries: HashMap, } impl Catalog { fn load() -> Self { let value: serde_yml::Value = serde_yml::from_str(EN_US) .expect("embedded en-US.yaml must parse (ADR-0019 §8.6 startup check)"); let mut entries = HashMap::new(); flatten(&value, String::new(), &mut entries); Self { entries } } /// Look up a flat dot-separated key. Returns the template /// string with placeholders un-substituted. #[must_use] pub fn get(&self, key: &str) -> Option<&str> { self.entries.get(key).map(String::as_str) } /// Iterate every catalog key; used by the validator test. pub fn keys(&self) -> impl Iterator { self.entries.keys().map(String::as_str) } } fn flatten( value: &serde_yml::Value, prefix: String, out: &mut HashMap, ) { match value { serde_yml::Value::Mapping(map) => { for (k, v) in map { let k_str = k .as_str() .expect("catalog keys must be strings"); let next = if prefix.is_empty() { k_str.to_string() } else { format!("{prefix}.{k_str}") }; flatten(v, next, out); } } serde_yml::Value::String(s) => { out.insert(prefix, s.clone()); } // Empty top-level (Null) is fine — an empty catalog // loads as no entries. Anything else is a structure // error worth panicking over since the catalog is // shipped with the binary. serde_yml::Value::Null if prefix.is_empty() => {} other => panic!("catalog value at `{prefix}` is not a string: {other:?}"), } } /// Singleton catalog access. Loads on first call; subsequent /// calls are a `HashMap` lookup away. pub fn catalog() -> &'static Catalog { static C: OnceLock = OnceLock::new(); C.get_or_init(Catalog::load) } /// Look up `key` and substitute the supplied named arguments. /// See module docs for failure modes. pub fn translate(key: &str, args: &[(&str, &dyn Display)]) -> String { let template = catalog().get(key).unwrap_or_else(|| { panic!( "missing catalog key: `{key}` (the validator should have caught this)" ); }); substitute(template, args, key) } fn substitute(template: &str, args: &[(&str, &dyn Display)], key: &str) -> String { let mut out = String::with_capacity(template.len()); let mut chars = template.chars().peekable(); while let Some(c) = chars.next() { match c { '{' => { if chars.peek() == Some(&'{') { chars.next(); out.push('{'); continue; } let mut name = String::new(); let mut closed = false; while let Some(&nc) = chars.peek() { match nc { '}' => { chars.next(); closed = true; break; } ':' => { panic!( "catalog key `{key}` uses a format specifier in \ `{{{name}:...}}` — specifiers are not allowed \ (ADR-0019 §8.4)" ); } _ => { chars.next(); name.push(nc); } } } if !closed { panic!("catalog key `{key}` has unterminated `{{`"); } if name.is_empty() { panic!("catalog key `{key}` has empty `{{}}` placeholder"); } let value = args .iter() .find(|(n, _)| *n == name) .map(|(_, v)| v) .unwrap_or_else(|| { panic!( "catalog key `{key}` references `{{{name}}}` but that \ argument was not supplied" ); }); write!(out, "{value}").expect("writing to String never fails"); } '}' => { if chars.peek() == Some(&'}') { chars.next(); out.push('}'); } else { panic!("catalog key `{key}` has stray `}}`"); } } _ => out.push(c), } } out } #[cfg(test)] mod tests { use super::*; #[test] fn catalog_loads_at_least_the_test_entry() { // Step 1 ships a single sanity entry under `_test.*`; // its presence proves the loader walks the YAML and // flattens hierarchical groups correctly. assert!( catalog().get("_test.hello").is_some(), "expected `_test.hello` in catalog; entries: {:?}", catalog().keys().collect::>() ); } #[test] fn substitute_replaces_named_placeholder() { let s = substitute("Hello, {name}!", &[("name", &"World")], "_test.hello"); assert_eq!(s, "Hello, World!"); } #[test] fn substitute_handles_multiple_placeholders() { let s = substitute( "{a} + {b} = {sum}", &[("a", &1), ("b", &2), ("sum", &3)], "_test.math", ); assert_eq!(s, "1 + 2 = 3"); } #[test] fn substitute_supports_doubled_braces_as_literals() { let s = substitute( "set with {{count}} = {count}", &[("count", &5)], "_test.literals", ); assert_eq!(s, "set with {count} = 5"); } #[test] fn substitute_passes_display_values_unchanged() { // Display for an i64 — no padding, no thousands separator. // ADR-0019 §8.7: value formats stay invariant. let s = substitute("n = {n}", &[("n", &1234567_i64)], "_test.display"); assert_eq!(s, "n = 1234567"); } #[test] #[should_panic(expected = "format specifier")] #[allow(clippy::literal_string_with_formatting_args)] fn substitute_rejects_format_specifier() { // The literal `{n:08}` is intentional — that's the // shape we want to refuse. let _ = substitute("padded: {n:08}", &[("n", &5)], "_test.specifier"); } #[test] #[should_panic(expected = "not supplied")] fn substitute_rejects_unknown_placeholder() { let _ = substitute("Hello, {who}!", &[("name", &"World")], "_test.unknown"); } #[test] #[should_panic(expected = "unterminated")] fn substitute_rejects_unterminated_brace() { let _ = substitute("oh no {", &[], "_test.unterm"); } #[test] #[should_panic(expected = "stray")] fn substitute_rejects_stray_close_brace() { let _ = substitute("oh no }", &[], "_test.stray"); } #[test] #[should_panic(expected = "empty")] fn substitute_rejects_empty_placeholder() { let _ = substitute("oh no {}", &[], "_test.empty"); } #[test] fn t_macro_dispatches_to_translate() { // Sanity check that the macro produces the same output // as a direct call. Uses the `_test.hello` entry from // the catalog. let m = crate::t!("_test.hello", name = "World"); let d = translate("_test.hello", &[("name", &"World")]); assert_eq!(m, d); } }