1use std::{
7 collections::HashSet,
8 env, fs,
9 path::{Path, PathBuf},
10};
11
12use proc_macro::TokenStream;
13use proc_macro2::{Literal, TokenStream as TokenStream2};
14use quote::quote;
15
16mod bake;
17
18#[proc_macro]
35pub fn embed_domain(_input: TokenStream) -> TokenStream {
36 let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set during macro expansion");
37 let pkg_name = env::var("CARGO_PKG_NAME").expect("CARGO_PKG_NAME not set during macro expansion");
38 let locale_dir = Path::new(&manifest_dir).join("locale");
39 let mut catalogs = vec![];
40 let mut tracked = vec![];
41 if locale_dir.exists()
42 && let Ok(entries) = fs::read_dir(&locale_dir)
43 {
44 let mut locales: Vec<_> = entries.flatten().collect();
45 locales.sort_by_key(std::fs::DirEntry::file_name);
46 for entry in locales {
47 let locale_name = entry.file_name().to_string_lossy().to_string();
48 let mo_path = entry.path().join("LC_MESSAGES").join(format!("{pkg_name}.mo"));
49 if mo_path.exists() {
50 let bytes = fs::read(&mo_path).unwrap_or_else(|e| panic!("couldn't read {}: {e}", mo_path.display()));
51 catalogs.push((locale_name, bytes));
52 tracked.push(rebuild_when_changed(&mo_path));
53 }
54 }
55 }
56 let baked = bake::bake(&catalogs).unwrap_or_else(|e| panic!("{e}"));
57 let msgids = &baked.msgids;
58 let locales = baked.locales.iter().map(|locale| {
59 let (code, header, text, starts) = (&locale.code, &locale.header, &locale.text, &locale.starts);
60 quote! {
61 ::patois::EmbeddedLocale { code: #code, header: #header, text: #text, starts: &[ #(#starts),* ] }
62 }
63 });
64 quote! {
65 #(#tracked)*
66 ::patois::inventory::submit! {
67 ::patois::EmbeddedDomain {
68 name: env!("CARGO_PKG_NAME"),
69 msgids: &[ #(#msgids),* ],
70 locales: &[ #(#locales),* ],
71 }
72 }
73 }
74 .into()
75}
76
77fn rebuild_when_changed(path: &Path) -> TokenStream2 {
80 let path_str = path.to_string_lossy().to_string();
81 quote! { const _: &[u8] = include_bytes!(#path_str); }
82}
83
84#[proc_macro]
104pub fn embed_wx_translations(_input: TokenStream) -> TokenStream {
105 let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set during macro expansion");
106 let app_langs = shipped_languages(Path::new(&manifest_dir));
107 let catalogs = wx_locale_dir().map_or_else(Vec::new, |locale_dir| matching_wx_catalogs(&locale_dir, &app_langs));
108 let file_entries = catalogs.iter().map(|(lang, mo_path)| {
109 let catalog = embedded_catalog(mo_path);
110 quote! { (#lang, #catalog) }
111 });
112 quote! {
113 pub struct WxStdCatalogLoader;
115
116 impl WxStdCatalogLoader {
117 const CATALOGS: &'static [(&'static str, &'static [u8])] = &[ #(#file_entries),* ];
118
119 fn is_wxstd_domain(domain: &str) -> bool {
121 domain == "wxstd" || domain.starts_with("wxstd-")
122 }
123 }
124
125 impl ::wxdragon::translations::TranslationsLoader for WxStdCatalogLoader {
126 fn load_catalog(&self, domain: &str, lang: &str) -> ::std::option::Option<::std::borrow::Cow<'_, [u8]>> {
127 if !Self::is_wxstd_domain(domain) {
128 return None;
129 }
130 Self::CATALOGS
131 .iter()
132 .find(|(l, _)| l.eq_ignore_ascii_case(lang))
133 .map(|&(_, bytes)| ::std::borrow::Cow::Borrowed(bytes))
134 }
135
136 fn available_translations(&self, domain: &str) -> ::std::vec::Vec<::std::string::String> {
137 if !Self::is_wxstd_domain(domain) {
138 return ::std::vec::Vec::new();
139 }
140 Self::CATALOGS.iter().map(|(l, _)| l.to_string()).collect()
141 }
142 }
143 }
144 .into()
145}
146
147fn embedded_catalog(path: &Path) -> TokenStream2 {
150 let path_str = path.to_string_lossy().to_string();
151 let bytes = fs::read(path).unwrap_or_else(|e| panic!("couldn't read {}: {e}", path.display()));
152 let bytes = Literal::byte_string(&bytes);
153 quote! {{
154 const _: &[u8] = include_bytes!(#path_str);
155 const B: &[u8] = #bytes;
156 B
157 }}
158}
159
160fn shipped_languages(manifest_dir: &Path) -> HashSet<String> {
161 let locale_dir = manifest_dir.join("locale");
162 let mut langs = HashSet::new();
163 if let Ok(entries) = fs::read_dir(&locale_dir) {
164 for entry in entries.flatten() {
165 if entry.path().is_dir() {
166 langs.insert(entry.file_name().to_string_lossy().to_lowercase());
167 }
168 }
169 }
170 langs
171}
172
173fn wx_locale_dir() -> Option<PathBuf> {
174 Some(target_profile_dir()?.join("wxdragon_sys_cmake_build").join("share").join("locale"))
175}
176
177fn target_profile_dir() -> Option<PathBuf> {
179 let profile = env::var("PROFILE").ok()?;
180 if let Ok(target_dir) = env::var("CARGO_TARGET_DIR") {
181 let mut dir = PathBuf::from(target_dir);
182 dir.push(profile);
183 return Some(dir);
184 }
185 let out_dir = PathBuf::from(env::var("OUT_DIR").ok()?);
186 out_dir.ancestors().nth(3).map(Path::to_path_buf)
187}
188
189fn matching_wx_catalogs(locale_dir: &Path, app_langs: &HashSet<String>) -> Vec<(String, PathBuf)> {
191 let Ok(entries) = fs::read_dir(locale_dir) else {
192 return Vec::new();
193 };
194 let mut lang_entries: Vec<_> = entries.flatten().collect();
195 lang_entries.sort_by_key(std::fs::DirEntry::file_name);
196 let mut catalogs = Vec::new();
197 for entry in lang_entries {
198 let lang = entry.file_name().to_string_lossy().to_string();
199 if !app_langs.contains(&lang.to_lowercase()) {
200 continue;
201 }
202 let lc_messages = entry.path().join("LC_MESSAGES");
203 let Ok(files) = fs::read_dir(&lc_messages) else { continue };
204 let mut mo_files: Vec<PathBuf> = files
205 .flatten()
206 .map(|f| f.path())
207 .filter(|p| {
208 p.extension().and_then(|e| e.to_str()) == Some("mo")
209 && p.file_stem().and_then(|s| s.to_str()).is_some_and(|s| s.starts_with("wxstd"))
210 })
211 .collect();
212 mo_files.sort();
213 if let Some(mo_path) = mo_files.into_iter().next() {
214 catalogs.push((lang, mo_path));
215 }
216 }
217 catalogs
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223
224 struct ScratchDir(PathBuf);
225
226 impl ScratchDir {
227 fn new(name: &str) -> Self {
228 let path = env::temp_dir().join(format!("patois_macros_test_{name}_{}", std::process::id()));
229 let _ = fs::remove_dir_all(&path);
230 fs::create_dir_all(&path).expect("create scratch dir");
231 Self(path)
232 }
233
234 fn path(&self) -> &Path {
235 &self.0
236 }
237 }
238
239 impl Drop for ScratchDir {
240 fn drop(&mut self) {
241 let _ = fs::remove_dir_all(&self.0);
242 }
243 }
244
245 fn write_mo(dir: &Path, lang: &str, filename: &str) {
246 let lc_messages = dir.join(lang).join("LC_MESSAGES");
247 fs::create_dir_all(&lc_messages).expect("create LC_MESSAGES dir");
248 fs::write(lc_messages.join(filename), b"fake mo bytes").expect("write fake .mo file");
249 }
250
251 #[test]
252 fn shipped_languages_lists_locale_subdirectories() {
253 let scratch = ScratchDir::new("shipped_languages");
254 let locale_dir = scratch.path().join("locale");
255 fs::create_dir_all(locale_dir.join("de")).unwrap();
256 fs::create_dir_all(locale_dir.join("pt_BR")).unwrap();
257 fs::write(locale_dir.join("README.md"), b"not a language").unwrap();
258 let langs = shipped_languages(scratch.path());
259 assert_eq!(langs, HashSet::from(["de".to_string(), "pt_br".to_string()]));
260 }
261
262 #[test]
263 fn shipped_languages_empty_when_locale_dir_missing() {
264 let scratch = ScratchDir::new("shipped_languages_missing");
265 assert!(shipped_languages(scratch.path()).is_empty());
266 }
267
268 #[test]
269 fn matching_wx_catalogs_filters_by_app_language_case_insensitively() {
270 let scratch = ScratchDir::new("matching_catalogs");
271 write_mo(scratch.path(), "de", "wxstd-3.3.mo");
272 write_mo(scratch.path(), "pt_BR", "wxstd-3.3.mo");
273 write_mo(scratch.path(), "af", "wxstd-3.3.mo");
274 let app_langs = HashSet::from(["de".to_string(), "pt_br".to_string()]);
275 let mut catalogs = matching_wx_catalogs(scratch.path(), &app_langs);
276 catalogs.sort();
277 assert_eq!(catalogs.len(), 2);
278 assert_eq!(catalogs[0].0, "de");
279 assert_eq!(catalogs[1].0, "pt_BR");
280 }
281
282 #[test]
283 fn matching_wx_catalogs_picks_lowest_sorted_mo_when_multiple_present() {
284 let scratch = ScratchDir::new("matching_catalogs_multi");
285 write_mo(scratch.path(), "de", "wxstd-3.3.mo");
286 write_mo(scratch.path(), "de", "wxstd-3.2.mo");
287 let app_langs = HashSet::from(["de".to_string()]);
288 let catalogs = matching_wx_catalogs(scratch.path(), &app_langs);
289 assert_eq!(catalogs.len(), 1);
290 assert!(catalogs[0].1.ends_with("wxstd-3.2.mo"));
291 }
292
293 #[test]
294 fn matching_wx_catalogs_ignores_non_wxstd_mo_files() {
295 let scratch = ScratchDir::new("matching_catalogs_ignore");
296 write_mo(scratch.path(), "de", "somethingelse.mo");
297 let app_langs = HashSet::from(["de".to_string()]);
298 assert_eq!(matching_wx_catalogs(scratch.path(), &app_langs), [] as [(String, PathBuf); 0]);
299 }
300
301 #[test]
302 fn matching_wx_catalogs_empty_when_locale_dir_missing() {
303 let scratch = ScratchDir::new("matching_catalogs_missing");
304 let missing = scratch.path().join("does_not_exist");
305 let app_langs = HashSet::from(["de".to_string()]);
306 assert_eq!(matching_wx_catalogs(&missing, &app_langs), [] as [(String, PathBuf); 0]);
307 }
308}