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