41b7e9a049
One-time, mechanical reformat — no functional changes. The tree was not rustfmt-clean (~1800 hunks across ~100 files); this brings it to stock `cargo fmt` defaults so a `cargo fmt --check` CI gate can follow. Behaviour-preserving: 2509 pass / 0 fail / 1 ignored (unchanged baseline), clippy clean. A .git-blame-ignore-revs entry follows so `git blame` skips this commit.
245 lines
8.2 KiB
Rust
245 lines
8.2 KiB
Rust
//! 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<String, String>,
|
|
}
|
|
|
|
impl Catalog {
|
|
fn load() -> Self {
|
|
let value: serde_norway::Value = serde_norway::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<Item = &str> {
|
|
self.entries.keys().map(String::as_str)
|
|
}
|
|
}
|
|
|
|
fn flatten(value: &serde_norway::Value, prefix: String, out: &mut HashMap<String, String>) {
|
|
match value {
|
|
serde_norway::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_norway::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_norway::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<Catalog> = 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::<Vec<_>>()
|
|
);
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
}
|