use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, Mutex, OnceLock};
use proc_macro2::TokenStream;
use quote::{quote, quote_spanned};
use syn::parse::{Parse, ParseStream};
use syn::punctuated::Punctuated;
use syn::{Expr, Ident, LitStr, Token};
struct TMacroInput {
locale: Expr,
key: LitStr,
args: Punctuated<KvArg, Token![,]>,
}
struct KvArg {
name: Ident,
value: Expr,
}
impl Parse for KvArg {
fn parse(input: ParseStream) -> syn::Result<Self> {
let name: Ident = input.parse()?;
let _eq: Token![=] = input.parse()?;
let value: Expr = input.parse()?;
Ok(Self { name, value })
}
}
impl Parse for TMacroInput {
fn parse(input: ParseStream) -> syn::Result<Self> {
let locale: Expr = input.parse()?;
let _comma: Token![,] = input.parse()?;
let key: LitStr = input.parse()?;
let args = if input.is_empty() {
Punctuated::new()
} else {
let _comma: Token![,] = input.parse()?;
Punctuated::parse_terminated(input)?
};
Ok(Self { locale, key, args })
}
}
pub fn t_macro(input: TokenStream) -> TokenStream {
let parsed = match syn::parse2::<TMacroInput>(input) {
Ok(p) => p,
Err(err) => return err.to_compile_error(),
};
if let Some(err) = validate_key(&parsed.key) {
return err;
}
let TMacroInput { locale, key, args } = parsed;
if args.is_empty() {
quote! {
::autumn_web::i18n::Locale::t(&#locale, #key)
}
} else {
let arg_pairs = args.iter().map(|KvArg { name, value }| {
let name_str = name.to_string();
quote! { (#name_str, #value) }
});
quote! {{
let __autumn_i18n_args: &[(&str, &str)] = &[ #( #arg_pairs ),* ];
::autumn_web::i18n::Locale::t_with(&#locale, #key, __autumn_i18n_args)
}}
}
}
fn validate_key(key_lit: &LitStr) -> Option<TokenStream> {
let bundle = match load_default_bundle() {
BundleLookup::Loaded(map) => map,
BundleLookup::NoFile => return None,
};
let key = key_lit.value();
if bundle.contains_key(&key) {
return None;
}
let suggestion = closest_key(&key, bundle.keys()).map_or_else(String::new, |closest| {
format!("\n hint: did you mean `{closest}`?")
});
let msg = format!("i18n key `{key}` is not defined in the default locale bundle{suggestion}");
Some(quote_spanned! { key_lit.span() =>
compile_error!(#msg)
})
}
enum BundleLookup {
Loaded(Arc<HashMap<String, String>>),
NoFile,
}
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
struct BundleLookupInputs {
explicit_file: Option<PathBuf>,
manifest_dir: Option<PathBuf>,
default_locale: String,
}
impl BundleLookupInputs {
fn from_env() -> Self {
Self {
explicit_file: std::env::var_os("AUTUMN_I18N_FILE").map(PathBuf::from),
manifest_dir: std::env::var_os("CARGO_MANIFEST_DIR").map(PathBuf::from),
default_locale: std::env::var("AUTUMN_I18N_DEFAULT_LOCALE")
.unwrap_or_else(|_| "en".to_owned()),
}
}
}
type BundleCache = HashMap<BundleLookupInputs, Option<Arc<HashMap<String, String>>>>;
fn load_default_bundle() -> BundleLookup {
load_default_bundle_for_inputs(&BundleLookupInputs::from_env())
}
fn load_default_bundle_for_inputs(inputs: &BundleLookupInputs) -> BundleLookup {
static CACHE: OnceLock<Mutex<BundleCache>> = OnceLock::new();
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
let cached = {
let mut cache = cache
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
cache
.entry(inputs.clone())
.or_insert_with(|| read_and_parse_default_bundle(inputs).map(Arc::new))
.clone()
};
cached.map_or(BundleLookup::NoFile, BundleLookup::Loaded)
}
fn read_and_parse_default_bundle(inputs: &BundleLookupInputs) -> Option<HashMap<String, String>> {
let path = locate_default_bundle(inputs)?;
let raw = std::fs::read_to_string(&path).ok()?;
Some(parse_keys(&raw))
}
fn locate_default_bundle(inputs: &BundleLookupInputs) -> Option<PathBuf> {
if let Some(path) = &inputs.explicit_file
&& path.is_file()
{
return Some(path.clone());
}
let manifest = inputs.manifest_dir.as_ref()?;
let candidate = manifest
.join("i18n")
.join(format!("{}.ftl", inputs.default_locale));
if candidate.is_file() {
Some(candidate)
} else {
None
}
}
fn parse_keys(src: &str) -> HashMap<String, String> {
let mut keys = HashMap::new();
for line in src.lines() {
let trimmed = line.trim_start();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if line.starts_with(' ') || line.starts_with('\t') {
continue;
}
if let Some((raw_key, _)) = trimmed.split_once('=') {
let key = raw_key.trim();
if !key.is_empty() {
keys.insert(key.to_owned(), String::new());
}
}
}
keys
}
fn closest_key<'a, I>(target: &str, candidates: I) -> Option<&'a str>
where
I: IntoIterator<Item = &'a String>,
{
let mut best: Option<(&'a str, usize)> = None;
for cand in candidates {
let d = levenshtein(target, cand);
if d <= 3 && best.is_none_or(|(_, current)| d < current) {
best = Some((cand.as_str(), d));
}
}
best.map(|(s, _)| s)
}
fn levenshtein(a: &str, b: &str) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
let mut prev: Vec<usize> = (0..=b.len()).collect();
let mut curr = vec![0usize; b.len() + 1];
for (i, ac) in a.iter().enumerate() {
curr[0] = i + 1;
for (j, bc) in b.iter().enumerate() {
let cost = usize::from(ac != bc);
curr[j + 1] = (curr[j] + 1).min(prev[j + 1] + 1).min(prev[j] + cost);
}
std::mem::swap(&mut prev, &mut curr);
}
prev[b.len()]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_keys_extracts_basic_entries() {
let src = "# comment\nfoo = bar\nbaz = qux\n";
let keys = parse_keys(src);
assert!(keys.contains_key("foo"));
assert!(keys.contains_key("baz"));
}
#[test]
fn parse_keys_skips_continuation_lines() {
let src = "long = first\n continued\n more\nshort = ok\n";
let keys = parse_keys(src);
assert!(keys.contains_key("long"));
assert!(keys.contains_key("short"));
assert!(!keys.contains_key("continued"));
}
#[test]
fn levenshtein_basic() {
assert_eq!(levenshtein("abc", "abc"), 0);
assert_eq!(levenshtein("abc", "abd"), 1);
assert_eq!(levenshtein("abc", "ac"), 1);
assert_eq!(levenshtein("welcome.tite", "welcome.title"), 1);
}
#[test]
fn closest_key_finds_typo() {
let candidates = ["welcome.title".to_owned(), "welcome.greeting".to_owned()];
let got = closest_key("welcome.tite", candidates.iter());
assert_eq!(got, Some("welcome.title"));
}
#[test]
fn closest_key_returns_none_when_nothing_close() {
let candidates = ["hi".to_owned()];
let got = closest_key("completely.unrelated.key", candidates.iter());
assert!(got.is_none());
}
#[test]
fn default_bundle_cache_is_scoped_to_lookup_inputs() {
let root =
std::env::temp_dir().join(format!("autumn-macros-i18n-cache-{}", std::process::id()));
let first = root.join("first");
let second = root.join("second");
std::fs::create_dir_all(first.join("i18n")).expect("first i18n dir");
std::fs::create_dir_all(second.join("i18n")).expect("second i18n dir");
std::fs::write(first.join("i18n/en.ftl"), "first.only = First\n").expect("first ftl");
std::fs::write(second.join("i18n/en.ftl"), "second.only = Second\n").expect("second ftl");
let first_lookup = load_default_bundle_for_inputs(&BundleLookupInputs {
explicit_file: None,
manifest_dir: Some(first),
default_locale: "en".to_owned(),
});
let second_lookup = load_default_bundle_for_inputs(&BundleLookupInputs {
explicit_file: None,
manifest_dir: Some(second),
default_locale: "en".to_owned(),
});
let BundleLookup::Loaded(first_bundle) = first_lookup else {
panic!("first bundle should load");
};
let BundleLookup::Loaded(second_bundle) = second_lookup else {
panic!("second bundle should load");
};
assert!(first_bundle.contains_key("first.only"));
assert!(!first_bundle.contains_key("second.only"));
assert!(second_bundle.contains_key("second.only"));
assert!(!second_bundle.contains_key("first.only"));
let _ = std::fs::remove_dir_all(root);
}
}