1#![doc = include_str!("../README.md")]
2
3use clap::ValueEnum;
4use es_fluent_core::meta::TypeKind;
5use es_fluent_core::namer::FluentKey;
6use es_fluent_core::registry::{FtlTypeInfo, FtlVariant};
7use fluent_syntax::{ast, parser, serializer};
8use std::collections::HashMap;
9use std::{fs, path::Path};
10
11pub mod error;
12mod formatter;
13
14use error::FluentGenerateError;
15use formatter::value::ValueFormatter;
16
17#[derive(Clone, Debug, Default, PartialEq, ValueEnum)]
19pub enum FluentParseMode {
20 Aggressive,
22 #[default]
24 Conservative,
25 #[clap(skip)]
27 Clean,
28}
29
30pub fn generate<P: AsRef<Path>>(
32 crate_name: &str,
33 i18n_path: P,
34 items: Vec<FtlTypeInfo>,
35 mode: FluentParseMode,
36) -> Result<(), FluentGenerateError> {
37 let i18n_path = i18n_path.as_ref();
38
39 fs::create_dir_all(i18n_path)?;
40
41 let file_path = i18n_path.join(format!("{}.ftl", crate_name));
42
43 let existing_resource = if file_path.exists() {
44 let content = fs::read_to_string(&file_path)?;
45 if content.trim().is_empty() {
46 ast::Resource { body: Vec::new() }
47 } else {
48 match parser::parse(content) {
49 Ok(res) => res,
50 Err((res, errors)) => {
51 log::warn!(
52 "Warning: Encountered parsing errors in {}: {:?}",
53 file_path.display(),
54 errors
55 );
56 res
57 },
58 }
59 }
60 } else {
61 ast::Resource { body: Vec::new() }
62 };
63
64 let final_resource = if matches!(mode, FluentParseMode::Aggressive) {
65 let target_resource = build_target_resource(&items);
66
67 let mut existing_entries_map: HashMap<String, ast::Entry<String>> = HashMap::new();
68 for entry in existing_resource.body.into_iter() {
69 match &entry {
70 ast::Entry::Message(msg) => {
71 existing_entries_map.insert(msg.id.name.clone(), entry);
72 },
73 ast::Entry::Term(term) => {
74 existing_entries_map
75 .insert(format!("{}{}", FluentKey::DELIMITER, term.id.name), entry);
76 },
77 _ => {},
78 }
79 }
80
81 let mut merged_resource_body: Vec<ast::Entry<String>> = Vec::new();
82
83 for entry in target_resource.body {
84 merged_resource_body.push(entry);
85 }
86
87 ast::Resource {
88 body: merged_resource_body,
89 }
90 } else {
91 let cleanup = matches!(mode, FluentParseMode::Clean);
92 smart_merge(existing_resource, &items, cleanup)
93 };
94
95 if !final_resource.body.is_empty() {
96 let final_output = serializer::serialize(&final_resource);
97
98 let final_content_to_write = final_output.trim_end();
99
100 let current_content = if file_path.exists() {
101 fs::read_to_string(&file_path)?
102 } else {
103 String::new()
104 };
105
106 if current_content != final_content_to_write {
107 fs::write(&file_path, final_content_to_write)?;
108 log::error!("Updated FTL file: {}", file_path.display());
109 } else {
110 log::error!("FTL file unchanged: {}", file_path.display());
111 }
112 } else {
113 let final_content_to_write = "".to_string();
114 let current_content = if file_path.exists() {
115 fs::read_to_string(&file_path)?
116 } else {
117 String::new()
118 };
119
120 if current_content != final_content_to_write && !current_content.trim().is_empty() {
121 fs::write(&file_path, &final_content_to_write)?;
122 log::error!("Wrote empty FTL file (no items): {}", file_path.display());
123 } else {
124 if current_content != final_content_to_write {
125 fs::write(&file_path, &final_content_to_write)?;
126 }
127 log::error!(
128 "FTL file unchanged (empty or no items): {}",
129 file_path.display()
130 );
131 }
132 }
133
134 Ok(())
135}
136
137fn smart_merge(
138 existing: ast::Resource<String>,
139 items: &[FtlTypeInfo],
140 cleanup: bool,
141) -> ast::Resource<String> {
142 let mut pending_items = merge_ftl_type_infos(items);
143 pending_items.sort_by(|a, b| a.type_name.cmp(&b.type_name));
144
145 let mut item_map: HashMap<String, FtlTypeInfo> = pending_items
146 .into_iter()
147 .map(|i| (i.type_name.clone(), i))
148 .collect();
149
150 let mut new_body = Vec::new();
151 let mut current_group_name: Option<String> = None;
152
153 for entry in existing.body {
154 match entry {
155 ast::Entry::GroupComment(ref comment) => {
156 if let Some(ref old_group) = current_group_name
157 && let Some(info) = item_map.get_mut(old_group)
158 && !info.variants.is_empty()
159 {
160 for variant in &info.variants {
161 new_body.push(create_message_entry(variant));
162 }
163 info.variants.clear();
164 }
165
166 if let Some(content) = comment.content.first() {
167 let trimmed = content.trim();
168 current_group_name = Some(trimmed.to_string());
169 } else {
170 current_group_name = None;
171 }
172
173 let keep_group = if let Some(ref group_name) = current_group_name {
174 !cleanup || item_map.contains_key(group_name)
175 } else {
176 true
177 };
178
179 if keep_group {
180 new_body.push(entry);
181 }
182 },
183 ast::Entry::Message(ref msg) => {
184 let key = &msg.id.name;
185 let mut handled = false;
186
187 if let Some(ref group_name) = current_group_name
188 && let Some(info) = item_map.get_mut(group_name)
189 && let Some(idx) = info
190 .variants
191 .iter()
192 .position(|v| v.ftl_key.to_string() == *key)
193 {
194 info.variants.remove(idx);
195 handled = true;
196 }
197
198 if !handled {
199 for info in item_map.values_mut() {
200 if let Some(idx) = info
201 .variants
202 .iter()
203 .position(|v| v.ftl_key.to_string() == *key)
204 {
205 info.variants.remove(idx);
206 handled = true;
207 break;
208 }
209 }
210 }
211
212 if handled || !cleanup {
213 new_body.push(entry);
214 }
215 },
216 ast::Entry::Term(ref term) => {
217 let key = format!("{}{}", FluentKey::DELIMITER, term.id.name);
218 let mut handled = false;
219 for info in item_map.values_mut() {
220 if let Some(idx) = info
221 .variants
222 .iter()
223 .position(|v| v.ftl_key.to_string() == key)
224 {
225 info.variants.remove(idx);
226 handled = true;
227 break;
228 }
229 }
230
231 if handled || !cleanup {
232 new_body.push(entry);
233 }
234 },
235 ast::Entry::Junk { .. } => {
236 new_body.push(entry);
237 },
238 _ => {
239 new_body.push(entry);
240 },
241 }
242 }
243
244 if let Some(ref last_group) = current_group_name
246 && let Some(info) = item_map.get_mut(last_group)
247 && !info.variants.is_empty()
248 {
249 for variant in &info.variants {
250 new_body.push(create_message_entry(variant));
251 }
252 info.variants.clear();
253 }
254
255 let mut remaining_groups: Vec<_> = item_map.into_iter().collect();
256 remaining_groups.sort_by(|(na, _), (nb, _)| na.cmp(nb));
257
258 for (type_name, info) in remaining_groups {
259 if !info.variants.is_empty() {
260 new_body.push(create_group_comment_entry(&type_name));
261 for variant in info.variants {
262 new_body.push(create_message_entry(&variant));
263 }
264 }
265 }
266
267 ast::Resource { body: new_body }
268}
269
270fn create_group_comment_entry(type_name: &str) -> ast::Entry<String> {
271 ast::Entry::GroupComment(ast::Comment {
272 content: vec![type_name.to_owned()],
273 })
274}
275
276fn create_message_entry(variant: &FtlVariant) -> ast::Entry<String> {
277 let message_id = ast::Identifier {
278 name: variant.ftl_key.to_string(),
279 };
280
281 let base_value = ValueFormatter::expand(&variant.name);
282
283 let mut elements = vec![ast::PatternElement::TextElement { value: base_value }];
284
285 for arg_name in &variant.args {
286 elements.push(ast::PatternElement::TextElement { value: " ".into() });
287
288 elements.push(ast::PatternElement::Placeable {
289 expression: ast::Expression::Inline(ast::InlineExpression::VariableReference {
290 id: ast::Identifier {
291 name: arg_name.into(),
292 },
293 }),
294 });
295 }
296
297 let pattern = ast::Pattern { elements };
298
299 ast::Entry::Message(ast::Message {
300 id: message_id,
301 value: Some(pattern),
302 attributes: Vec::new(),
303 comment: None,
304 })
305}
306
307fn merge_ftl_type_infos(items: &[FtlTypeInfo]) -> Vec<FtlTypeInfo> {
308 use std::collections::BTreeMap;
309
310 let mut grouped: BTreeMap<String, (TypeKind, Vec<FtlVariant>)> = BTreeMap::new();
312
313 for item in items {
314 let entry = grouped
315 .entry(item.type_name.clone())
316 .or_insert_with(|| (item.type_kind.clone(), Vec::new()));
317 entry.1.extend(item.variants.clone());
318 }
319
320 grouped
321 .into_iter()
322 .map(|(type_name, (type_kind, mut variants))| {
323 variants.sort_by(|a, b| {
324 let a_is_this = !a.ftl_key.to_string().contains(FluentKey::DELIMITER);
326 let b_is_this = !b.ftl_key.to_string().contains(FluentKey::DELIMITER);
327
328 match (a_is_this, b_is_this) {
329 (true, false) => std::cmp::Ordering::Less,
330 (false, true) => std::cmp::Ordering::Greater,
331 _ => a.name.cmp(&b.name),
332 }
333 });
334 variants.dedup();
335
336 FtlTypeInfo {
337 type_kind,
338 type_name,
339 variants,
340 file_path: None,
341 }
342 })
343 .collect()
344}
345
346fn build_target_resource(items: &[FtlTypeInfo]) -> ast::Resource<String> {
347 let items = merge_ftl_type_infos(items);
348 let mut body: Vec<ast::Entry<String>> = Vec::new();
349 let mut sorted_items = items.to_vec();
350 sorted_items.sort_by(|a, b| a.type_name.cmp(&b.type_name));
351
352 for info in &sorted_items {
353 body.push(create_group_comment_entry(&info.type_name));
354
355 for variant in &info.variants {
356 body.push(create_message_entry(variant));
357 }
358 }
359
360 ast::Resource { body }
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366 use es_fluent_core::{meta::TypeKind, namer::FluentKey};
367 use proc_macro2::Ident;
368 use std::fs;
369 use tempfile::TempDir;
370
371 #[test]
372 fn test_value_formatter_expand() {
373 assert_eq!(ValueFormatter::expand("simple-key"), "Key");
374 assert_eq!(ValueFormatter::expand("another-test-value"), "Value");
375 assert_eq!(ValueFormatter::expand("single"), "Single");
376 }
377
378 #[test]
379 fn test_generate_empty_items() {
380 let temp_dir = TempDir::new().unwrap();
381 let i18n_path = temp_dir.path().join("i18n");
382
383 let result = generate(
384 "test_crate",
385 &i18n_path,
386 vec![],
387 FluentParseMode::Conservative,
388 );
389 assert!(result.is_ok());
390
391 let ftl_file_path = i18n_path.join("test_crate.ftl");
392 assert!(!ftl_file_path.exists());
393 }
394
395 #[test]
396 fn test_generate_with_items() {
397 let temp_dir = TempDir::new().unwrap();
398 let i18n_path = temp_dir.path().join("i18n");
399
400 let ftl_key = FluentKey::new(
401 &Ident::new("TestEnum", proc_macro2::Span::call_site()),
402 "Variant1",
403 );
404 let variant = FtlVariant {
405 name: "variant1".to_string(),
406 ftl_key,
407 args: Vec::new(),
408 };
409
410 let type_info = FtlTypeInfo {
411 type_kind: TypeKind::Enum,
412 type_name: "TestEnum".to_string(),
413 variants: vec![variant],
414 file_path: None,
415 };
416
417 let result = generate(
418 "test_crate",
419 &i18n_path,
420 vec![type_info],
421 FluentParseMode::Conservative,
422 );
423 assert!(result.is_ok());
424
425 let ftl_file_path = i18n_path.join("test_crate.ftl");
426 assert!(ftl_file_path.exists());
427
428 let content = fs::read_to_string(ftl_file_path).unwrap();
429 assert!(content.contains("TestEnum"));
430 assert!(content.contains("Variant1"));
431 }
432
433 #[test]
434 fn test_generate_aggressive_mode() {
435 let temp_dir = TempDir::new().unwrap();
436 let i18n_path = temp_dir.path().join("i18n");
437
438 let ftl_file_path = i18n_path.join("test_crate.ftl");
439 fs::create_dir_all(&i18n_path).unwrap();
440 fs::write(&ftl_file_path, "existing-message = Existing Content").unwrap();
441
442 let ftl_key = FluentKey::new(
443 &Ident::new("TestEnum", proc_macro2::Span::call_site()),
444 "Variant1",
445 );
446 let variant = FtlVariant {
447 name: "variant1".to_string(),
448 ftl_key,
449 args: Vec::new(),
450 };
451
452 let type_info = FtlTypeInfo {
453 type_kind: TypeKind::Enum,
454 type_name: "TestEnum".to_string(),
455 variants: vec![variant],
456 file_path: None,
457 };
458
459 let result = generate(
460 "test_crate",
461 &i18n_path,
462 vec![type_info],
463 FluentParseMode::Aggressive,
464 );
465 assert!(result.is_ok());
466
467 let content = fs::read_to_string(&ftl_file_path).unwrap();
468 assert!(!content.contains("existing-message"));
469 assert!(content.contains("TestEnum"));
470 assert!(content.contains("Variant1"));
471 }
472
473 #[test]
474 fn test_generate_conservative_mode() {
475 let temp_dir = TempDir::new().unwrap();
476 let i18n_path = temp_dir.path().join("i18n");
477
478 let ftl_file_path = i18n_path.join("test_crate.ftl");
479 fs::create_dir_all(&i18n_path).unwrap();
480 fs::write(&ftl_file_path, "existing-message = Existing Content").unwrap();
481
482 let ftl_key = FluentKey::new(
483 &Ident::new("TestEnum", proc_macro2::Span::call_site()),
484 "Variant1",
485 );
486 let variant = FtlVariant {
487 name: "variant1".to_string(),
488 ftl_key,
489 args: Vec::new(),
490 };
491
492 let type_info = FtlTypeInfo {
493 type_kind: TypeKind::Enum,
494 type_name: "TestEnum".to_string(),
495 variants: vec![variant],
496 file_path: None,
497 };
498
499 let result = generate(
500 "test_crate",
501 &i18n_path,
502 vec![type_info],
503 FluentParseMode::Conservative,
504 );
505 assert!(result.is_ok());
506
507 let content = fs::read_to_string(&ftl_file_path).unwrap();
508 assert!(content.contains("existing-message"));
509 assert!(content.contains("TestEnum"));
510 assert!(content.contains("Variant1"));
511 }
512 #[test]
513 fn test_generate_clean_mode() {
514 let temp_dir = TempDir::new().unwrap();
515 let i18n_path = temp_dir.path().join("i18n");
516
517 let ftl_file_path = i18n_path.join("test_crate.ftl");
518 fs::create_dir_all(&i18n_path).unwrap();
519
520 let initial_content = "
521## OrphanGroup
522
523what-Hi = Hi
524awdawd = awdwa
525
526## ExistingGroup
527
528existing-key = Existing Value
529";
530 fs::write(&ftl_file_path, initial_content).unwrap();
531
532 let ftl_key = FluentKey::new(
534 &Ident::new("ExistingGroup", proc_macro2::Span::call_site()),
535 "ExistingKey",
536 );
537 let variant = FtlVariant {
538 name: "ExistingKey".to_string(),
539 ftl_key,
540 args: Vec::new(),
541 };
542
543 let type_info = FtlTypeInfo {
544 type_kind: TypeKind::Enum,
545 type_name: "ExistingGroup".to_string(),
546 variants: vec![variant],
547 file_path: None,
548 };
549
550 let result = generate(
551 "test_crate",
552 &i18n_path,
553 vec![type_info],
554 FluentParseMode::Clean,
555 );
556 assert!(result.is_ok());
557
558 let content = fs::read_to_string(&ftl_file_path).unwrap();
559
560 assert!(!content.contains("## OrphanGroup"));
562 assert!(!content.contains("what-Hi"));
563 assert!(!content.contains("awdawd"));
564
565 assert!(content.contains("## ExistingGroup"));
567 }
568}