es_fluent_generate/
lib.rs

1#![doc = include_str!("../README.md")]
2
3use clap::ValueEnum;
4use es_fluent_core::meta::TypeKind;
5use es_fluent_core::namer::FluentKey;
6use es_fluent_core::registry::{FtlTypeInfo, FtlVariant};
7use fluent_syntax::{ast, parser, serializer};
8use std::collections::HashMap;
9use std::{fs, path::Path};
10
11pub mod error;
12mod formatter;
13
14use error::FluentGenerateError;
15use formatter::value::ValueFormatter;
16
17/// The mode to use when parsing Fluent files.
18#[derive(Clone, Debug, Default, PartialEq, ValueEnum)]
19pub enum FluentParseMode {
20    /// Overwrite existing translations.
21    Aggressive,
22    /// Preserve existing translations.
23    #[default]
24    Conservative,
25    /// Clean orphan keys (unused in code) but preserve used translations.
26    #[clap(skip)]
27    Clean,
28}
29
30/// Generates a Fluent translation file from a list of `FtlTypeInfo` objects.
31pub fn generate<P: AsRef<Path>>(
32    crate_name: &str,
33    i18n_path: P,
34    items: Vec<FtlTypeInfo>,
35    mode: FluentParseMode,
36) -> Result<(), FluentGenerateError> {
37    let i18n_path = i18n_path.as_ref();
38
39    fs::create_dir_all(i18n_path)?;
40
41    let file_path = i18n_path.join(format!("{}.ftl", crate_name));
42
43    let existing_resource = if file_path.exists() {
44        let content = fs::read_to_string(&file_path)?;
45        if content.trim().is_empty() {
46            ast::Resource { body: Vec::new() }
47        } else {
48            match parser::parse(content) {
49                Ok(res) => res,
50                Err((res, errors)) => {
51                    log::warn!(
52                        "Warning: Encountered parsing errors in {}: {:?}",
53                        file_path.display(),
54                        errors
55                    );
56                    res
57                },
58            }
59        }
60    } else {
61        ast::Resource { body: Vec::new() }
62    };
63
64    let final_resource = if matches!(mode, FluentParseMode::Aggressive) {
65        let target_resource = build_target_resource(&items);
66
67        let mut existing_entries_map: HashMap<String, ast::Entry<String>> = HashMap::new();
68        for entry in existing_resource.body.into_iter() {
69            match &entry {
70                ast::Entry::Message(msg) => {
71                    existing_entries_map.insert(msg.id.name.clone(), entry);
72                },
73                ast::Entry::Term(term) => {
74                    existing_entries_map
75                        .insert(format!("{}{}", FluentKey::DELIMITER, term.id.name), entry);
76                },
77                _ => {},
78            }
79        }
80
81        let mut merged_resource_body: Vec<ast::Entry<String>> = Vec::new();
82
83        for entry in target_resource.body {
84            merged_resource_body.push(entry);
85        }
86
87        ast::Resource {
88            body: merged_resource_body,
89        }
90    } else {
91        let cleanup = matches!(mode, FluentParseMode::Clean);
92        smart_merge(existing_resource, &items, cleanup)
93    };
94
95    if !final_resource.body.is_empty() {
96        let final_output = serializer::serialize(&final_resource);
97
98        let final_content_to_write = final_output.trim_end();
99
100        let current_content = if file_path.exists() {
101            fs::read_to_string(&file_path)?
102        } else {
103            String::new()
104        };
105
106        if current_content != final_content_to_write {
107            fs::write(&file_path, final_content_to_write)?;
108            log::error!("Updated FTL file: {}", file_path.display());
109        } else {
110            log::error!("FTL file unchanged: {}", file_path.display());
111        }
112    } else {
113        let final_content_to_write = "".to_string();
114        let current_content = if file_path.exists() {
115            fs::read_to_string(&file_path)?
116        } else {
117            String::new()
118        };
119
120        if current_content != final_content_to_write && !current_content.trim().is_empty() {
121            fs::write(&file_path, &final_content_to_write)?;
122            log::error!("Wrote empty FTL file (no items): {}", file_path.display());
123        } else {
124            if current_content != final_content_to_write {
125                fs::write(&file_path, &final_content_to_write)?;
126            }
127            log::error!(
128                "FTL file unchanged (empty or no items): {}",
129                file_path.display()
130            );
131        }
132    }
133
134    Ok(())
135}
136
137fn smart_merge(
138    existing: ast::Resource<String>,
139    items: &[FtlTypeInfo],
140    cleanup: bool,
141) -> ast::Resource<String> {
142    let mut pending_items = merge_ftl_type_infos(items);
143    pending_items.sort_by(|a, b| a.type_name.cmp(&b.type_name));
144
145    let mut item_map: HashMap<String, FtlTypeInfo> = pending_items
146        .into_iter()
147        .map(|i| (i.type_name.clone(), i))
148        .collect();
149
150    let mut new_body = Vec::new();
151    let mut current_group_name: Option<String> = None;
152
153    for entry in existing.body {
154        match entry {
155            ast::Entry::GroupComment(ref comment) => {
156                if let Some(ref old_group) = current_group_name
157                    && let Some(info) = item_map.get_mut(old_group)
158                    && !info.variants.is_empty()
159                {
160                    for variant in &info.variants {
161                        new_body.push(create_message_entry(variant));
162                    }
163                    info.variants.clear();
164                }
165
166                if let Some(content) = comment.content.first() {
167                    let trimmed = content.trim();
168                    current_group_name = Some(trimmed.to_string());
169                } else {
170                    current_group_name = None;
171                }
172
173                let keep_group = if let Some(ref group_name) = current_group_name {
174                    !cleanup || item_map.contains_key(group_name)
175                } else {
176                    true
177                };
178
179                if keep_group {
180                    new_body.push(entry);
181                }
182            },
183            ast::Entry::Message(ref msg) => {
184                let key = &msg.id.name;
185                let mut handled = false;
186
187                if let Some(ref group_name) = current_group_name
188                    && let Some(info) = item_map.get_mut(group_name)
189                    && let Some(idx) = info
190                        .variants
191                        .iter()
192                        .position(|v| v.ftl_key.to_string() == *key)
193                {
194                    info.variants.remove(idx);
195                    handled = true;
196                }
197
198                if !handled {
199                    for info in item_map.values_mut() {
200                        if let Some(idx) = info
201                            .variants
202                            .iter()
203                            .position(|v| v.ftl_key.to_string() == *key)
204                        {
205                            info.variants.remove(idx);
206                            handled = true;
207                            break;
208                        }
209                    }
210                }
211
212                if handled || !cleanup {
213                    new_body.push(entry);
214                }
215            },
216            ast::Entry::Term(ref term) => {
217                let key = format!("{}{}", FluentKey::DELIMITER, term.id.name);
218                let mut handled = false;
219                for info in item_map.values_mut() {
220                    if let Some(idx) = info
221                        .variants
222                        .iter()
223                        .position(|v| v.ftl_key.to_string() == key)
224                    {
225                        info.variants.remove(idx);
226                        handled = true;
227                        break;
228                    }
229                }
230
231                if handled || !cleanup {
232                    new_body.push(entry);
233                }
234            },
235            ast::Entry::Junk { .. } => {
236                new_body.push(entry);
237            },
238            _ => {
239                new_body.push(entry);
240            },
241        }
242    }
243
244    // Correctly handle the end of the last group
245    if let Some(ref last_group) = current_group_name
246        && let Some(info) = item_map.get_mut(last_group)
247        && !info.variants.is_empty()
248    {
249        for variant in &info.variants {
250            new_body.push(create_message_entry(variant));
251        }
252        info.variants.clear();
253    }
254
255    let mut remaining_groups: Vec<_> = item_map.into_iter().collect();
256    remaining_groups.sort_by(|(na, _), (nb, _)| na.cmp(nb));
257
258    for (type_name, info) in remaining_groups {
259        if !info.variants.is_empty() {
260            new_body.push(create_group_comment_entry(&type_name));
261            for variant in info.variants {
262                new_body.push(create_message_entry(&variant));
263            }
264        }
265    }
266
267    ast::Resource { body: new_body }
268}
269
270fn create_group_comment_entry(type_name: &str) -> ast::Entry<String> {
271    ast::Entry::GroupComment(ast::Comment {
272        content: vec![type_name.to_owned()],
273    })
274}
275
276fn create_message_entry(variant: &FtlVariant) -> ast::Entry<String> {
277    let message_id = ast::Identifier {
278        name: variant.ftl_key.to_string(),
279    };
280
281    let base_value = ValueFormatter::expand(&variant.name);
282
283    let mut elements = vec![ast::PatternElement::TextElement { value: base_value }];
284
285    for arg_name in &variant.args {
286        elements.push(ast::PatternElement::TextElement { value: " ".into() });
287
288        elements.push(ast::PatternElement::Placeable {
289            expression: ast::Expression::Inline(ast::InlineExpression::VariableReference {
290                id: ast::Identifier {
291                    name: arg_name.into(),
292                },
293            }),
294        });
295    }
296
297    let pattern = ast::Pattern { elements };
298
299    ast::Entry::Message(ast::Message {
300        id: message_id,
301        value: Some(pattern),
302        attributes: Vec::new(),
303        comment: None,
304    })
305}
306
307fn merge_ftl_type_infos(items: &[FtlTypeInfo]) -> Vec<FtlTypeInfo> {
308    use std::collections::BTreeMap;
309
310    // Group by type_name
311    let mut grouped: BTreeMap<String, (TypeKind, Vec<FtlVariant>)> = BTreeMap::new();
312
313    for item in items {
314        let entry = grouped
315            .entry(item.type_name.clone())
316            .or_insert_with(|| (item.type_kind.clone(), Vec::new()));
317        entry.1.extend(item.variants.clone());
318    }
319
320    grouped
321        .into_iter()
322        .map(|(type_name, (type_kind, mut variants))| {
323            variants.sort_by(|a, b| {
324                // Put "this" variants (those without a dash in the key) first
325                let a_is_this = !a.ftl_key.to_string().contains(FluentKey::DELIMITER);
326                let b_is_this = !b.ftl_key.to_string().contains(FluentKey::DELIMITER);
327
328                match (a_is_this, b_is_this) {
329                    (true, false) => std::cmp::Ordering::Less,
330                    (false, true) => std::cmp::Ordering::Greater,
331                    _ => a.name.cmp(&b.name),
332                }
333            });
334            variants.dedup();
335
336            FtlTypeInfo {
337                type_kind,
338                type_name,
339                variants,
340                file_path: None,
341            }
342        })
343        .collect()
344}
345
346fn build_target_resource(items: &[FtlTypeInfo]) -> ast::Resource<String> {
347    let items = merge_ftl_type_infos(items);
348    let mut body: Vec<ast::Entry<String>> = Vec::new();
349    let mut sorted_items = items.to_vec();
350    sorted_items.sort_by(|a, b| a.type_name.cmp(&b.type_name));
351
352    for info in &sorted_items {
353        body.push(create_group_comment_entry(&info.type_name));
354
355        for variant in &info.variants {
356            body.push(create_message_entry(variant));
357        }
358    }
359
360    ast::Resource { body }
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366    use es_fluent_core::{meta::TypeKind, namer::FluentKey};
367    use proc_macro2::Ident;
368    use std::fs;
369    use tempfile::TempDir;
370
371    #[test]
372    fn test_value_formatter_expand() {
373        assert_eq!(ValueFormatter::expand("simple-key"), "Key");
374        assert_eq!(ValueFormatter::expand("another-test-value"), "Value");
375        assert_eq!(ValueFormatter::expand("single"), "Single");
376    }
377
378    #[test]
379    fn test_generate_empty_items() {
380        let temp_dir = TempDir::new().unwrap();
381        let i18n_path = temp_dir.path().join("i18n");
382
383        let result = generate(
384            "test_crate",
385            &i18n_path,
386            vec![],
387            FluentParseMode::Conservative,
388        );
389        assert!(result.is_ok());
390
391        let ftl_file_path = i18n_path.join("test_crate.ftl");
392        assert!(!ftl_file_path.exists());
393    }
394
395    #[test]
396    fn test_generate_with_items() {
397        let temp_dir = TempDir::new().unwrap();
398        let i18n_path = temp_dir.path().join("i18n");
399
400        let ftl_key = FluentKey::new(
401            &Ident::new("TestEnum", proc_macro2::Span::call_site()),
402            "Variant1",
403        );
404        let variant = FtlVariant {
405            name: "variant1".to_string(),
406            ftl_key,
407            args: Vec::new(),
408        };
409
410        let type_info = FtlTypeInfo {
411            type_kind: TypeKind::Enum,
412            type_name: "TestEnum".to_string(),
413            variants: vec![variant],
414            file_path: None,
415        };
416
417        let result = generate(
418            "test_crate",
419            &i18n_path,
420            vec![type_info],
421            FluentParseMode::Conservative,
422        );
423        assert!(result.is_ok());
424
425        let ftl_file_path = i18n_path.join("test_crate.ftl");
426        assert!(ftl_file_path.exists());
427
428        let content = fs::read_to_string(ftl_file_path).unwrap();
429        assert!(content.contains("TestEnum"));
430        assert!(content.contains("Variant1"));
431    }
432
433    #[test]
434    fn test_generate_aggressive_mode() {
435        let temp_dir = TempDir::new().unwrap();
436        let i18n_path = temp_dir.path().join("i18n");
437
438        let ftl_file_path = i18n_path.join("test_crate.ftl");
439        fs::create_dir_all(&i18n_path).unwrap();
440        fs::write(&ftl_file_path, "existing-message = Existing Content").unwrap();
441
442        let ftl_key = FluentKey::new(
443            &Ident::new("TestEnum", proc_macro2::Span::call_site()),
444            "Variant1",
445        );
446        let variant = FtlVariant {
447            name: "variant1".to_string(),
448            ftl_key,
449            args: Vec::new(),
450        };
451
452        let type_info = FtlTypeInfo {
453            type_kind: TypeKind::Enum,
454            type_name: "TestEnum".to_string(),
455            variants: vec![variant],
456            file_path: None,
457        };
458
459        let result = generate(
460            "test_crate",
461            &i18n_path,
462            vec![type_info],
463            FluentParseMode::Aggressive,
464        );
465        assert!(result.is_ok());
466
467        let content = fs::read_to_string(&ftl_file_path).unwrap();
468        assert!(!content.contains("existing-message"));
469        assert!(content.contains("TestEnum"));
470        assert!(content.contains("Variant1"));
471    }
472
473    #[test]
474    fn test_generate_conservative_mode() {
475        let temp_dir = TempDir::new().unwrap();
476        let i18n_path = temp_dir.path().join("i18n");
477
478        let ftl_file_path = i18n_path.join("test_crate.ftl");
479        fs::create_dir_all(&i18n_path).unwrap();
480        fs::write(&ftl_file_path, "existing-message = Existing Content").unwrap();
481
482        let ftl_key = FluentKey::new(
483            &Ident::new("TestEnum", proc_macro2::Span::call_site()),
484            "Variant1",
485        );
486        let variant = FtlVariant {
487            name: "variant1".to_string(),
488            ftl_key,
489            args: Vec::new(),
490        };
491
492        let type_info = FtlTypeInfo {
493            type_kind: TypeKind::Enum,
494            type_name: "TestEnum".to_string(),
495            variants: vec![variant],
496            file_path: None,
497        };
498
499        let result = generate(
500            "test_crate",
501            &i18n_path,
502            vec![type_info],
503            FluentParseMode::Conservative,
504        );
505        assert!(result.is_ok());
506
507        let content = fs::read_to_string(&ftl_file_path).unwrap();
508        assert!(content.contains("existing-message"));
509        assert!(content.contains("TestEnum"));
510        assert!(content.contains("Variant1"));
511    }
512    #[test]
513    fn test_generate_clean_mode() {
514        let temp_dir = TempDir::new().unwrap();
515        let i18n_path = temp_dir.path().join("i18n");
516
517        let ftl_file_path = i18n_path.join("test_crate.ftl");
518        fs::create_dir_all(&i18n_path).unwrap();
519
520        let initial_content = "
521## OrphanGroup
522
523what-Hi = Hi
524awdawd = awdwa
525
526## ExistingGroup
527
528existing-key = Existing Value
529";
530        fs::write(&ftl_file_path, initial_content).unwrap();
531
532        // Define items that match ExistingGroup but NOT OrphanGroup
533        let ftl_key = FluentKey::new(
534            &Ident::new("ExistingGroup", proc_macro2::Span::call_site()),
535            "ExistingKey",
536        );
537        let variant = FtlVariant {
538            name: "ExistingKey".to_string(),
539            ftl_key,
540            args: Vec::new(),
541        };
542
543        let type_info = FtlTypeInfo {
544            type_kind: TypeKind::Enum,
545            type_name: "ExistingGroup".to_string(),
546            variants: vec![variant],
547            file_path: None,
548        };
549
550        let result = generate(
551            "test_crate",
552            &i18n_path,
553            vec![type_info],
554            FluentParseMode::Clean,
555        );
556        assert!(result.is_ok());
557
558        let content = fs::read_to_string(&ftl_file_path).unwrap();
559
560        // Should NOT contain orphan content
561        assert!(!content.contains("## OrphanGroup"));
562        assert!(!content.contains("what-Hi"));
563        assert!(!content.contains("awdawd"));
564
565        // Should contain existing content that is still valid
566        assert!(content.contains("## ExistingGroup"));
567    }
568}