1use std::{
2 collections::BTreeSet,
3 env, fs,
4 io::Write as _,
5 path::{Path, PathBuf},
6};
7
8use fluent_syntax::parser;
9use unic_langid::LanguageIdentifier;
10
11pub struct FluentZeroBuilder {
13 locales_dir: PathBuf,
14 charset_dest: Option<PathBuf>,
15}
16
17impl Default for FluentZeroBuilder {
18 fn default() -> Self {
19 Self {
20 locales_dir: PathBuf::from("assets/locales"),
21 charset_dest: None,
22 }
23 }
24}
25
26impl FluentZeroBuilder {
27 #[must_use]
33 pub fn new<P: AsRef<Path>>(locales_dir_path: P) -> Self {
34 Self {
35 locales_dir: locales_dir_path.as_ref().to_path_buf(),
36 charset_dest: None,
37 }
38 }
39
40 #[must_use]
46 pub fn export_charset<P: AsRef<Path>>(mut self, dest: P) -> Self {
47 self.charset_dest = Some(dest.as_ref().to_path_buf());
48 self
49 }
50
51 pub fn generate(self) {
59 let out_dir = env::var("OUT_DIR").expect("OUT_DIR not set");
60 let dest_path = Path::new(&out_dir).join("static_cache.rs");
61 let mut file = fs::File::create(&dest_path).expect("Failed to create static_cache.rs");
62
63 writeln!(&mut file, "// @generated by fluent-zero-build").unwrap();
64
65 let mut unique_chars: BTreeSet<char> = BTreeSet::new();
66
67 for (env_key, env_val) in env::vars() {
71 if env_key.starts_with("DEP_")
72 && env_key.ends_with("_FLUENT_CHARSET_PATH")
73 && let Ok(content) = fs::read_to_string(&env_val)
74 {
75 unique_chars.extend(content.chars().filter(|c| !c.is_control()));
76 }
77 }
78
79 let mut bundle_entries: Vec<(String, String)> = Vec::new();
80 let mut cache_root_entries: Vec<(String, String)> = Vec::new();
81
82 if self.locales_dir.exists() {
84 println!("cargo:rerun-if-changed={}", self.locales_dir.display());
85 let dir_entries =
86 fs::read_dir(&self.locales_dir).expect("Failed to read locales directory");
87
88 for entry in dir_entries {
89 let entry = entry.expect("Failed to read directory entry");
90 let path = entry.path();
91
92 if !path.is_dir() {
93 continue;
94 }
95
96 let Some(dir_name) = path.file_name().and_then(|n| n.to_str()) else {
97 continue;
98 };
99 let Ok(lang_id) = dir_name.parse::<LanguageIdentifier>() else {
100 continue;
101 };
102
103 let lang_key = lang_id.to_string();
104 let sanitized_lang = lang_key.replace('-', "_").to_uppercase();
105
106 let cache_name = format!("CACHE_{sanitized_lang}");
107 let bundle_name = format!("BUNDLE_{sanitized_lang}");
108
109 let (combined_ftl_source, cache_entries) =
110 Self::process_locale_dir(&path, &mut unique_chars);
111
112 Self::write_bundle_initializer(
113 &mut file,
114 &bundle_name,
115 &lang_key,
116 &combined_ftl_source,
117 );
118 Self::write_cache_map(&mut file, &cache_name, &cache_entries);
119
120 bundle_entries.push((lang_key.clone(), format!("&{bundle_name}")));
121 cache_root_entries.push((lang_key, cache_name));
122 }
123 }
124
125 Self::write_root_maps(&mut file, &cache_root_entries, &bundle_entries);
128 self.export_charset_data(&mut file, &unique_chars, &out_dir);
129 }
130
131 fn process_locale_dir(
132 path: &Path,
133 unique_chars: &mut BTreeSet<char>,
134 ) -> (String, Vec<(String, String)>) {
135 let mut combined_ftl_source = String::new();
136 let mut cache_entries = Vec::new();
137
138 let entries = fs::read_dir(path).expect("Failed to read locale subdirectory");
139
140 for file_entry in entries {
141 let file_entry = file_entry.expect("Failed to read file entry");
142 let file_path = file_entry.path();
143
144 if file_path.extension().is_some_and(|ext| ext == "ftl") {
145 println!("cargo:rerun-if-changed={}", file_path.display());
146 let source = fs::read_to_string(&file_path).expect("Failed to read FTL file");
147
148 combined_ftl_source.push_str(&source);
149 combined_ftl_source.push('\n');
150
151 let ast = parser::parse(source).expect("Failed to parse FTL");
152 for entry in ast.body {
153 if let fluent_syntax::ast::Entry::Message(msg) = entry {
154 Self::harvest_chars(&msg, unique_chars);
155 cache_entries.push((msg.id.name.clone(), Self::generate_cache_entry(&msg)));
156 }
157 }
158 }
159 }
160
161 (combined_ftl_source, cache_entries)
162 }
163
164 fn harvest_chars(msg: &fluent_syntax::ast::Message<String>, unique_chars: &mut BTreeSet<char>) {
165 let Some(pattern) = &msg.value else { return };
166 for element in &pattern.elements {
167 if let fluent_syntax::ast::PatternElement::TextElement { value } = element {
168 unique_chars.extend(value.chars().filter(|c| !c.is_control()));
169 }
170 }
171 }
172
173 fn generate_cache_entry(msg: &fluent_syntax::ast::Message<String>) -> String {
174 if let Some(pattern) = &msg.value
175 && pattern.elements.len() == 1
176 && let fluent_syntax::ast::PatternElement::TextElement { value } = &pattern.elements[0]
177 && !value.contains('\\')
178 {
179 format!("::fluent_zero::CacheEntry::Static(\"{value}\")")
180 } else {
181 "::fluent_zero::CacheEntry::Dynamic".to_string()
182 }
183 }
184
185 fn write_bundle_initializer(
186 file: &mut fs::File,
187 bundle_name: &str,
188 lang_key: &str,
189 combined_ftl_source: &str,
190 ) {
191 let escaped_ftl = format!("{combined_ftl_source:?}");
192 let init_code = format!(
193 "std::sync::LazyLock::new(|| {{\n\
194 \x20 let lang: ::fluent_zero::LanguageIdentifier = \"{lang_key}\".parse().unwrap();\n\
195 \x20 let mut bundle = ::fluent_zero::ConcurrentFluentBundle::new_concurrent(vec![lang]);\n\
196 \x20 let res = ::fluent_zero::FluentResource::try_new({escaped_ftl}.to_string()).expect(\"FTL Error\");\n\
197 \x20 bundle.add_resource(res).expect(\"Resource Error\");\n\
198 \x20 bundle\n\
199 }})"
200 );
201 writeln!(
202 file,
203 "static {bundle_name}: std::sync::LazyLock<::fluent_zero::ConcurrentFluentBundle<::fluent_zero::FluentResource>> = {init_code};"
204 ).unwrap();
205 }
206
207 fn write_cache_map(file: &mut fs::File, cache_name: &str, cache_entries: &[(String, String)]) {
208 let mut map = phf_codegen::Map::new();
209 map.phf_path("::fluent_zero::phf");
210 for (k, v) in cache_entries {
211 map.entry(k.as_str(), v.as_str());
212 }
213 writeln!(
214 file,
215 "static {cache_name}: ::fluent_zero::phf::Map<&'static str, ::fluent_zero::CacheEntry> = {};",
216 map.build()
217 ).unwrap();
218 }
219
220 fn write_root_maps(
221 file: &mut fs::File,
222 cache_root_entries: &[(String, String)],
223 bundle_entries: &[(String, String)],
224 ) {
225 let mut root_map = phf_codegen::Map::new();
226 root_map.phf_path("::fluent_zero::phf");
227 for (l, v) in cache_root_entries {
228 root_map.entry(l.as_str(), format!("&{v}"));
229 }
230 writeln!(
231 file,
232 "pub static CACHE: ::fluent_zero::phf::Map<&'static str, &'static ::fluent_zero::phf::Map<&'static str, ::fluent_zero::CacheEntry>> = {};",
233 root_map.build()
234 ).unwrap();
235
236 let mut bundle_map = phf_codegen::Map::new();
237 bundle_map.phf_path("::fluent_zero::phf");
238 for (l, c) in bundle_entries {
239 bundle_map.entry(l.as_str(), c.as_str());
240 }
241 writeln!(
242 file,
243 "pub static LOCALES: ::fluent_zero::phf::Map<&'static str, &'static std::sync::LazyLock<::fluent_zero::ConcurrentFluentBundle<::fluent_zero::FluentResource>>> = {};",
244 bundle_map.build()
245 ).unwrap();
246 }
247
248 fn export_charset_data(
249 &self,
250 file: &mut fs::File,
251 unique_chars: &BTreeSet<char>,
252 out_dir: &str,
253 ) {
254 let charset_string: String = unique_chars.iter().collect();
255
256 writeln!(
258 file,
259 "\n/// A deterministically sorted string containing all unique characters\n\
260 /// used across all `.ftl` files in this crate and its dependencies.\n\
261 pub const CHARSET: &str = {charset_string:?};"
262 )
263 .unwrap();
264
265 let internal_dest = Path::new(out_dir).join("fluent_charset_internal.txt");
269 if fs::write(&internal_dest, &charset_string).is_ok() {
270 println!("cargo:fluent_charset_path={}", internal_dest.display());
271 }
272
273 if let Some(charset_dest) = &self.charset_dest {
274 if let Some(parent) = charset_dest.parent() {
275 let _ = fs::create_dir_all(parent);
276 }
277 fs::write(charset_dest, &charset_string).expect("Failed to write fluent charset file");
278 println!("cargo:rerun-if-changed={}", charset_dest.display());
279 }
280 }
281}
282
283pub fn generate_static_cache(locales_dir_path: &str) {
285 FluentZeroBuilder::new(locales_dir_path).generate();
286}