Skip to main content

fluent_zero_build/
lib.rs

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
11/// A builder to configure and generate the static cache code for `fluent-zero`.
12pub 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    /// Creates a new builder pointing to the directory containing locale subdirectories.
28    ///
29    /// # Arguments
30    ///
31    /// * `locales_dir_path` - Relative path to the folder containing locale subdirectories.
32    #[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    /// Opt-in to exporting a deterministic text file containing all unique
41    /// characters used across all `.ftl` files in this crate **and its dependencies**.
42    ///
43    /// **Dependency Aggregation:** This natively aggregates characters from dependencies
44    /// using Cargo's standard IPC. No metadata scraping or tree walking is required.
45    #[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    /// Consumes the builder, parses the Fluent files, aggregates IPC variables,
52    /// and generates the Rust static cache code.
53    ///
54    /// # Panics
55    ///
56    /// Panics if the `OUT_DIR` environment variable is not set, or if critical
57    /// IO file system operations fail during compilation.
58    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        // 1. Dependency Aggregation via Cargo IPC.
68        // Cargo automatically propagates `cargo:KEY=VALUE` emitted by dependency build
69        // scripts up to dependents as `DEP_<LINKS>_<KEY>` if the dependency specifies a `links` key.
70        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        // 2. Process Local Translations (If present)
83        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        // 3. Complete Code Generation & Upstream Exporting
126        // This unconditionally runs to guarantee valid empty PHF maps in aggregator crates.
127        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        // Embed the charset constant natively into the module.
257        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        // 1. Emit absolute path for the NEXT build.rs to consume natively.
266        // Cargo automatically captures this and converts it into `DEP_<LINKS>_FLUENT_CHARSET_PATH`.
267        // We write to a file rather than passing the string to bypass Windows 32KB limits.
268        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
283/// Generates the static cache code for `fluent-zero`.
284pub fn generate_static_cache(locales_dir_path: &str) {
285    FluentZeroBuilder::new(locales_dir_path).generate();
286}