1use crate::commands::{WorkspaceArgs, WorkspaceCrates};
7use crate::core::CliError;
8use crate::ftl::{LocaleContext, parse_ftl_file};
9use crate::utils::{discover_ftl_files, ui};
10use anyhow::Result;
11use clap::Parser;
12use colored::Colorize as _;
13use fluent_syntax::ast;
14use std::path::Path;
15use treelog::Tree;
16
17#[derive(Clone, Copy)]
18struct TreeRenderer {
19 show_attributes: bool,
20 show_variables: bool,
21}
22
23impl TreeRenderer {
24 fn new(show_attributes: bool, show_variables: bool) -> Self {
25 Self {
26 show_attributes,
27 show_variables,
28 }
29 }
30
31 fn build_file_tree(&self, relative_path: &str, abs_path: &Path) -> Tree {
33 let resource = match parse_ftl_file(abs_path) {
34 Ok(res) => res,
35 Err(_) => {
36 return Tree::Node(
37 relative_path.yellow().to_string(),
38 vec![Tree::Leaf(vec!["<parse error>".red().to_string()])],
39 );
40 },
41 };
42
43 let entries: Vec<Tree> = resource
44 .body
45 .iter()
46 .filter_map(|entry| match entry {
47 ast::Entry::Message(msg) => Some(self.build_message_tree(&msg.id.name, msg)),
48 ast::Entry::Term(term) => Some(self.build_term_tree(&term.id.name, term)),
49 ast::Entry::Comment(_) => None,
50 ast::Entry::GroupComment(_) => None,
51 ast::Entry::ResourceComment(_) => None,
52 ast::Entry::Junk { .. } => None,
53 })
54 .collect();
55
56 Tree::Node(relative_path.yellow().to_string(), entries)
57 }
58
59 fn build_message_tree(&self, id: &str, msg: &ast::Message<String>) -> Tree {
61 let children = self.build_entry_children(&msg.attributes, msg.value.as_ref());
62
63 if children.is_empty() {
64 Tree::Leaf(vec![id.to_string()])
65 } else {
66 Tree::Node(id.to_string(), children)
67 }
68 }
69
70 fn build_term_tree(&self, id: &str, term: &ast::Term<String>) -> Tree {
72 let children = self.build_entry_children(&term.attributes, Some(&term.value));
73 let label = format!("-{}", id);
74
75 if children.is_empty() {
76 Tree::Leaf(vec![label.dimmed().to_string()])
77 } else {
78 Tree::Node(label.dimmed().to_string(), children)
79 }
80 }
81
82 fn build_entry_children(
84 &self,
85 attributes: &[ast::Attribute<String>],
86 value: Option<&ast::Pattern<String>>,
87 ) -> Vec<Tree> {
88 let mut children: Vec<Tree> = Vec::new();
89
90 if self.show_attributes {
91 for attr in attributes {
92 let attr_label = format!("@{}", attr.id.name);
93 children.push(Tree::Leaf(vec![attr_label.dimmed().to_string()]));
94 }
95 }
96
97 if self.show_variables {
98 let mut variables = Vec::new();
99 if let Some(pattern) = value {
100 extract_variables_from_pattern_into(pattern, &mut variables);
101 }
102 for attr in attributes {
103 extract_variables_from_pattern_into(&attr.value, &mut variables);
104 }
105
106 if !variables.is_empty() {
107 variables.sort();
108 variables.dedup();
109 let vars_str = variables
110 .iter()
111 .map(|v| format!("${}", v))
112 .collect::<Vec<_>>()
113 .join(", ");
114 children.push(Tree::Leaf(vec![vars_str.magenta().to_string()]));
115 }
116 }
117
118 children
119 }
120}
121
122#[derive(Debug, Parser)]
124pub struct TreeArgs {
125 #[command(flatten)]
126 pub workspace: WorkspaceArgs,
127
128 #[arg(long)]
130 pub all: bool,
131
132 #[arg(long)]
134 pub attributes: bool,
135
136 #[arg(long)]
138 pub variables: bool,
139}
140
141pub fn run_tree(args: TreeArgs) -> Result<(), CliError> {
143 let workspace = WorkspaceCrates::discover(args.workspace)?;
144
145 ui::print_tree_header();
146
147 if workspace.crates.is_empty() {
148 ui::print_no_crates_found();
149 return Ok(());
150 }
151
152 for krate in &workspace.crates {
153 print_crate_tree(krate, args.all, args.attributes, args.variables)?;
154 }
155
156 Ok(())
157}
158
159fn print_crate_tree(
161 krate: &crate::core::CrateInfo,
162 all_locales: bool,
163 show_attributes: bool,
164 show_variables: bool,
165) -> Result<()> {
166 let ctx = LocaleContext::from_crate(krate, all_locales)?;
167 let renderer = TreeRenderer::new(show_attributes, show_variables);
168
169 let mut locale_trees: Vec<Tree> = Vec::new();
170
171 for locale in &ctx.locales {
172 let locale_dir = ctx.locale_dir(locale);
173 if !locale_dir.exists() {
174 continue;
175 }
176
177 let ftl_files = discover_ftl_files(&ctx.assets_dir, locale, &ctx.crate_name)?;
178
179 if ftl_files.is_empty() {
180 continue;
181 }
182
183 let file_trees: Vec<Tree> = ftl_files
184 .iter()
185 .map(|file_info| {
186 renderer.build_file_tree(
187 &file_info.relative_path.display().to_string(),
188 &file_info.abs_path,
189 )
190 })
191 .collect();
192
193 locale_trees.push(Tree::Node(locale.green().to_string(), file_trees));
194 }
195
196 let tree = Tree::Node(krate.name.bold().cyan().to_string(), locale_trees);
197 println!("{}", tree.render_to_string());
198
199 Ok(())
200}
201
202fn extract_variables_from_pattern_into(
204 pattern: &ast::Pattern<String>,
205 variables: &mut Vec<String>,
206) {
207 for element in &pattern.elements {
208 if let ast::PatternElement::Placeable { expression } = element {
209 extract_variables_from_expression(expression, variables);
210 }
211 }
212}
213
214fn extract_variables_from_expression(
216 expression: &ast::Expression<String>,
217 variables: &mut Vec<String>,
218) {
219 match expression {
220 ast::Expression::Inline(inline) => {
221 extract_variables_from_inline(inline, variables);
222 },
223 ast::Expression::Select { selector, variants } => {
224 extract_variables_from_inline(selector, variables);
225 for variant in variants {
226 extract_variables_from_pattern_into(&variant.value, variables);
227 }
228 },
229 }
230}
231
232fn extract_variables_from_inline(
234 inline: &ast::InlineExpression<String>,
235 variables: &mut Vec<String>,
236) {
237 match inline {
238 ast::InlineExpression::VariableReference { id } => {
239 variables.push(id.name.clone());
240 },
241 ast::InlineExpression::FunctionReference { arguments, .. } => {
242 for arg in &arguments.positional {
243 extract_variables_from_inline(arg, variables);
244 }
245 for arg in &arguments.named {
246 extract_variables_from_inline(&arg.value, variables);
247 }
248 },
249 ast::InlineExpression::Placeable { expression } => {
250 extract_variables_from_expression(expression, variables);
251 },
252 _ => {},
253 }
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259 use crate::core::CrateInfo;
260 use fluent_syntax::parser;
261 use std::fs;
262 use tempfile::tempdir;
263
264 fn parse_ftl(content: &str) -> ast::Resource<String> {
265 parser::parse(content.to_string()).unwrap()
266 }
267
268 fn get_message<'a>(
269 resource: &'a ast::Resource<String>,
270 id: &str,
271 ) -> Option<&'a ast::Message<String>> {
272 resource.body.iter().find_map(|entry| {
273 if let ast::Entry::Message(msg) = entry
274 && msg.id.name == id
275 {
276 return Some(msg);
277 }
278 None
279 })
280 }
281
282 fn renderer(show_attributes: bool, show_variables: bool) -> TreeRenderer {
283 TreeRenderer::new(show_attributes, show_variables)
284 }
285
286 fn create_workspace_with_tree_data() -> tempfile::TempDir {
287 let temp = tempdir().expect("tempdir");
288 fs::create_dir_all(temp.path().join("src")).expect("create src");
289 fs::create_dir_all(temp.path().join("i18n/en/test-app")).expect("create i18n dirs");
290 fs::write(
291 temp.path().join("Cargo.toml"),
292 r#"[package]
293name = "test-app"
294version = "0.1.0"
295edition = "2024"
296"#,
297 )
298 .expect("write Cargo.toml");
299 fs::write(temp.path().join("src/lib.rs"), "pub struct Demo;\n").expect("write lib.rs");
300 fs::write(
301 temp.path().join("i18n.toml"),
302 "fallback_language = \"en\"\nassets_dir = \"i18n\"\n",
303 )
304 .expect("write i18n.toml");
305 fs::write(
306 temp.path().join("i18n/en/test-app.ftl"),
307 "hello = Hello { $name }\n-term = Term Value\n",
308 )
309 .expect("write main ftl");
310 fs::write(
311 temp.path().join("i18n/en/test-app/ui.ftl"),
312 "button = Click\n",
313 )
314 .expect("write namespaced ftl");
315 temp
316 }
317
318 fn crate_info_from_temp(temp: &tempfile::TempDir) -> CrateInfo {
319 CrateInfo {
320 name: "test-app".to_string(),
321 manifest_dir: temp.path().to_path_buf(),
322 src_dir: temp.path().join("src"),
323 i18n_config_path: temp.path().join("i18n.toml"),
324 ftl_output_dir: temp.path().join("i18n/en"),
325 has_lib_rs: true,
326 fluent_features: Vec::new(),
327 }
328 }
329
330 #[test]
331 fn test_extract_variables_simple() {
332 let content = "hello = Hello { $name }!";
333 let resource = parse_ftl(content);
334 let msg = get_message(&resource, "hello").unwrap();
335
336 let mut variables = Vec::new();
337 extract_variables_from_pattern_into(msg.value.as_ref().unwrap(), &mut variables);
338
339 assert_eq!(variables, vec!["name"]);
340 }
341
342 #[test]
343 fn test_extract_variables_multiple() {
344 let content = "greeting = Hello { $name }, you have { $count } messages";
345 let resource = parse_ftl(content);
346 let msg = get_message(&resource, "greeting").unwrap();
347
348 let mut variables = Vec::new();
349 extract_variables_from_pattern_into(msg.value.as_ref().unwrap(), &mut variables);
350 variables.sort();
351
352 assert_eq!(variables, vec!["count", "name"]);
353 }
354
355 #[test]
356 fn test_extract_variables_select() {
357 let content = r#"count = { $num ->
358 [one] One item
359 *[other] { $num } items
360}"#;
361 let resource = parse_ftl(content);
362 let msg = get_message(&resource, "count").unwrap();
363
364 let mut variables = Vec::new();
365 extract_variables_from_pattern_into(msg.value.as_ref().unwrap(), &mut variables);
366 variables.sort();
367 variables.dedup();
368
369 assert_eq!(variables, vec!["num"]);
370 }
371
372 #[test]
373 fn test_extract_variables_nested() {
374 let content = r#"message = Hello { $user }, today is { DATETIME($date) }"#;
375 let resource = parse_ftl(content);
376 let msg = get_message(&resource, "message").unwrap();
377
378 let mut variables = Vec::new();
379 extract_variables_from_pattern_into(msg.value.as_ref().unwrap(), &mut variables);
380 variables.sort();
381
382 assert_eq!(variables, vec!["date", "user"]);
383 }
384
385 #[test]
386 fn test_build_message_tree_simple() {
387 let content = "hello = Hello World";
388 let resource = parse_ftl(content);
389 let msg = get_message(&resource, "hello").unwrap();
390
391 let tree = renderer(false, false).build_message_tree("hello", msg);
392
393 match tree {
394 Tree::Leaf(lines) => assert_eq!(lines, vec!["hello"]),
395 _ => panic!("Expected leaf node"),
396 }
397 }
398
399 #[test]
400 fn test_build_message_tree_with_attributes() {
401 let content = r#"button = Button
402 .tooltip = Click me
403 .aria-label = Submit"#;
404 let resource = parse_ftl(content);
405 let msg = get_message(&resource, "button").unwrap();
406
407 let tree = renderer(true, false).build_message_tree("button", msg);
408
409 match tree {
410 Tree::Node(label, children) => {
411 assert_eq!(label, "button");
412 assert_eq!(children.len(), 2);
413 },
414 _ => panic!("Expected node with children"),
415 }
416 }
417
418 #[test]
419 fn test_build_message_tree_with_variables() {
420 let content = "greeting = Hello { $name }";
421 let resource = parse_ftl(content);
422 let msg = get_message(&resource, "greeting").unwrap();
423
424 let tree = renderer(false, true).build_message_tree("greeting", msg);
425
426 match tree {
427 Tree::Node(label, children) => {
428 assert_eq!(label, "greeting");
429 assert_eq!(children.len(), 1);
430 },
431 _ => panic!("Expected node with children"),
432 }
433 }
434
435 #[test]
436 fn test_build_entry_children_no_attributes_no_variables() {
437 let children = renderer(false, false).build_entry_children(&[], None);
438 assert!(children.is_empty());
439 }
440
441 #[test]
442 fn test_build_entry_children_attributes_only() {
443 let content = r#"button = Button
444 .tooltip = Click me"#;
445 let resource = parse_ftl(content);
446 let msg = get_message(&resource, "button").unwrap();
447
448 let children =
449 renderer(true, false).build_entry_children(&msg.attributes, msg.value.as_ref());
450
451 assert_eq!(children.len(), 1);
452 }
453
454 #[test]
455 fn test_build_file_tree_nonexistent() {
456 let tree =
457 renderer(false, false).build_file_tree("test.ftl", Path::new("/nonexistent/path.ftl"));
458
459 match tree {
460 Tree::Node(label, children) => {
461 assert!(label.contains("test.ftl"));
462 assert!(
463 children.is_empty(),
464 "nonexistent file should produce empty tree"
465 );
466 },
467 _ => panic!("Expected node"),
468 }
469 }
470
471 #[test]
472 fn test_tree_render_basic() {
473 let tree = Tree::Node(
474 "root".to_string(),
475 vec![
476 Tree::Leaf(vec!["item1".to_string()]),
477 Tree::Leaf(vec!["item2".to_string()]),
478 ],
479 );
480
481 let output = tree.render_to_string();
482 assert!(output.contains("root"));
483 assert!(output.contains("item1"));
484 assert!(output.contains("item2"));
485 }
486
487 #[test]
488 fn test_tree_render_nested() {
489 let tree = Tree::Node(
490 "crate".to_string(),
491 vec![Tree::Node(
492 "en".to_string(),
493 vec![Tree::Leaf(vec!["message".to_string()])],
494 )],
495 );
496
497 let output = tree.render_to_string();
498 assert!(output.contains("crate"));
499 assert!(output.contains("en"));
500 assert!(output.contains("message"));
501 }
502
503 #[test]
504 fn test_build_term_tree_and_print_crate_tree() {
505 let temp = create_workspace_with_tree_data();
506 let krate = crate_info_from_temp(&temp);
507
508 let printed = print_crate_tree(&krate, false, true, true);
510 assert!(printed.is_ok());
511
512 let resource = parse_ftl("-term = Term\n");
513 let term = resource
514 .body
515 .iter()
516 .find_map(|entry| match entry {
517 ast::Entry::Term(term) => Some(term),
518 _ => None,
519 })
520 .expect("term exists");
521 let tree = renderer(false, false).build_term_tree(&term.id.name, term);
522 match tree {
523 Tree::Leaf(lines) => assert!(lines[0].contains("-term")),
524 _ => panic!("expected leaf term tree"),
525 }
526 }
527
528 #[test]
529 fn run_tree_returns_ok_for_missing_package_filter() {
530 let temp = create_workspace_with_tree_data();
531 let result = run_tree(TreeArgs {
532 workspace: WorkspaceArgs {
533 path: Some(temp.path().to_path_buf()),
534 package: Some("missing-package".to_string()),
535 },
536 all: false,
537 attributes: false,
538 variables: false,
539 });
540 assert!(result.is_ok());
541 }
542}