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 FluentZeroBuilder {
18    /// Creates a new builder pointing to the directory containing locale subdirectories.
19    #[must_use]
20    pub fn new<P: AsRef<Path>>(locales_dir_path: P) -> Self {
21        Self {
22            locales_dir: locales_dir_path.as_ref().to_path_buf(),
23            charset_dest: None,
24        }
25    }
26
27    /// Opt-in to exporting a deterministic text file containing all unique
28    /// characters used across all `.ftl` files. This is highly recommended
29    /// for font subsetting in WASM/Game GUI environments.
30    #[must_use]
31    pub fn export_charset<P: AsRef<Path>>(mut self, dest: P) -> Self {
32        self.charset_dest = Some(dest.as_ref().to_path_buf());
33        self
34    }
35
36    /// Consumes the builder, parses the Fluent files, and generates the Rust code.
37    #[allow(clippy::too_many_lines)]
38    pub fn generate(self) {
39        println!("cargo:rerun-if-changed={}", self.locales_dir.display());
40
41        let out_dir = env::var("OUT_DIR").expect("OUT_DIR not set");
42        let dest_path = Path::new(&out_dir).join("static_cache.rs");
43        let mut file = fs::File::create(&dest_path).expect("Failed to create static_cache.rs");
44
45        writeln!(&mut file, "// @generated by fluent-zero-build").unwrap();
46
47        if !self.locales_dir.exists() {
48            return;
49        }
50
51        let mut bundle_entries: Vec<(String, String)> = Vec::new();
52        let mut cache_root_entries: Vec<(String, String)> = Vec::new();
53
54        // This BTreeSet guarantees the output characters are sorted and deterministic.
55        let mut unique_chars: BTreeSet<char> = BTreeSet::new();
56
57        for entry in fs::read_dir(&self.locales_dir).unwrap() {
58            let entry = entry.unwrap();
59            let path = entry.path();
60
61            if path.is_dir() {
62                let dir_name = path.file_name().unwrap().to_str().unwrap();
63                let lang_id: LanguageIdentifier = match dir_name.parse() {
64                    Ok(id) => id,
65                    Err(_) => continue,
66                };
67                let lang_key = lang_id.to_string();
68                let sanitized_lang = lang_key.replace('-', "_").to_uppercase();
69
70                // Define variable names
71                let cache_name = format!("CACHE_{sanitized_lang}");
72                let bundle_name = format!("BUNDLE_{sanitized_lang}");
73
74                let mut combined_ftl_source = String::new();
75                // Vector of (Key, ValueCode)
76                let mut cache_entries: Vec<(String, String)> = Vec::new();
77
78                for file_entry in fs::read_dir(&path).unwrap() {
79                    let file_entry = file_entry.unwrap();
80                    let file_path = file_entry.path();
81
82                    if file_path.extension().is_some_and(|ext| ext == "ftl") {
83                        println!("cargo:rerun-if-changed={}", file_path.display());
84                        let source = fs::read_to_string(&file_path).unwrap();
85                        combined_ftl_source.push_str(&source);
86                        combined_ftl_source.push('\n');
87
88                        let ast = parser::parse(source).expect("Failed to parse FTL");
89                        for entry in ast.body {
90                            if let fluent_syntax::ast::Entry::Message(msg) = entry {
91                                // 1. Character Harvesting for Subsetting
92                                if let Some(pattern) = &msg.value {
93                                    for element in &pattern.elements {
94                                        if let fluent_syntax::ast::PatternElement::TextElement {
95                                            value,
96                                        } = element
97                                        {
98                                            for ch in value.chars() {
99                                                // Ignore invisible control characters
100                                                if !ch.is_control() {
101                                                    unique_chars.insert(ch);
102                                                }
103                                            }
104                                        }
105                                    }
106                                }
107
108                                // 2. Cache Entry Generation
109                                // Check if Simple Static
110                                // Criteria:
111                                // 1. Has a value.
112                                // 2. Only one element in the pattern.
113                                // 3. That element is Text (not a Variable).
114                                // 4. No escape characters (simplifies generation).
115                                if let Some(pattern) = &msg.value
116                                    && pattern.elements.len() == 1
117                                    && let fluent_syntax::ast::PatternElement::TextElement { value } =
118                                        &pattern.elements[0]
119                                    && !value.contains('\\')
120                                {
121                                    // Entry::Static("value")
122                                    // Stored directly in the binary .rodata
123                                    cache_entries.push((
124                                        msg.id.name.clone(),
125                                        format!("::fluent_zero::CacheEntry::Static(\"{value}\")"),
126                                    ));
127                                } else {
128                                    // Entry::Dynamic
129                                    // Requires parsing by FluentBundle at runtime
130                                    cache_entries.push((
131                                        msg.id.name.clone(),
132                                        "::fluent_zero::CacheEntry::Dynamic".to_string(),
133                                    ));
134                                }
135                            }
136                        }
137                    }
138                }
139
140                // 1. Write the Bundle Static Item
141                // We use LazyLock to ensure we only parse the FTL for the bundle if we actually
142                // hit a dynamic message for this specific locale.
143                let escaped_ftl = format!("{combined_ftl_source:?}");
144                let bundle_init_code = format!(
145                    "std::sync::LazyLock::new(|| {{
146                    let lang: ::fluent_zero::LanguageIdentifier = \"{lang_key}\".parse().unwrap();
147                    let mut bundle = ::fluent_zero::ConcurrentFluentBundle::new_concurrent(vec![lang]); 
148                    let res = ::fluent_zero::FluentResource::try_new({escaped_ftl}.to_string()).expect(\"FTL Error\");
149                    bundle.add_resource(res).expect(\"Resource Error\");
150                    bundle
151                }})"
152                );
153
154                writeln!(&mut file,
155                    "static {bundle_name}: std::sync::LazyLock<::fluent_zero::ConcurrentFluentBundle<::fluent_zero::FluentResource>> = {bundle_init_code};"
156                ).unwrap();
157
158                bundle_entries.push((lang_key.clone(), format!("&{bundle_name}")));
159
160                // 2. Write Unified Cache Map
161                let mut map = phf_codegen::Map::new();
162                map.phf_path("::fluent_zero::phf");
163                for (k, v) in &cache_entries {
164                    map.entry(k.as_str(), v.as_str());
165                }
166
167                writeln!(
168                    &mut file,
169                    "static {}: ::fluent_zero::phf::Map<&'static str, ::fluent_zero::CacheEntry> = {};",
170                    cache_name,
171                    map.build()
172                )
173                .unwrap();
174                cache_root_entries.push((lang_key.clone(), cache_name));
175            }
176        }
177
178        // 3. Generate Root Maps
179
180        // Unified Cache Root
181        let mut root_map = phf_codegen::Map::new();
182        root_map.phf_path("::fluent_zero::phf");
183        for (l, v) in &cache_root_entries {
184            root_map.entry(l.as_str(), format!("&{v}"));
185        }
186        writeln!(&mut file,
187            "pub static CACHE: ::fluent_zero::phf::Map<&'static str, &'static ::fluent_zero::phf::Map<&'static str, ::fluent_zero::CacheEntry>> = {};",
188            root_map.build()
189        ).unwrap();
190
191        // Locales Root
192        let mut bundle_map = phf_codegen::Map::new();
193        bundle_map.phf_path("::fluent_zero::phf");
194        for (l, c) in &bundle_entries {
195            bundle_map.entry(l.as_str(), c.as_str());
196        }
197        writeln!(&mut file,
198            "pub static LOCALES: ::fluent_zero::phf::Map<&'static str, &'static std::sync::LazyLock<::fluent_zero::ConcurrentFluentBundle<::fluent_zero::FluentResource>>> = {};",
199            bundle_map.build()
200        ).unwrap();
201
202        // 4. Export Charset (If Requested)
203        if let Some(charset_dest) = self.charset_dest {
204            let charset_string: String = unique_chars.into_iter().collect();
205            fs::write(&charset_dest, charset_string).expect("Failed to write fluent charset file");
206
207            // Tell cargo to rerun if the output path changes, though
208            // typically OUT_DIR contents are ignored.
209            println!("cargo:rerun-if-changed={}", charset_dest.display());
210        }
211    }
212}
213
214/// Generates the static cache code for `fluent-zero`.
215///
216/// This is the legacy, non-breaking function signature. Under the hood,
217/// it delegates to `FluentZeroBuilder`.
218///
219/// # Arguments
220///
221/// * `locales_dir_path` - Relative path to the folder containing locale subdirectories.
222pub fn generate_static_cache(locales_dir_path: &str) {
223    FluentZeroBuilder::new(locales_dir_path).generate();
224}