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#[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>, 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 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 let dir = i18n_path.join(crate_name);
94 let file = dir.join(format!("{}.ftl", ns));
95 (dir, file)
96 },
97 None => {
98 (
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
159fn 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
186fn 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 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 ¤t_content,
222 &final_content,
223 is_empty,
224 dry_run,
225 )?;
226 Ok(true)
227}
228
229fn 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
244fn 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
277fn compare_type_infos(a: &OwnedTypeInfo, b: &OwnedTypeInfo) -> std::cmp::Ordering {
279 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 Append,
296 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 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 if let Some(ref last_group) = current_group_name
430 && let Some(info) = item_map.get_mut(last_group)
431 {
432 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 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 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}