Skip to main content

es_fluent_generate/
lib.rs

1#![doc = include_str!("../README.md")]
2
3use clap::ValueEnum;
4use es_fluent_derive_core::namer::FluentKey;
5use es_fluent_derive_core::registry::{FtlTypeInfo, FtlVariant};
6use fluent_syntax::{ast, parser};
7use indexmap::IndexMap;
8use std::{collections::HashSet, fs, path::Path};
9
10pub mod clean;
11pub mod error;
12pub mod formatting;
13pub mod value;
14
15use es_fluent_derive_core::EsFluentResult;
16use value::ValueFormatter;
17
18/// The mode to use when parsing Fluent files.
19#[derive(Clone, Debug, Default, PartialEq, ValueEnum)]
20pub enum FluentParseMode {
21    /// Overwrite existing translations.
22    Aggressive,
23    /// Preserve existing translations.
24    #[default]
25    Conservative,
26}
27
28impl std::fmt::Display for FluentParseMode {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        match self {
31            Self::Aggressive => write!(f, "aggressive"),
32            Self::Conservative => write!(f, "conservative"),
33        }
34    }
35}
36
37// Internal owned types for merge operations
38#[derive(Clone, Debug, Eq, Hash, PartialEq)]
39struct OwnedVariant {
40    name: String,
41    ftl_key: String,
42    args: Vec<String>,
43}
44
45impl From<&FtlVariant> for OwnedVariant {
46    fn from(v: &FtlVariant) -> Self {
47        Self {
48            name: v.name.to_string(),
49            ftl_key: v.ftl_key.to_string(),
50            args: v.args.iter().map(|s| s.to_string()).collect(),
51        }
52    }
53}
54
55#[derive(Clone, Debug)]
56struct OwnedTypeInfo {
57    type_name: String,
58    variants: Vec<OwnedVariant>,
59}
60
61impl From<&FtlTypeInfo> for OwnedTypeInfo {
62    fn from(info: &FtlTypeInfo) -> Self {
63        Self {
64            type_name: info.type_name.to_string(),
65            variants: info.variants.iter().map(OwnedVariant::from).collect(),
66        }
67    }
68}
69
70/// Generates a Fluent translation file from a list of `FtlTypeInfo` objects.
71pub fn generate<P: AsRef<Path>, M: AsRef<Path>, I: AsRef<FtlTypeInfo>>(
72    crate_name: &str,
73    i18n_path: P,
74    manifest_dir: M,
75    items: &[I],
76    mode: FluentParseMode,
77    dry_run: bool,
78) -> EsFluentResult<bool> {
79    let i18n_path = i18n_path.as_ref();
80    let manifest_dir = manifest_dir.as_ref();
81    let items_ref: Vec<&FtlTypeInfo> = items.iter().map(|i| i.as_ref()).collect();
82
83    // Group items by namespace
84    let mut namespaced: IndexMap<Option<String>, Vec<&FtlTypeInfo>> = IndexMap::new();
85    for item in &items_ref {
86        let namespace = item.resolved_namespace(manifest_dir);
87        namespaced.entry(namespace).or_default().push(item);
88    }
89
90    let mut any_changed = false;
91
92    for (namespace, ns_items) in namespaced {
93        let (dir_path, file_path) = match namespace {
94            Some(ns) => {
95                // Namespaced items go to {i18n_path}/{crate_name}/{namespace}.ftl
96                let dir = i18n_path.join(crate_name);
97                let file = dir.join(format!("{}.ftl", ns));
98                (dir, file)
99            },
100            None => {
101                // Non-namespaced items go to {i18n_path}/{crate_name}.ftl
102                (
103                    i18n_path.to_path_buf(),
104                    i18n_path.join(format!("{}.ftl", crate_name)),
105                )
106            },
107        };
108
109        if !dry_run {
110            fs::create_dir_all(&dir_path)?;
111        }
112
113        let existing_resource = read_existing_resource(&file_path)?;
114
115        let final_resource = if matches!(mode, FluentParseMode::Aggressive) {
116            build_target_resource(&ns_items)
117        } else {
118            smart_merge(existing_resource, &ns_items, MergeBehavior::Append)
119        };
120
121        if write_updated_resource(
122            &file_path,
123            &final_resource,
124            dry_run,
125            formatting::sort_ftl_resource,
126        )? {
127            any_changed = true;
128        }
129    }
130
131    Ok(any_changed)
132}
133
134pub(crate) fn print_diff(old: &str, new: &str) {
135    use colored::Colorize as _;
136    use similar::{ChangeTag, TextDiff};
137
138    let diff = TextDiff::from_lines(old, new);
139
140    for (idx, group) in diff.grouped_ops(3).iter().enumerate() {
141        if idx > 0 {
142            println!("{}", "  ...".dimmed());
143        }
144        for op in group {
145            for change in diff.iter_changes(op) {
146                let sign = match change.tag() {
147                    ChangeTag::Delete => "-",
148                    ChangeTag::Insert => "+",
149                    ChangeTag::Equal => " ",
150                };
151                let line = format!("{} {}", sign, change);
152                match change.tag() {
153                    ChangeTag::Delete => print!("{}", line.red()),
154                    ChangeTag::Insert => print!("{}", line.green()),
155                    ChangeTag::Equal => print!("{}", line.dimmed()),
156                }
157            }
158        }
159    }
160}
161
162/// Read and parse an existing FTL resource file.
163///
164/// Returns an empty resource if the file doesn't exist or is empty.
165/// Logs warnings for parsing errors but continues with partial parse.
166fn read_existing_resource(file_path: &Path) -> EsFluentResult<ast::Resource<String>> {
167    if !file_path.exists() {
168        return Ok(ast::Resource { body: Vec::new() });
169    }
170
171    let content = fs::read_to_string(file_path)?;
172    if content.trim().is_empty() {
173        return Ok(ast::Resource { body: Vec::new() });
174    }
175
176    match parser::parse(content) {
177        Ok(res) => Ok(res),
178        Err((res, errors)) => {
179            tracing::warn!(
180                "Warning: Encountered parsing errors in {}: {:?}",
181                file_path.display(),
182                errors
183            );
184            Ok(res)
185        },
186    }
187}
188
189/// Write an updated resource to disk, handling change detection and dry-run mode.
190///
191/// Returns `true` if the file was changed (or would be changed in dry-run mode).
192fn write_updated_resource(
193    file_path: &Path,
194    resource: &ast::Resource<String>,
195    dry_run: bool,
196    formatter: impl Fn(&ast::Resource<String>) -> String,
197) -> EsFluentResult<bool> {
198    let is_empty = resource.body.is_empty();
199    let final_content = if is_empty {
200        String::new()
201    } else {
202        formatter(resource)
203    };
204
205    let current_content = if file_path.exists() {
206        fs::read_to_string(file_path)?
207    } else {
208        String::new()
209    };
210
211    // Determine if content has changed
212    let has_changed = match is_empty {
213        true => current_content != final_content && !current_content.trim().is_empty(),
214        false => current_content.trim() != final_content.trim(),
215    };
216
217    if !has_changed {
218        log_unchanged(file_path, is_empty, dry_run);
219        return Ok(false);
220    }
221
222    write_or_preview(
223        file_path,
224        &current_content,
225        &final_content,
226        is_empty,
227        dry_run,
228    )?;
229    Ok(true)
230}
231
232/// Log that a file was unchanged (only when not in dry-run mode).
233fn log_unchanged(file_path: &Path, is_empty: bool, dry_run: bool) {
234    if dry_run {
235        return;
236    }
237    let msg = match is_empty {
238        true => format!(
239            "FTL file unchanged (empty or no items): {}",
240            file_path.display()
241        ),
242        false => format!("FTL file unchanged: {}", file_path.display()),
243    };
244    tracing::debug!("{}", msg);
245}
246
247/// Write changes to disk or preview them in dry-run mode.
248fn write_or_preview(
249    file_path: &Path,
250    current_content: &str,
251    final_content: &str,
252    is_empty: bool,
253    dry_run: bool,
254) -> EsFluentResult<()> {
255    if dry_run {
256        let display_path = fs::canonicalize(file_path).unwrap_or_else(|_| file_path.to_path_buf());
257        let msg = match (is_empty, !current_content.trim().is_empty()) {
258            (true, true) => format!(
259                "Would write empty FTL file (no items): {}",
260                display_path.display()
261            ),
262            (true, false) => format!("Would write empty FTL file: {}", display_path.display()),
263            (false, _) => format!("Would update FTL file: {}", display_path.display()),
264        };
265        println!("{}", msg);
266        print_diff(current_content, final_content);
267        println!();
268        return Ok(());
269    }
270
271    fs::write(file_path, final_content)?;
272    let msg = match is_empty {
273        true => format!("Wrote empty FTL file (no items): {}", file_path.display()),
274        false => format!("Updated FTL file: {}", file_path.display()),
275    };
276    tracing::info!("{}", msg);
277    Ok(())
278}
279
280/// Compares two type infos, putting "this" types first.
281fn compare_type_infos(a: &OwnedTypeInfo, b: &OwnedTypeInfo) -> std::cmp::Ordering {
282    // Infer is_this from variants
283    let a_is_this = a
284        .variants
285        .iter()
286        .any(|v| v.ftl_key.ends_with(FluentKey::THIS_SUFFIX));
287    let b_is_this = b
288        .variants
289        .iter()
290        .any(|v| v.ftl_key.ends_with(FluentKey::THIS_SUFFIX));
291
292    formatting::compare_with_this_priority(a_is_this, &a.type_name, b_is_this, &b.type_name)
293}
294
295#[derive(Clone, Copy, Debug, PartialEq)]
296pub(crate) enum MergeBehavior {
297    /// Add new keys and preserve existing ones.
298    Append,
299    /// Remove orphan keys and empty groups, do not add new keys.
300    Clean,
301}
302
303pub(crate) fn smart_merge(
304    existing: ast::Resource<String>,
305    items: &[&FtlTypeInfo],
306    behavior: MergeBehavior,
307) -> ast::Resource<String> {
308    let mut pending_items = merge_ftl_type_infos(items);
309    pending_items.sort_by(compare_type_infos);
310
311    let mut item_map: IndexMap<String, OwnedTypeInfo> = pending_items
312        .into_iter()
313        .map(|i| (i.type_name.clone(), i))
314        .collect();
315    let mut key_to_group: IndexMap<String, String> = IndexMap::new();
316    for (group_name, info) in &item_map {
317        for variant in &info.variants {
318            key_to_group.insert(variant.ftl_key.clone(), group_name.clone());
319        }
320    }
321    let mut relocated_by_group: IndexMap<String, Vec<ast::Entry<String>>> = IndexMap::new();
322    let mut late_relocated_by_group: IndexMap<String, Vec<ast::Entry<String>>> = IndexMap::new();
323    let mut seen_groups: HashSet<String> = HashSet::new();
324    let existing_keys = collect_existing_keys(&existing);
325    let mut seen_keys: HashSet<String> = HashSet::new();
326
327    let mut new_body = Vec::new();
328    let mut current_group_name: Option<String> = None;
329    let cleanup = matches!(behavior, MergeBehavior::Clean);
330
331    for entry in existing.body {
332        match entry {
333            ast::Entry::GroupComment(ref comment) => {
334                if let Some(ref old_group) = current_group_name
335                    && let Some(info) = item_map.get_mut(old_group)
336                {
337                    // Only append missing variants if we are appending
338                    if matches!(behavior, MergeBehavior::Append) {
339                        if let Some(entries) = relocated_by_group.shift_remove(old_group) {
340                            new_body.extend(entries);
341                        }
342                        if !info.variants.is_empty() {
343                            for variant in &info.variants {
344                                if !existing_keys.contains(&variant.ftl_key) {
345                                    seen_keys.insert(variant.ftl_key.clone());
346                                    new_body.push(create_message_entry(variant));
347                                }
348                            }
349                        }
350                    }
351                    info.variants.clear();
352                }
353
354                if let Some(content) = comment.content.first() {
355                    let trimmed = content.trim();
356                    current_group_name = Some(trimmed.to_string());
357                } else {
358                    current_group_name = None;
359                }
360
361                let keep_group = if let Some(ref group_name) = current_group_name {
362                    !cleanup || item_map.contains_key(group_name)
363                } else {
364                    true
365                };
366
367                if keep_group {
368                    new_body.push(entry);
369                }
370
371                if let Some(ref group_name) = current_group_name {
372                    seen_groups.insert(group_name.clone());
373                }
374            },
375            ast::Entry::Message(msg) => {
376                let key = msg.id.name.clone();
377                let mut handled = false;
378                let mut relocate_to: Option<String> = None;
379
380                if seen_keys.contains(&key) {
381                    continue;
382                }
383
384                if let Some(expected_group) = key_to_group.get(&key).cloned() {
385                    if current_group_name.as_deref() != Some(expected_group.as_str())
386                        && matches!(behavior, MergeBehavior::Append)
387                    {
388                        relocate_to = Some(expected_group.clone());
389                    }
390                    handled = true;
391
392                    if let Some(info) = item_map.get_mut(&expected_group)
393                        && let Some(idx) = info.variants.iter().position(|v| v.ftl_key == key)
394                    {
395                        info.variants.remove(idx);
396                    }
397                } else if !handled {
398                    for info in item_map.values_mut() {
399                        if let Some(idx) = info.variants.iter().position(|v| v.ftl_key == key) {
400                            info.variants.remove(idx);
401                            handled = true;
402                            break;
403                        }
404                    }
405                }
406
407                if let Some(group_name) = relocate_to {
408                    seen_keys.insert(key);
409                    if seen_groups.contains(&group_name) {
410                        late_relocated_by_group
411                            .entry(group_name)
412                            .or_default()
413                            .push(ast::Entry::Message(msg));
414                    } else {
415                        relocated_by_group
416                            .entry(group_name)
417                            .or_default()
418                            .push(ast::Entry::Message(msg));
419                    }
420                } else if handled || !cleanup {
421                    seen_keys.insert(key);
422                    new_body.push(ast::Entry::Message(msg));
423                }
424            },
425            ast::Entry::Term(ref term) => {
426                let key = format!("{}{}", FluentKey::DELIMITER, term.id.name);
427                let mut handled = false;
428                if seen_keys.contains(&key) {
429                    continue;
430                }
431                for info in item_map.values_mut() {
432                    if let Some(idx) = info.variants.iter().position(|v| v.ftl_key == key) {
433                        info.variants.remove(idx);
434                        handled = true;
435                        break;
436                    }
437                }
438
439                if handled || !cleanup {
440                    seen_keys.insert(key);
441                    new_body.push(entry);
442                }
443            },
444            ast::Entry::Junk { .. } => {
445                new_body.push(entry);
446            },
447            _ => {
448                new_body.push(entry);
449            },
450        }
451    }
452
453    // Correctly handle the end of the last group
454    if let Some(ref last_group) = current_group_name
455        && let Some(info) = item_map.get_mut(last_group)
456    {
457        // Only append missing variants if we are appending
458        if matches!(behavior, MergeBehavior::Append) {
459            if let Some(entries) = relocated_by_group.shift_remove(last_group) {
460                new_body.extend(entries);
461            }
462            if !info.variants.is_empty() {
463                for variant in &info.variants {
464                    if !existing_keys.contains(&variant.ftl_key) {
465                        seen_keys.insert(variant.ftl_key.clone());
466                        new_body.push(create_message_entry(variant));
467                    }
468                }
469            }
470        }
471        info.variants.clear();
472    }
473
474    // Only append remaining new groups if we are appending
475    if matches!(behavior, MergeBehavior::Append) {
476        let mut remaining_groups: Vec<_> = item_map.into_iter().collect();
477        remaining_groups.sort_by(|(_, a), (_, b)| compare_type_infos(a, b));
478
479        for (type_name, info) in remaining_groups {
480            let relocated = relocated_by_group.shift_remove(&type_name);
481            let has_missing = info
482                .variants
483                .iter()
484                .any(|variant| !existing_keys.contains(&variant.ftl_key));
485            if has_missing || relocated.is_some() {
486                new_body.push(create_group_comment_entry(&type_name));
487                if let Some(entries) = relocated {
488                    new_body.extend(entries);
489                }
490                for variant in info.variants {
491                    if !existing_keys.contains(&variant.ftl_key) {
492                        seen_keys.insert(variant.ftl_key.clone());
493                        new_body.push(create_message_entry(&variant));
494                    }
495                }
496            }
497        }
498    }
499
500    let mut resource = ast::Resource { body: new_body };
501
502    if matches!(behavior, MergeBehavior::Append) && !late_relocated_by_group.is_empty() {
503        insert_late_relocated(&mut resource.body, &late_relocated_by_group);
504    }
505    if cleanup {
506        remove_empty_group_comments(resource)
507    } else {
508        resource
509    }
510}
511
512fn group_comment_name(comment: &ast::Comment<String>) -> Option<String> {
513    comment
514        .content
515        .first()
516        .map(|line| line.trim())
517        .filter(|line| !line.is_empty())
518        .map(|line| line.to_string())
519}
520
521fn collect_existing_keys(resource: &ast::Resource<String>) -> HashSet<String> {
522    let mut keys = HashSet::new();
523    for entry in &resource.body {
524        match entry {
525            ast::Entry::Message(msg) => {
526                keys.insert(msg.id.name.clone());
527            },
528            ast::Entry::Term(term) => {
529                keys.insert(format!("{}{}", FluentKey::DELIMITER, term.id.name));
530            },
531            _ => {},
532        }
533    }
534    keys
535}
536
537fn insert_late_relocated(
538    body: &mut Vec<ast::Entry<String>>,
539    late_relocated_by_group: &IndexMap<String, Vec<ast::Entry<String>>>,
540) {
541    let mut group_positions: Vec<(String, usize)> = Vec::new();
542    for (idx, entry) in body.iter().enumerate() {
543        if let ast::Entry::GroupComment(comment) = entry
544            && let Some(name) = group_comment_name(comment)
545        {
546            group_positions.push((name, idx));
547        }
548    }
549
550    if group_positions.is_empty() {
551        return;
552    }
553
554    let mut inserted: HashSet<String> = HashSet::new();
555    for (i, (name, _start)) in group_positions.iter().enumerate().rev() {
556        if inserted.contains(name) {
557            continue;
558        }
559        let end = if i + 1 < group_positions.len() {
560            group_positions[i + 1].1
561        } else {
562            body.len()
563        };
564        if let Some(entries) = late_relocated_by_group.get(name)
565            && !entries.is_empty()
566        {
567            body.splice(end..end, entries.clone());
568        }
569        inserted.insert(name.clone());
570    }
571}
572
573fn remove_empty_group_comments(resource: ast::Resource<String>) -> ast::Resource<String> {
574    let mut body: Vec<ast::Entry<String>> = Vec::with_capacity(resource.body.len());
575    let mut pending_group: Option<ast::Entry<String>> = None;
576    let mut pending_entries: Vec<ast::Entry<String>> = Vec::new();
577    let mut has_message = false;
578
579    let flush_pending = |body: &mut Vec<ast::Entry<String>>,
580                         pending_group: &mut Option<ast::Entry<String>>,
581                         pending_entries: &mut Vec<ast::Entry<String>>,
582                         has_message: &mut bool| {
583        if let Some(group_comment) = pending_group.take() {
584            if *has_message {
585                body.push(group_comment);
586            }
587            body.append(pending_entries);
588        }
589        *has_message = false;
590    };
591
592    for entry in resource.body {
593        match entry {
594            ast::Entry::GroupComment(_) => {
595                flush_pending(
596                    &mut body,
597                    &mut pending_group,
598                    &mut pending_entries,
599                    &mut has_message,
600                );
601                pending_group = Some(entry);
602                pending_entries = Vec::new();
603            },
604            ast::Entry::Message(_) | ast::Entry::Term(_) => {
605                if pending_group.is_some() {
606                    has_message = true;
607                    pending_entries.push(entry);
608                } else {
609                    body.push(entry);
610                }
611            },
612            _ => {
613                if pending_group.is_some() {
614                    pending_entries.push(entry);
615                } else {
616                    body.push(entry);
617                }
618            },
619        }
620    }
621
622    flush_pending(
623        &mut body,
624        &mut pending_group,
625        &mut pending_entries,
626        &mut has_message,
627    );
628
629    ast::Resource { body }
630}
631
632fn create_group_comment_entry(type_name: &str) -> ast::Entry<String> {
633    ast::Entry::GroupComment(ast::Comment {
634        content: vec![type_name.to_owned()],
635    })
636}
637
638fn create_message_entry(variant: &OwnedVariant) -> ast::Entry<String> {
639    let message_id = ast::Identifier {
640        name: variant.ftl_key.clone(),
641    };
642
643    let base_value = ValueFormatter::expand(&variant.name);
644
645    let mut elements = vec![ast::PatternElement::TextElement { value: base_value }];
646
647    for arg_name in &variant.args {
648        elements.push(ast::PatternElement::TextElement { value: " ".into() });
649
650        elements.push(ast::PatternElement::Placeable {
651            expression: ast::Expression::Inline(ast::InlineExpression::VariableReference {
652                id: ast::Identifier {
653                    name: arg_name.clone(),
654                },
655            }),
656        });
657    }
658
659    let pattern = ast::Pattern { elements };
660
661    ast::Entry::Message(ast::Message {
662        id: message_id,
663        value: Some(pattern),
664        attributes: Vec::new(),
665        comment: None,
666    })
667}
668
669fn merge_ftl_type_infos(items: &[&FtlTypeInfo]) -> Vec<OwnedTypeInfo> {
670    use std::collections::BTreeMap;
671
672    // Group by type_name. Callers already separate items by namespace.
673    let mut grouped: BTreeMap<String, Vec<OwnedVariant>> = BTreeMap::new();
674
675    for item in items {
676        let entry = grouped.entry(item.type_name.to_string()).or_default();
677        entry.extend(item.variants.iter().map(OwnedVariant::from));
678    }
679
680    grouped
681        .into_iter()
682        .map(|(type_name, mut variants)| {
683            variants.sort_by(|a, b| {
684                let a_is_this = a.ftl_key.ends_with(FluentKey::THIS_SUFFIX);
685                let b_is_this = b.ftl_key.ends_with(FluentKey::THIS_SUFFIX);
686                formatting::compare_with_this_priority(a_is_this, &a.name, b_is_this, &b.name)
687            });
688            variants.dedup();
689
690            OwnedTypeInfo {
691                type_name,
692                variants,
693            }
694        })
695        .collect()
696}
697
698fn build_target_resource(items: &[&FtlTypeInfo]) -> ast::Resource<String> {
699    let items = merge_ftl_type_infos(items);
700    let mut body: Vec<ast::Entry<String>> = Vec::new();
701    let mut sorted_items = items.to_vec();
702    sorted_items.sort_by(compare_type_infos);
703
704    for info in &sorted_items {
705        body.push(create_group_comment_entry(&info.type_name));
706
707        for variant in &info.variants {
708            body.push(create_message_entry(variant));
709        }
710    }
711
712    ast::Resource { body }
713}