use std::collections::BTreeSet;
use std::fmt::Write as _;
use std::path::Path;
struct Message {
key: String,
text: String,
params: Vec<(String, bool)>,
}
fn messages(src: &str) -> Vec<Message> {
let mut out: Vec<Message> = Vec::new();
for line in src.lines() {
if line.starts_with('#') || line.trim().is_empty() {
continue;
}
if line.starts_with(char::is_whitespace) {
if let Some(m) = out.last_mut() {
collect_params(line, &mut m.params);
}
continue;
}
let Some((key, text)) = line.split_once('=') else {
continue;
};
let mut m = Message {
key: key.trim().to_string(),
text: text.trim().to_string(),
params: Vec::new(),
};
collect_params(text, &mut m.params);
out.push(m);
}
out
}
fn collect_params(text: &str, params: &mut Vec<(String, bool)>) {
let mut rest = text;
while let Some(i) = rest.find('$') {
rest = &rest[i + 1..];
let name: String = rest
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
.collect();
let numeric = rest[name.len()..].trim_start().starts_with("->");
match params.iter_mut().find(|(n, _)| *n == name) {
Some(p) => p.1 |= numeric,
None if !name.is_empty() => params.push((name, numeric)),
None => {}
}
}
}
fn is_ident(s: &str) -> bool {
let mut chars = s.chars();
matches!(chars.next(), Some(c) if c.is_ascii_lowercase() || c == '_')
&& chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
}
fn main() {
let dir = Path::new("catalog");
println!("cargo::rerun-if-changed=catalog");
let read = |file: &str| {
std::fs::read_to_string(dir.join(file))
.unwrap_or_else(|e| panic!("day-localization: reading catalog/{file}: {e}"))
};
let english = messages(&read("en.ftl"));
let mut errors = Vec::new();
for m in &english {
if !is_ident(&m.key) {
errors.push(format!(
"catalog/en.ftl: key {:?} is not a Rust identifier; name it {:?}",
m.key,
m.key.replace('-', "_")
));
}
for (p, _) in &m.params {
if !is_ident(p) {
errors.push(format!(
"catalog/en.ftl: {} takes ${p}, which is not a Rust identifier",
m.key
));
}
}
}
let known: BTreeSet<&str> = english.iter().map(|m| m.key.as_str()).collect();
let mut locales: Vec<_> = std::fs::read_dir(dir)
.unwrap_or_else(|e| panic!("day-localization: listing catalog/: {e}"))
.filter_map(|e| e.ok()?.file_name().into_string().ok())
.filter(|f| f.ends_with(".ftl") && f != "en.ftl")
.collect();
locales.sort();
for file in &locales {
for m in messages(&read(file)) {
if !known.contains(m.key.as_str()) {
errors.push(format!(
"catalog/{file}: {:?} is not in catalog/en.ftl, so no accessor names it",
m.key
));
}
}
}
if !errors.is_empty() {
panic!(
"day-localization: the core catalog is invalid:\n {}",
errors.join("\n ")
);
}
let mut keys = String::new();
let mut fns = String::new();
for m in &english {
let doc = format!("`{}` — `{}`", m.key, m.text);
let _ = writeln!(
keys,
" /// {doc}\n pub const {k}: &str = {k:?};",
k = m.key
);
let generics: Vec<String> = (0..m.params.len()).map(|i| format!("M{i}")).collect();
let args: Vec<String> = m
.params
.iter()
.enumerate()
.map(|(i, (p, numeric))| {
let ty = if *numeric {
"IntoNumberFArg"
} else {
"IntoFArg"
};
format!("{p}: impl $crate::{ty}<M{i}>")
})
.collect();
let mut body = format!("tr($crate::key::{})", m.key);
for (p, _) in &m.params {
let _ = write!(body, ".arg({p:?}, {p})");
}
let generics = if generics.is_empty() {
String::new()
} else {
format!("<{}>", generics.join(", "))
};
let _ = writeln!(
fns,
" /// {doc}\n pub fn {}{generics}({}) -> LocalizedText {{ {body} }}",
m.key,
args.join(", ")
);
}
let code = format!(
"/// The core catalog's keys, one constant per message, named as the key itself\n\
/// (`key::day_ok` is `\"day_ok\"`): what the framework's own crates pass to [`t`].\n\
/// Generated from `catalog/en.ftl` by this crate's build script.\n\
#[allow(non_upper_case_globals)]\n\
pub mod key {{\n{keys}}}\n\n\
/// The typed accessors for the core catalog, one `pub fn` per message with its arguments\n\
/// in the signature. day-localization-ui expands this as `res::str`, in a module that has\n\
/// `LocalizedText` and `tr` in scope.\n\
#[doc(hidden)]\n\
#[macro_export]\n\
macro_rules! __core_strings {{\n () => {{\n{fns} }};\n}}\n"
);
let out =
Path::new(&std::env::var("OUT_DIR").expect("cargo sets OUT_DIR")).join("core_strings.rs");
std::fs::write(&out, code)
.unwrap_or_else(|e| panic!("day-localization: writing {}: {e}", out.display()));
}