Skip to main content

patois_macros/
lib.rs

1//! Procedural macros for [patois](https://docs.rs/patois).
2//!
3//! Depend on `patois` and use these macros through its re-exports, because their output refers to
4//! items in `patois`.
5
6use 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/// Embeds the compiled catalogs of the calling crate in the binary.
19///
20/// Reads every `<crate_root>/locale/<lang>/LC_MESSAGES/<crate_name>.mo` file at compile time. Use
21/// the macro once, at module level, in the app crate. Generate the files in a build script with
22/// `patois_build::compile_translations`, so that they exist when the macro runs.
23///
24/// The catalogs are written out as Rust statics: one sorted list of msgids that every locale
25/// shares, and for each locale its translations in one string with where each one starts. Nothing
26/// is parsed, checked or copied at runtime, and each msgid is stored once rather than once per
27/// locale. Nothing is compressed either, since the installer or archive compresses the whole
28/// binary, and does it better.
29///
30/// # Panics
31///
32/// Stops compilation with an error if a catalog can't be read or isn't a valid `.mo` file, or if
33/// Cargo didn't set `CARGO_MANIFEST_DIR` and `CARGO_PKG_NAME`.
34#[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
77// The `include_bytes!` is never read, so it adds nothing to the binary. It's there because Cargo
78// rebuilds a crate when an included file changes, and can't see this macro read the catalog.
79fn 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/// Embeds the `wxstd` catalogs of wxWidgets for the languages that the calling crate ships, and
85/// generates a `WxStdCatalogLoader` type that serves them.
86///
87/// wxWidgets has its own translations for stock strings, such as the labels on standard buttons.
88/// The `wxdragon-sys` build puts them under `target/<profile>`, which doesn't exist next to an
89/// installed app, so this macro copies them into the binary. It embeds only the languages under
90/// `<crate_root>/locale/`, so the binary doesn't carry languages that the app doesn't have.
91///
92/// Use the macro once, at module level, in an app crate that depends on `wxdragon` and on `patois`
93/// with the `wx` feature. The crate's build script must call `patois_build::compile_translations`,
94/// which gives this macro the build profile that it needs to find the catalogs.
95///
96/// If the catalogs don't exist, for example because wxWidgets was built without `msgfmt`, the
97/// macro embeds nothing and stock strings stay in English.
98///
99/// # Panics
100///
101/// Stops compilation with an error if a catalog can't be read, or if Cargo didn't set
102/// `CARGO_MANIFEST_DIR`.
103#[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		/// Serves the `wxstd` catalogs that `patois::embed_wx_translations!()` embedded.
114		pub struct WxStdCatalogLoader;
115
116		impl WxStdCatalogLoader {
117			const CATALOGS: &'static [(&'static str, &'static [u8])] = &[ #(#file_entries),* ];
118
119			// wxWidgets asks for the versioned domain, such as `wxstd-3.3`, and then for `wxstd`.
120			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
147// The `include_bytes!` is never read, so it adds nothing to the binary. It's there because Cargo
148// rebuilds a crate when an included file changes, and can't see this macro read the catalog.
149fn 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
177// `OUT_DIR` is `target/<profile>/build/<crate>-<hash>/out`.
178fn 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
189// wxWidgets names a language `pt_BR` where an app may name it `pt_br`, so `app_langs` is lowercase.
190fn 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}