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#[derive(Clone, Debug, Default, PartialEq, ValueEnum)]
20pub enum FluentParseMode {
21 Aggressive,
23 #[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#[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
70pub 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 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 let dir = i18n_path.join(crate_name);
97 let file = dir.join(format!("{}.ftl", ns));
98 (dir, file)
99 },
100 None => {
101 (
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
162fn 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
189fn 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 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 ¤t_content,
225 &final_content,
226 is_empty,
227 dry_run,
228 )?;
229 Ok(true)
230}
231
232fn 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
247fn 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
280fn compare_type_infos(a: &OwnedTypeInfo, b: &OwnedTypeInfo) -> std::cmp::Ordering {
282 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 Append,
299 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 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 if let Some(ref last_group) = current_group_name
455 && let Some(info) = item_map.get_mut(last_group)
456 {
457 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 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 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}