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::{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>, I: AsRef<FtlTypeInfo>>(
72    crate_name: &str,
73    i18n_path: P,
74    items: &[I],
75    mode: FluentParseMode,
76    dry_run: bool,
77) -> EsFluentResult<bool> {
78    let i18n_path = i18n_path.as_ref();
79    let items_ref: Vec<&FtlTypeInfo> = items.iter().map(|i| i.as_ref()).collect();
80
81    // Group items by namespace
82    let mut namespaced: IndexMap<Option<&str>, Vec<&FtlTypeInfo>> = IndexMap::new();
83    for item in &items_ref {
84        namespaced.entry(item.namespace).or_default().push(item);
85    }
86
87    let mut any_changed = false;
88
89    for (namespace, ns_items) in namespaced {
90        let (dir_path, file_path) = match namespace {
91            Some(ns) => {
92                // Namespaced items go to {i18n_path}/{crate_name}/{namespace}.ftl
93                let dir = i18n_path.join(crate_name);
94                let file = dir.join(format!("{}.ftl", ns));
95                (dir, file)
96            },
97            None => {
98                // Non-namespaced items go to {i18n_path}/{crate_name}.ftl
99                (
100                    i18n_path.to_path_buf(),
101                    i18n_path.join(format!("{}.ftl", crate_name)),
102                )
103            },
104        };
105
106        if !dry_run {
107            fs::create_dir_all(&dir_path)?;
108        }
109
110        let existing_resource = read_existing_resource(&file_path)?;
111
112        let final_resource = if matches!(mode, FluentParseMode::Aggressive) {
113            build_target_resource(&ns_items)
114        } else {
115            smart_merge(existing_resource, &ns_items, MergeBehavior::Append)
116        };
117
118        if write_updated_resource(
119            &file_path,
120            &final_resource,
121            dry_run,
122            formatting::sort_ftl_resource,
123        )? {
124            any_changed = true;
125        }
126    }
127
128    Ok(any_changed)
129}
130
131pub(crate) fn print_diff(old: &str, new: &str) {
132    use colored::Colorize as _;
133    use similar::{ChangeTag, TextDiff};
134
135    let diff = TextDiff::from_lines(old, new);
136
137    for (idx, group) in diff.grouped_ops(3).iter().enumerate() {
138        if idx > 0 {
139            println!("{}", "  ...".dimmed());
140        }
141        for op in group {
142            for change in diff.iter_changes(op) {
143                let sign = match change.tag() {
144                    ChangeTag::Delete => "-",
145                    ChangeTag::Insert => "+",
146                    ChangeTag::Equal => " ",
147                };
148                let line = format!("{} {}", sign, change);
149                match change.tag() {
150                    ChangeTag::Delete => print!("{}", line.red()),
151                    ChangeTag::Insert => print!("{}", line.green()),
152                    ChangeTag::Equal => print!("{}", line.dimmed()),
153                }
154            }
155        }
156    }
157}
158
159/// Read and parse an existing FTL resource file.
160///
161/// Returns an empty resource if the file doesn't exist or is empty.
162/// Logs warnings for parsing errors but continues with partial parse.
163fn read_existing_resource(file_path: &Path) -> EsFluentResult<ast::Resource<String>> {
164    if !file_path.exists() {
165        return Ok(ast::Resource { body: Vec::new() });
166    }
167
168    let content = fs::read_to_string(file_path)?;
169    if content.trim().is_empty() {
170        return Ok(ast::Resource { body: Vec::new() });
171    }
172
173    match parser::parse(content) {
174        Ok(res) => Ok(res),
175        Err((res, errors)) => {
176            tracing::warn!(
177                "Warning: Encountered parsing errors in {}: {:?}",
178                file_path.display(),
179                errors
180            );
181            Ok(res)
182        },
183    }
184}
185
186/// Write an updated resource to disk, handling change detection and dry-run mode.
187///
188/// Returns `true` if the file was changed (or would be changed in dry-run mode).
189fn write_updated_resource(
190    file_path: &Path,
191    resource: &ast::Resource<String>,
192    dry_run: bool,
193    formatter: impl Fn(&ast::Resource<String>) -> String,
194) -> EsFluentResult<bool> {
195    let is_empty = resource.body.is_empty();
196    let final_content = if is_empty {
197        String::new()
198    } else {
199        formatter(resource)
200    };
201
202    let current_content = if file_path.exists() {
203        fs::read_to_string(file_path)?
204    } else {
205        String::new()
206    };
207
208    // Determine if content has changed
209    let has_changed = match is_empty {
210        true => current_content != final_content && !current_content.trim().is_empty(),
211        false => current_content.trim() != final_content.trim(),
212    };
213
214    if !has_changed {
215        log_unchanged(file_path, is_empty, dry_run);
216        return Ok(false);
217    }
218
219    write_or_preview(
220        file_path,
221        &current_content,
222        &final_content,
223        is_empty,
224        dry_run,
225    )?;
226    Ok(true)
227}
228
229/// Log that a file was unchanged (only when not in dry-run mode).
230fn log_unchanged(file_path: &Path, is_empty: bool, dry_run: bool) {
231    if dry_run {
232        return;
233    }
234    let msg = match is_empty {
235        true => format!(
236            "FTL file unchanged (empty or no items): {}",
237            file_path.display()
238        ),
239        false => format!("FTL file unchanged: {}", file_path.display()),
240    };
241    tracing::debug!("{}", msg);
242}
243
244/// Write changes to disk or preview them in dry-run mode.
245fn write_or_preview(
246    file_path: &Path,
247    current_content: &str,
248    final_content: &str,
249    is_empty: bool,
250    dry_run: bool,
251) -> EsFluentResult<()> {
252    if dry_run {
253        let display_path = fs::canonicalize(file_path).unwrap_or_else(|_| file_path.to_path_buf());
254        let msg = match (is_empty, !current_content.trim().is_empty()) {
255            (true, true) => format!(
256                "Would write empty FTL file (no items): {}",
257                display_path.display()
258            ),
259            (true, false) => format!("Would write empty FTL file: {}", display_path.display()),
260            (false, _) => format!("Would update FTL file: {}", display_path.display()),
261        };
262        println!("{}", msg);
263        print_diff(current_content, final_content);
264        println!();
265        return Ok(());
266    }
267
268    fs::write(file_path, final_content)?;
269    let msg = match is_empty {
270        true => format!("Wrote empty FTL file (no items): {}", file_path.display()),
271        false => format!("Updated FTL file: {}", file_path.display()),
272    };
273    tracing::info!("{}", msg);
274    Ok(())
275}
276
277/// Compares two type infos, putting "this" types first.
278fn compare_type_infos(a: &OwnedTypeInfo, b: &OwnedTypeInfo) -> std::cmp::Ordering {
279    // Infer is_this from variants
280    let a_is_this = a
281        .variants
282        .iter()
283        .any(|v| v.ftl_key.ends_with(FluentKey::THIS_SUFFIX));
284    let b_is_this = b
285        .variants
286        .iter()
287        .any(|v| v.ftl_key.ends_with(FluentKey::THIS_SUFFIX));
288
289    formatting::compare_with_this_priority(a_is_this, &a.type_name, b_is_this, &b.type_name)
290}
291
292#[derive(Clone, Copy, Debug, PartialEq)]
293pub(crate) enum MergeBehavior {
294    /// Add new keys and preserve existing ones.
295    Append,
296    /// Remove orphan keys and empty groups, do not add new keys.
297    Clean,
298}
299
300pub(crate) fn smart_merge(
301    existing: ast::Resource<String>,
302    items: &[&FtlTypeInfo],
303    behavior: MergeBehavior,
304) -> ast::Resource<String> {
305    let mut pending_items = merge_ftl_type_infos(items);
306    pending_items.sort_by(compare_type_infos);
307
308    let mut item_map: IndexMap<String, OwnedTypeInfo> = pending_items
309        .into_iter()
310        .map(|i| (i.type_name.clone(), i))
311        .collect();
312    let mut key_to_group: IndexMap<String, String> = IndexMap::new();
313    for (group_name, info) in &item_map {
314        for variant in &info.variants {
315            key_to_group.insert(variant.ftl_key.clone(), group_name.clone());
316        }
317    }
318    let mut relocated_by_group: IndexMap<String, Vec<ast::Entry<String>>> = IndexMap::new();
319
320    let mut new_body = Vec::new();
321    let mut current_group_name: Option<String> = None;
322    let cleanup = matches!(behavior, MergeBehavior::Clean);
323
324    for entry in existing.body {
325        match entry {
326            ast::Entry::GroupComment(ref comment) => {
327                if let Some(ref old_group) = current_group_name
328                    && let Some(info) = item_map.get_mut(old_group)
329                {
330                    // Only append missing variants if we are appending
331                    if matches!(behavior, MergeBehavior::Append) {
332                        if let Some(entries) = relocated_by_group.shift_remove(old_group) {
333                            new_body.extend(entries);
334                        }
335                        if !info.variants.is_empty() {
336                            for variant in &info.variants {
337                                new_body.push(create_message_entry(variant));
338                            }
339                        }
340                    }
341                    info.variants.clear();
342                }
343
344                if let Some(content) = comment.content.first() {
345                    let trimmed = content.trim();
346                    current_group_name = Some(trimmed.to_string());
347                } else {
348                    current_group_name = None;
349                }
350
351                let keep_group = if let Some(ref group_name) = current_group_name {
352                    !cleanup || item_map.contains_key(group_name)
353                } else {
354                    true
355                };
356
357                if keep_group {
358                    new_body.push(entry);
359                }
360            },
361            ast::Entry::Message(msg) => {
362                let key = msg.id.name.clone();
363                let mut handled = false;
364                let mut relocate_to: Option<String> = None;
365
366                if let Some(ref group_name) = current_group_name
367                    && let Some(info) = item_map.get_mut(group_name)
368                    && let Some(idx) = info.variants.iter().position(|v| v.ftl_key == key)
369                {
370                    info.variants.remove(idx);
371                    handled = true;
372                }
373
374                if !handled
375                    && let Some(expected_group) = key_to_group.get(&key)
376                    && matches!(behavior, MergeBehavior::Append)
377                    && current_group_name.as_deref() != Some(expected_group.as_str())
378                    && let Some(info) = item_map.get_mut(expected_group)
379                    && let Some(idx) = info.variants.iter().position(|v| v.ftl_key == key)
380                {
381                    info.variants.remove(idx);
382                    relocate_to = Some(expected_group.clone());
383                }
384
385                if relocate_to.is_none() && !handled {
386                    for info in item_map.values_mut() {
387                        if let Some(idx) = info.variants.iter().position(|v| v.ftl_key == key) {
388                            info.variants.remove(idx);
389                            handled = true;
390                            break;
391                        }
392                    }
393                }
394
395                if let Some(group_name) = relocate_to {
396                    relocated_by_group
397                        .entry(group_name)
398                        .or_default()
399                        .push(ast::Entry::Message(msg));
400                } else if handled || !cleanup {
401                    new_body.push(ast::Entry::Message(msg));
402                }
403            },
404            ast::Entry::Term(ref term) => {
405                let key = format!("{}{}", FluentKey::DELIMITER, term.id.name);
406                let mut handled = false;
407                for info in item_map.values_mut() {
408                    if let Some(idx) = info.variants.iter().position(|v| v.ftl_key == key) {
409                        info.variants.remove(idx);
410                        handled = true;
411                        break;
412                    }
413                }
414
415                if handled || !cleanup {
416                    new_body.push(entry);
417                }
418            },
419            ast::Entry::Junk { .. } => {
420                new_body.push(entry);
421            },
422            _ => {
423                new_body.push(entry);
424            },
425        }
426    }
427
428    // Correctly handle the end of the last group
429    if let Some(ref last_group) = current_group_name
430        && let Some(info) = item_map.get_mut(last_group)
431    {
432        // Only append missing variants if we are appending
433        if matches!(behavior, MergeBehavior::Append) {
434            if let Some(entries) = relocated_by_group.shift_remove(last_group) {
435                new_body.extend(entries);
436            }
437            if !info.variants.is_empty() {
438                for variant in &info.variants {
439                    new_body.push(create_message_entry(variant));
440                }
441            }
442        }
443        info.variants.clear();
444    }
445
446    // Only append remaining new groups if we are appending
447    if matches!(behavior, MergeBehavior::Append) {
448        let mut remaining_groups: Vec<_> = item_map.into_iter().collect();
449        remaining_groups.sort_by(|(_, a), (_, b)| compare_type_infos(a, b));
450
451        for (type_name, info) in remaining_groups {
452            let relocated = relocated_by_group.shift_remove(&type_name);
453            if !info.variants.is_empty() || relocated.is_some() {
454                new_body.push(create_group_comment_entry(&type_name));
455                if let Some(entries) = relocated {
456                    new_body.extend(entries);
457                }
458                for variant in info.variants {
459                    new_body.push(create_message_entry(&variant));
460                }
461            }
462        }
463    }
464
465    ast::Resource { body: new_body }
466}
467
468fn create_group_comment_entry(type_name: &str) -> ast::Entry<String> {
469    ast::Entry::GroupComment(ast::Comment {
470        content: vec![type_name.to_owned()],
471    })
472}
473
474fn create_message_entry(variant: &OwnedVariant) -> ast::Entry<String> {
475    let message_id = ast::Identifier {
476        name: variant.ftl_key.clone(),
477    };
478
479    let base_value = ValueFormatter::expand(&variant.name);
480
481    let mut elements = vec![ast::PatternElement::TextElement { value: base_value }];
482
483    for arg_name in &variant.args {
484        elements.push(ast::PatternElement::TextElement { value: " ".into() });
485
486        elements.push(ast::PatternElement::Placeable {
487            expression: ast::Expression::Inline(ast::InlineExpression::VariableReference {
488                id: ast::Identifier {
489                    name: arg_name.clone(),
490                },
491            }),
492        });
493    }
494
495    let pattern = ast::Pattern { elements };
496
497    ast::Entry::Message(ast::Message {
498        id: message_id,
499        value: Some(pattern),
500        attributes: Vec::new(),
501        comment: None,
502    })
503}
504
505fn merge_ftl_type_infos(items: &[&FtlTypeInfo]) -> Vec<OwnedTypeInfo> {
506    use std::collections::BTreeMap;
507
508    // Group by type_name, but only merge items with the same namespace
509    // This prevents merging types that have the same name but different namespaces
510    let mut grouped: BTreeMap<(Option<String>, String), Vec<OwnedVariant>> = BTreeMap::new();
511
512    for item in items {
513        let key = (
514            item.namespace.map(|s| s.to_string()),
515            item.type_name.to_string(),
516        );
517        let entry = grouped.entry(key).or_default();
518        entry.extend(item.variants.iter().map(OwnedVariant::from));
519    }
520
521    grouped
522        .into_iter()
523        .map(|((_, type_name), mut variants)| {
524            variants.sort_by(|a, b| {
525                let a_is_this = a.ftl_key.ends_with(FluentKey::THIS_SUFFIX);
526                let b_is_this = b.ftl_key.ends_with(FluentKey::THIS_SUFFIX);
527                formatting::compare_with_this_priority(a_is_this, &a.name, b_is_this, &b.name)
528            });
529            variants.dedup();
530
531            OwnedTypeInfo {
532                type_name,
533                variants,
534            }
535        })
536        .collect()
537}
538
539fn build_target_resource(items: &[&FtlTypeInfo]) -> ast::Resource<String> {
540    let items = merge_ftl_type_infos(items);
541    let mut body: Vec<ast::Entry<String>> = Vec::new();
542    let mut sorted_items = items.to_vec();
543    sorted_items.sort_by(compare_type_infos);
544
545    for info in &sorted_items {
546        body.push(create_group_comment_entry(&info.type_name));
547
548        for variant in &info.variants {
549            body.push(create_message_entry(variant));
550        }
551    }
552
553    ast::Resource { body }
554}