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#[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) -> 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 build_target_resource(&items_ref)
93 } else {
94 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
134fn 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
161fn 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 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 ¤t_content,
197 &final_content,
198 is_empty,
199 dry_run,
200 )?;
201 Ok(true)
202}
203
204fn 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
219fn 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
252fn compare_type_infos(a: &OwnedTypeInfo, b: &OwnedTypeInfo) -> std::cmp::Ordering {
254 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 Append,
271 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 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 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 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 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 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}