Skip to main content

es_fluent_cli/commands/
tree.rs

1//! Tree command for displaying FTL structure.
2//!
3//! This module provides functionality to display a tree view of FTL items
4//! for each FTL file associated with a crate.
5
6use 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    /// Build a tree for a single FTL file.
32    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    /// Build a tree for a message entry.
60    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    /// Build a tree for a term entry.
71    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    /// Build child nodes for an entry (attributes and variables).
83    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/// Arguments for the tree command.
123#[derive(Debug, Parser)]
124pub struct TreeArgs {
125    #[command(flatten)]
126    pub workspace: WorkspaceArgs,
127
128    /// Show all locales, not just the fallback language.
129    #[arg(long)]
130    pub all: bool,
131
132    /// Show attributes under each message.
133    #[arg(long)]
134    pub attributes: bool,
135
136    /// Show variables used in each message.
137    #[arg(long)]
138    pub variables: bool,
139}
140
141/// Run the tree command.
142pub 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
159/// Print the tree for a single crate.
160fn 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
202/// Extract variable names from a pattern into a vector.
203fn 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
214/// Extract variable names from an expression.
215fn 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
232/// Extract variable names from an inline expression.
233fn 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        // Exercise print path for crate tree.
509        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}