1#![cfg_attr(feature = "nightly-features", feature(track_path))]
2use std::{
3 collections::{BTreeMap, HashMap},
4 fs::File,
5 io::BufReader,
6 path::{Path, PathBuf},
7};
8
9use oxilangtag::LanguageTag;
10use proc_macro2::Ident;
11use quote::{format_ident, quote};
12use serde::Deserialize;
13use walkdir::WalkDir;
14
15struct PathStr(String);
16
17impl syn::parse::Parse for PathStr {
18 fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
19 input.parse::<syn::LitStr>().map(|v| Self(v.value()))
20 }
21}
22
23#[derive(Debug, Default, Deserialize)]
24struct Config {
25 fallback: HashMap<String, String>,
26}
27
28struct LocaleExtra {
29 name: String,
30 ident: Ident,
31 translations: HashMap<String, String>,
32}
33
34type LocaleModel = BTreeMap<
35 String, LocaleExtra,
37>;
38type TranslationModel = HashMap<
39 String, LocaleModel,
41>;
42
43#[proc_macro]
52pub fn init(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
53 let path = match syn::parse::<PathStr>(input) {
54 Ok(dir) => {
55 let p = std::env::var("CARGO_MANIFEST_DIR")
56 .map(|p| PathBuf::from(p).join(dir.0))
57 .expect("CARGO_MANIFEST_DIR doesn't exist");
58
59 match p.is_dir() {
60 true => p,
61 false => panic!("{} is not a directory.", p.display()),
62 }
63 }
64 Err(e) => return e.to_compile_error().into(),
65 };
66
67 let (config, model) = scan_locales(path);
68 let locales = generate_locales(&model);
69 let locale_helpers = generate_helpers(&config, &model);
70
71 quote! {
72 #[doc(hidden)]
73 pub(crate) mod __r18_gen {
74 #locales
75 #locale_helpers
76 }
77 }
78 .into()
79}
80
81fn scan_locales(path: impl AsRef<Path>) -> (Config, TranslationModel) {
82 let mut model = TranslationModel::new();
83 let mut config = Config::default();
84
85 WalkDir::new(path)
86 .into_iter()
87 .filter_map(|p| {
88 let path = p.ok()?;
89 let mut parts = path.path().file_name()?.to_str()?.split('.').rev();
90
91 let language = match (parts.next(), parts.next(), parts.next()) {
92 (Some("json"), Some("config"), None) => {
93 config = load_config(path.path());
94 None
95 }
96 (Some("json"), Some(tag), None) => LanguageTag::parse_and_normalize(tag).ok(),
97 _ => None,
98 }?;
99
100 Some((path, language))
101 })
102 .for_each(|(path, language)| {
103 #[cfg(feature = "nightly-features")]
104 proc_macro::tracked_path::path(path.path().to_str().unwrap_or_default());
105
106 let region = language.region().unwrap_or_default().to_string();
107 let name = region
108 .is_empty()
109 .then_some(language.primary_language().into())
110 .unwrap_or_else(|| format!("{}-{}", language.primary_language(), region));
111
112 let extra = LocaleExtra {
113 ident: format_ident!("{}", name.replace('-', "_").to_uppercase()),
114 name,
115 translations: r18_trans_support::translation::extract(path.path()).unwrap(),
116 };
117
118 model
119 .entry(language.primary_language().into())
120 .or_default()
121 .insert(region, extra);
122 });
123
124 (config, model)
125}
126
127fn load_config(path: impl AsRef<Path>) -> Config {
128 File::open(path)
129 .ok()
130 .and_then(|f| serde_json::from_reader(BufReader::new(f)).ok())
131 .unwrap_or_default()
132}
133
134fn generate_primary(locales: &LocaleModel) -> proc_macro2::TokenStream {
135 locales
136 .iter()
137 .map(|(_, extra)| {
138 let code = &extra.ident;
139 let name = &extra.name;
140 let translation = extra.translations.iter().map(|(k, v)| quote!( #k => #v ));
141
142 quote! {
143 #[doc(hidden)]
144 const #code: ::r18::Locale = ::r18::Locale {
145 name: #name,
146 translate: {
147 use ::r18::phf;
148 phf::phf_map! {
149 #( #translation ),*
150 }
151 }
152 };
153 }
154 })
155 .collect()
156}
157
158fn generate_locales(model: &TranslationModel) -> proc_macro2::TokenStream {
159 model
160 .iter()
161 .map(|(_, locales)| generate_primary(locales))
162 .collect()
163}
164
165fn generate_lang_matches(
166 config: &Config,
167 primary: &String,
168 locales: &LocaleModel,
169) -> proc_macro2::TokenStream {
170 let exact_matches =
171 locales
172 .iter()
173 .filter(|(region, _)| !region.is_empty())
174 .map(|(region, extra)| {
175 let ident = &extra.ident;
176 quote! { (#primary, Some(#region)) => Some(&#ident) , }
177 });
178
179 if let Some((_, extra)) = locales.first_key_value() {
180 let fallback_region = config
181 .fallback
182 .get(primary)
183 .and_then(|fallback| {
184 LanguageTag::parse_and_normalize(fallback)
185 .ok()?
186 .region()
187 .map(|r| r.to_string())
188 })
189 .unwrap_or_default();
190
191 let fallback_match = locales
192 .get(&fallback_region)
193 .map(|extra| {
194 let ident = &extra.ident;
195 quote! { (#primary, _) => Some(&#ident) , }
196 })
197 .unwrap_or_else(|| {
198 let ident = &extra.ident;
199 quote! { (#primary, _) => Some(&#ident) , }
200 });
201
202 exact_matches.chain([fallback_match]).collect()
203 } else {
204 quote!()
205 }
206}
207
208fn generate_helpers(config: &Config, model: &TranslationModel) -> proc_macro2::TokenStream {
209 let matches = model
210 .iter()
211 .map(|(primary, locales)| generate_lang_matches(config, primary, locales))
212 .collect::<proc_macro2::TokenStream>();
213
214 quote! {
215 #[doc(hidden)]
216 pub(crate) fn set_locale(locale: impl AsRef<str>) {
217 *::r18::CURRENT_LOCALE
218 .get_or_init(|| ::std::sync::Mutex::new(None))
219 .lock()
220 .unwrap() = match ::r18::LanguageTag::parse_and_normalize(locale.as_ref()) {
221 Ok(lang) => {
222 match (lang.primary_language(), lang.region()) {
223 #matches
224 _ => None,
225 }
226 }
227 Err(_) => None,
228 };
229 }
230 }
231}