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 fluent_syntax::parser;
260
261    fn parse_ftl(content: &str) -> ast::Resource<String> {
262        parser::parse(content.to_string()).unwrap()
263    }
264
265    fn get_message<'a>(
266        resource: &'a ast::Resource<String>,
267        id: &str,
268    ) -> Option<&'a ast::Message<String>> {
269        resource.body.iter().find_map(|entry| {
270            if let ast::Entry::Message(msg) = entry
271                && msg.id.name == id
272            {
273                return Some(msg);
274            }
275            None
276        })
277    }
278
279    fn renderer(show_attributes: bool, show_variables: bool) -> TreeRenderer {
280        TreeRenderer::new(show_attributes, show_variables)
281    }
282
283    #[test]
284    fn test_extract_variables_simple() {
285        let content = "hello = Hello { $name }!";
286        let resource = parse_ftl(content);
287        let msg = get_message(&resource, "hello").unwrap();
288
289        let mut variables = Vec::new();
290        extract_variables_from_pattern_into(msg.value.as_ref().unwrap(), &mut variables);
291
292        assert_eq!(variables, vec!["name"]);
293    }
294
295    #[test]
296    fn test_extract_variables_multiple() {
297        let content = "greeting = Hello { $name }, you have { $count } messages";
298        let resource = parse_ftl(content);
299        let msg = get_message(&resource, "greeting").unwrap();
300
301        let mut variables = Vec::new();
302        extract_variables_from_pattern_into(msg.value.as_ref().unwrap(), &mut variables);
303        variables.sort();
304
305        assert_eq!(variables, vec!["count", "name"]);
306    }
307
308    #[test]
309    fn test_extract_variables_select() {
310        let content = r#"count = { $num ->
311    [one] One item
312   *[other] { $num } items
313}"#;
314        let resource = parse_ftl(content);
315        let msg = get_message(&resource, "count").unwrap();
316
317        let mut variables = Vec::new();
318        extract_variables_from_pattern_into(msg.value.as_ref().unwrap(), &mut variables);
319        variables.sort();
320        variables.dedup();
321
322        assert_eq!(variables, vec!["num"]);
323    }
324
325    #[test]
326    fn test_extract_variables_nested() {
327        let content = r#"message = Hello { $user }, today is { DATETIME($date) }"#;
328        let resource = parse_ftl(content);
329        let msg = get_message(&resource, "message").unwrap();
330
331        let mut variables = Vec::new();
332        extract_variables_from_pattern_into(msg.value.as_ref().unwrap(), &mut variables);
333        variables.sort();
334
335        assert_eq!(variables, vec!["date", "user"]);
336    }
337
338    #[test]
339    fn test_build_message_tree_simple() {
340        let content = "hello = Hello World";
341        let resource = parse_ftl(content);
342        let msg = get_message(&resource, "hello").unwrap();
343
344        let tree = renderer(false, false).build_message_tree("hello", msg);
345
346        match tree {
347            Tree::Leaf(lines) => assert_eq!(lines, vec!["hello"]),
348            _ => panic!("Expected leaf node"),
349        }
350    }
351
352    #[test]
353    fn test_build_message_tree_with_attributes() {
354        let content = r#"button = Button
355    .tooltip = Click me
356    .aria-label = Submit"#;
357        let resource = parse_ftl(content);
358        let msg = get_message(&resource, "button").unwrap();
359
360        let tree = renderer(true, false).build_message_tree("button", msg);
361
362        match tree {
363            Tree::Node(label, children) => {
364                assert_eq!(label, "button");
365                assert_eq!(children.len(), 2);
366            },
367            _ => panic!("Expected node with children"),
368        }
369    }
370
371    #[test]
372    fn test_build_message_tree_with_variables() {
373        let content = "greeting = Hello { $name }";
374        let resource = parse_ftl(content);
375        let msg = get_message(&resource, "greeting").unwrap();
376
377        let tree = renderer(false, true).build_message_tree("greeting", msg);
378
379        match tree {
380            Tree::Node(label, children) => {
381                assert_eq!(label, "greeting");
382                assert_eq!(children.len(), 1);
383            },
384            _ => panic!("Expected node with children"),
385        }
386    }
387
388    #[test]
389    fn test_build_entry_children_no_attributes_no_variables() {
390        let children = renderer(false, false).build_entry_children(&[], None);
391        assert!(children.is_empty());
392    }
393
394    #[test]
395    fn test_build_entry_children_attributes_only() {
396        let content = r#"button = Button
397    .tooltip = Click me"#;
398        let resource = parse_ftl(content);
399        let msg = get_message(&resource, "button").unwrap();
400
401        let children =
402            renderer(true, false).build_entry_children(&msg.attributes, msg.value.as_ref());
403
404        assert_eq!(children.len(), 1);
405    }
406
407    #[test]
408    fn test_build_file_tree_nonexistent() {
409        let tree =
410            renderer(false, false).build_file_tree("test.ftl", Path::new("/nonexistent/path.ftl"));
411
412        match tree {
413            Tree::Node(label, children) => {
414                assert!(label.contains("test.ftl"));
415                assert!(
416                    children.is_empty(),
417                    "nonexistent file should produce empty tree"
418                );
419            },
420            _ => panic!("Expected node"),
421        }
422    }
423
424    #[test]
425    fn test_tree_render_basic() {
426        let tree = Tree::Node(
427            "root".to_string(),
428            vec![
429                Tree::Leaf(vec!["item1".to_string()]),
430                Tree::Leaf(vec!["item2".to_string()]),
431            ],
432        );
433
434        let output = tree.render_to_string();
435        assert!(output.contains("root"));
436        assert!(output.contains("item1"));
437        assert!(output.contains("item2"));
438    }
439
440    #[test]
441    fn test_tree_render_nested() {
442        let tree = Tree::Node(
443            "crate".to_string(),
444            vec![Tree::Node(
445                "en".to_string(),
446                vec![Tree::Leaf(vec!["message".to_string()])],
447            )],
448        );
449
450        let output = tree.render_to_string();
451        assert!(output.contains("crate"));
452        assert!(output.contains("en"));
453        assert!(output.contains("message"));
454    }
455}