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 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}