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