fluent_localization_bindgen/
lib.rs

1//#![feature(proc_macro_diagnostic)]
2
3use std::{
4    collections::{HashMap, HashSet},
5    sync::Arc,
6};
7
8use fluent_bundle::FluentResource;
9use fluent_localization_loader::{
10    base_path, fold_displayable, load_resources_from_folder, DEFAULT_DIR,
11};
12use fluent_syntax::ast::{Entry, Expression, InlineExpression, PatternElement};
13use proc_macro::TokenStream;
14use quote::quote;
15use syn::LitStr;
16
17//hardcode the alphabet, seems to be the fastest way to do this
18const ALPHABET: [char; 26] = [
19    'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S',
20    'T', 'U', 'V', 'W', 'X', 'Y', 'Z',
21];
22
23struct Node<'a> {
24    category: &'a str,
25    name: &'a str,
26    variables: HashSet<&'a str>,
27    dependencies: HashSet<&'a str>,
28    term: bool,
29}
30
31impl<'a> Node<'a> {
32    pub fn new(category: &'a str, name: &'a str, term: bool) -> Self {
33        Node {
34            category,
35            name,
36            variables: HashSet::new(),
37            dependencies: HashSet::new(),
38            term,
39        }
40    }
41}
42#[proc_macro]
43pub fn bind_localizations(_meta: TokenStream) -> TokenStream {
44    //Load the bundle
45
46    let mut base_dir = base_path();
47    base_dir.push(DEFAULT_DIR);
48
49    let resources = match load_resources_from_folder(base_dir) {
50        Ok(value) => value,
51        Err(e) => panic!("{}", fold_displayable(e.chain(), "| Caused by: ")),
52    };
53
54    // Walk each resource and generaate its nodes, then collect them all in a singular hashmap.
55    //No need to worry about duplicates since that would have yieled a loading error earlyier on
56    let mut nodes_map: HashMap<String, Node> = resources
57        .iter()
58        .flat_map(|resource| generate_nodes_for(&resource.name, &resource.resource))
59        .map(|node| (node.name.to_string(), node))
60        .collect();
61
62    //Assemble full list for later, filter out terms cause we can't enforce their pressence sadly
63    let all_terms: Vec<LitStr> = nodes_map
64        .iter()
65        .filter(|(_, node)| node.term)
66        .map(|(name, _)| syn::LitStr::new(name.as_str(), proc_macro2::Span::call_site()))
67        .collect();
68    let term_count = all_terms.len();
69    let all_messages: Vec<LitStr> = nodes_map
70        .iter()
71        .filter(|(_, node)| !node.term)
72        .map(|(name, _)| syn::LitStr::new(name.as_str(), proc_macro2::Span::call_site()))
73        .collect();
74    let message_count = all_messages.len();
75    //println!("{all_names:?}");
76
77    //Nodes can depend on other nodes, copy over all the dependecies where needed
78    // ! Recursion checking required in since fluent doesn't give parse errors on these so we need to avoid infinite loops here !
79    loop {
80        // rust mutability can be a pain in the ass sometimes so we have to do this the hard way
81        let Some(todo) = nodes_map
82            .iter()
83            .filter_map(|(_, node)| node.dependencies.iter().next().map(|todo| todo.to_string()))
84            .next()
85        else {
86            break;
87        };
88
89        let Some((variables, dependencies)) = nodes_map
90            .get(todo.as_str())
91            .map(|node| (node.variables.clone(), node.dependencies.clone()))
92        else {
93            panic!(
94                "Enountered a dependency on localization node {todo} but no such node was loaded"
95            );
96        };
97
98        for (name, node) in nodes_map
99            .iter_mut()
100            .filter(|(_, node)| node.dependencies.contains(todo.as_str()))
101        {
102            if name.as_str() == todo.as_str() {
103                panic!("Cyclic localization loop detected at node {name}!");
104            }
105
106            node.dependencies.remove(todo.as_str());
107            node.variables.extend(variables.iter());
108            node.dependencies.extend(dependencies.iter());
109        }
110    }
111
112    // General code for validating the bundle and handling errors
113
114    let mut code = quote! {
115        pub const MESSAGES: [&str; #message_count] = [#(#all_messages,)*];
116        pub const TERMS: [&str; #term_count] = [#(#all_terms,)*];
117
118        pub struct LanguageLocalizer<'a> {
119            localizations: &'a fluent_localization_loader::LocalizationHolder,
120            language: &'a str,
121        }
122
123
124        impl <'a> LanguageLocalizer<'a> {
125            pub fn new(holder: &'a fluent_localization_loader::LocalizationHolder, language: &'a str) -> LanguageLocalizer<'a> {
126                LanguageLocalizer {
127                    localizations: holder,
128                    language,
129                }
130            }
131
132
133            pub fn validate_default_bundle_complete() -> anyhow::Result<()> {
134                tracing::debug!("Validating default bundle has all expected keys");
135                let mut base_dir = fluent_localization_loader::base_path();
136                let default_lang = fluent_localization_loader::get_default_language()?;
137
138                base_dir.push(default_lang.to_string());
139
140                let resources = fluent_localization_loader::load_resources_from_folder(base_dir)?;
141
142                let mut found_messages: std::collections::HashSet<String> = std::collections::HashSet::new();
143                let mut found_terms: std::collections::HashSet<String> = std::collections::HashSet::new();
144
145                resources.iter()
146                .flat_map(|resource| resource.resource.entries())
147                .for_each(|entry| {
148                    match entry {
149                        fluent_syntax::ast::Entry::Message(message) => {
150                            if message.value.is_some()  {
151                                found_messages.insert(message.id.name.to_string());
152                            }
153                        }
154                        fluent_syntax::ast::Entry::Term(term) => {
155                            found_terms.insert(term.id.name.to_string());
156                        },
157                        _ => ()
158                }
159            });
160
161                let missing_messages: Vec<&str> = MESSAGES.into_iter().filter(|name| !found_messages.contains(&name.to_string())).collect();
162                let missing_terms: Vec<&str> = TERMS.into_iter().filter(|name| !found_terms.contains(&name.to_string())).collect();
163                if missing_messages.is_empty() && missing_terms.is_empty()  {
164                    tracing::info!("Default bundle ({default_lang}) is valid");
165                    Ok(())
166                } else {
167                    Err(fluent_localization_loader::LocalizationLoadingError::new(format!("The following localization keys where not found in the default language bundle: {}", fluent_localization_loader::fold_displayable(missing_messages.into_iter().map(|name| name.to_string()).chain(missing_terms.into_iter().map(|name| format!("-{name}"))), ", "))))?
168                }
169            }
170
171            pub fn localize(&self, name: &str, arguments: Option<fluent_bundle::FluentArgs<'a>>) -> String {
172                let bundle = self.localizations.get_bundle(self.language);
173                //This is autogenerated from the same list as the bundle validator so we know this is present
174                let message = bundle.get_message(name).unwrap();
175
176                let mut errors = Vec::new();
177
178                let message = bundle.format_pattern(message.value().unwrap(), arguments.as_ref(), &mut errors);
179
180
181                if errors.is_empty() {
182                    message.to_string()
183                } else {
184                    self.handle_errors(name, errors)
185                }
186
187            }
188
189            pub fn handle_errors(&self, name: &str, errors: Vec<fluent_bundle::FluentError>) -> String {
190                let errors = fluent_localization_loader::fold_displayable(errors.into_iter(), ", ");
191                tracing::error!("Failed to localize {name} due to following errors: {errors}");
192
193                //TODO: actually report this error somewhere other then logs?
194                format!("Failed to localize the \"{name}\" response.")
195            }
196
197
198
199
200        }
201    };
202
203    //Now let's generate the helper functions, just from strings now cause that's easier with all the damn generics
204
205    // let's start easy: no params here
206    let start = String::from("impl <'a> LanguageLocalizer<'a> {");
207    let mut simple_block = nodes_map
208        .iter()
209        .filter(|(_, node)| node.variables.is_empty() && !node.term)
210        .map(|(name, node)| {
211            let category = sanitize(node.category);
212            let sanitized_name = sanitize(name);
213            format!(
214                "
215\tpub fn {category}_{sanitized_name}(&self) -> String {{
216\t\tself.localize(\"{name}\", None)
217\t}}"
218            )
219        })
220        .fold(start, |assembled, extra| assembled + "\n" + &extra);
221    simple_block += "\n}";
222    //println!("{simple_block}");
223    let compiled_simple_block = simple_block
224        .parse::<proc_macro2::TokenStream>()
225        .expect("Failed to assemble simple block token stream");
226
227    code.extend(compiled_simple_block);
228
229    //Now it gets real, welcome to generated generics
230    // ! sorting is needed on the names because otherwise their order is random and not consistent between compilations!
231    let hell = nodes_map
232        .iter()
233        .filter(|(_, node)| !node.variables.is_empty() && !node.term)
234        .map(|(name, node)| {
235            let count = node.variables.len();
236            let letters = get_letters(count);
237
238            let generics = format!("<{}>", fold_displayable(letters.iter(), ", "));
239
240            let generic_definitions = letters
241                .iter()
242                .map(|letter| format!("\t{letter}: Into<fluent_bundle::FluentValue<'a>>,"))
243                .fold(String::from("where"), |assembled, extra| {
244                    assembled + "\n" + &extra
245                })
246                + "\n";
247
248            let mut variables: Vec<&&str> = node.variables.iter().collect();
249            variables.sort_unstable_by_key(|value| value.to_lowercase());
250
251            let mut letter_iter = letters.iter();
252            let mut params = String::from("&self");
253            let mut handle_arguments =
254                String::from("let mut arguments = fluent_bundle::FluentArgs::new();");
255            for name in variables {
256                let sanitized_name = sanitize(name);
257                // safe to unwrap, we generated the letters based on the variable count above
258                let letter = letter_iter.next().unwrap();
259
260                params += &format!(", {sanitized_name}: {letter}");
261                handle_arguments +=
262                    &format!("\n\t\targuments.set(\"{name}\", {sanitized_name}.into());");
263            }
264
265            let category = node.category;
266            let sanitized_name = sanitize(name);
267            format!(
268                "
269\tpub fn {category}_{sanitized_name}{generics}({params}) -> String
270\t{generic_definitions}\t{{
271\t\t{handle_arguments}
272\t\tself.localize(\"{name}\", Some(arguments))
273\t}}"
274            )
275        })
276        .fold(
277            String::from("impl <'a> LanguageLocalizer<'a> {"),
278            |assembled, extra| assembled + "\n" + &extra,
279        )
280        + "\n}";
281
282    //println!("{hell}");
283    let compiled_hell_block = hell
284        .parse::<proc_macro2::TokenStream>()
285        .expect("Failed to assemble the token stream from hell");
286
287    code.extend(compiled_hell_block);
288
289    code.into()
290}
291
292fn sanitize(original: &str) -> String {
293    original.replace('-', "_").to_lowercase()
294}
295
296fn get_letters(amount: usize) -> Vec<char> {
297    if amount > 26 {
298        todo!("Localization strings with 26+ params, what the hell is this? are we assembling a phone book?");
299    }
300    (0..amount).map(|count| ALPHABET[count]).collect()
301}
302
303fn generate_nodes_for<'a>(parrent: &'a str, resource: &'a Arc<FluentResource>) -> Vec<Node<'a>> {
304    let mut out = Vec::new();
305
306    for entry in resource.entries() {
307        let (name, pattern, term) = match entry {
308            Entry::Message(message) => {
309                let Some(pattern) = &message.value else {
310                    continue;
311                };
312                (message.id.name, pattern, false)
313            }
314            Entry::Term(term) => (term.id.name, &term.value, true),
315            _ => continue,
316        };
317
318        let mut node = Node::new(parrent, name, term);
319        process_pattern_elements(&pattern.elements, &mut node);
320        out.push(node)
321    }
322
323    out
324}
325
326fn process_pattern_elements<'a>(attributes: &'a Vec<PatternElement<&'a str>>, node: &mut Node<'a>) {
327    for attribute in attributes {
328        // We only care about placables since those are dynamic, we are not interested in fixed textelements
329        match attribute {
330            PatternElement::TextElement { value: _ } => (),
331            PatternElement::Placeable { expression } => {
332                process_expression(expression, node);
333            }
334        }
335    }
336}
337
338fn process_expression<'a>(expression: &'a Expression<&'a str>, node: &mut Node<'a>) {
339    match expression {
340        Expression::Select { selector, variants } => {
341            process_inline_expression(selector, node);
342            for variant in variants {
343                process_pattern_elements(&variant.value.elements, node)
344            }
345        }
346        Expression::Inline(inline) => process_inline_expression(inline, node),
347    }
348}
349
350fn process_inline_expression<'a>(expression: &'a InlineExpression<&'a str>, node: &mut Node<'a>) {
351    match expression {
352        InlineExpression::FunctionReference {
353            id: _,
354            arguments: _,
355        } => todo!(), // leaving this as a crash intentionally for now since i don't know how these work exactly yet. will deal with this if i ever end up actually using them
356        InlineExpression::MessageReference { id, attribute: _ } => {
357            node.dependencies.insert(id.name);
358        }
359        InlineExpression::TermReference {
360            id,
361            attribute: _,
362            arguments: _,
363        } => {
364            node.dependencies.insert(id.name);
365        }
366        InlineExpression::VariableReference { id } => {
367            node.variables.insert(id.name);
368        }
369        InlineExpression::Placeable { expression } => {
370            process_expression(expression, node);
371        }
372        InlineExpression::StringLiteral { value: _ }
373        | InlineExpression::NumberLiteral { value: _ } => {}
374    }
375
376    if let InlineExpression::VariableReference { id } = expression {
377        node.variables.insert(id.name);
378    }
379}