cs/output/
formatter.rs

1use crate::trace::{CallNode, CallTree, TraceDirection};
2use crate::tree::{NodeType, ReferenceTree, TreeNode};
3use crate::SearchResult;
4use colored::*;
5
6/// Formatter for rendering reference trees as text
7pub struct TreeFormatter {
8    max_width: usize,
9}
10
11impl TreeFormatter {
12    /// Create a new TreeFormatter with default width (80 columns)
13    pub fn new() -> Self {
14        // Force color output even when not in a TTY
15        colored::control::set_override(true);
16        Self { max_width: 80 }
17    }
18
19    /// Create a TreeFormatter with custom width
20    pub fn with_width(max_width: usize) -> Self {
21        Self { max_width }
22    }
23
24    /// Format a search result with clear sections
25    pub fn format_result(&self, result: &SearchResult) -> String {
26        let mut output = String::new();
27
28        // Section 1: Translation Files
29        if !result.translation_entries.is_empty() {
30            output.push_str(&format!("{}\n", "=== Translation Files ===".bold()));
31            for entry in &result.translation_entries {
32                output.push_str(&format!(
33                    "{}:{}:{}: {}\n",
34                    entry.file.display(),
35                    entry.line,
36                    entry.key.yellow().bold(),
37                    format!("\"{}\"", entry.value).green().bold()
38                ));
39            }
40            output.push('\n');
41        }
42
43        // Section 2: Code References
44        if !result.code_references.is_empty() {
45            output.push_str(&format!("{}\n", "=== Code References ===".bold()));
46            for code_ref in &result.code_references {
47                // Highlight the key in the context
48                let highlighted_context = self.highlight_key_in_context(&code_ref.context, &code_ref.key_path);
49                output.push_str(&format!(
50                    "{}:{}:{}\n",
51                    code_ref.file.display(),
52                    code_ref.line,
53                    highlighted_context
54                ));
55            }
56        }
57
58        output
59    }
60
61    /// Highlight the i18n key in the code context
62    fn highlight_key_in_context(&self, context: &str, key: &str) -> String {
63        // Make the key bold in the context
64        context.replace(key, &key.bold().to_string())
65    }
66
67    /// Format a reference tree as a string (legacy tree format)
68    pub fn format(&self, tree: &ReferenceTree) -> String {
69        let mut output = String::new();
70        self.format_node(&tree.root, &mut output, "", true, true);
71        output
72    }
73
74    pub fn format_trace_tree(&self, tree: &CallTree, direction: TraceDirection) -> String {
75        match direction {
76            TraceDirection::Forward => self.format_forward_tree(tree),
77            TraceDirection::Backward => self.format_backward_tree(tree),
78        }
79    }
80
81    fn format_forward_tree(&self, tree: &CallTree) -> String {
82        let mut output = String::new();
83        self.format_call_node(&tree.root, &mut output, "", true, true);
84        output
85    }
86
87    fn format_backward_tree(&self, tree: &CallTree) -> String {
88        let mut output = String::new();
89        // For backward trace, we want to show chains like: caller -> callee -> target
90        // But the tree structure is target <- callee <- caller
91        // So we need to traverse from leaves to root, or just print the tree inverted.
92        // The requirement says: "Formats backward trace as chains (callers -> function)"
93        // Example: blah1 -> foo1 -> bar
94        
95        // Let's traverse the tree and collect paths from leaves to root.
96        // Since the tree is built with target as root and callers as children, 
97        // a path from a leaf to root represents a call chain: leaf calls ... calls root.
98        
99        let mut paths = Vec::new();
100        self.collect_backward_paths(&tree.root, vec![], &mut paths);
101        
102        for path in paths {
103            // path is [leaf, ..., root]
104            // We want to print: leaf -> ... -> root
105            // But wait, the path collected by collect_backward_paths is [root, ..., leaf] because we push node then recurse?
106            // Let's check collect_backward_paths.
107            // current_path.push(node); recurse(child, current_path.clone())
108            // So yes, current_path is [root, child, ..., leaf].
109            // Root is the target. Leaf is the furthest caller.
110            // So path is [target, caller, caller_of_caller].
111            // We want: caller_of_caller -> caller -> target.
112            // So we need to reverse the path.
113            
114            let mut display_path = path.clone();
115            display_path.reverse();
116            
117            let mut chain = display_path.iter()
118                .map(|node| format!("{} ({}:{})", node.def.name.bold(), node.def.file.display(), node.def.line))
119                .collect::<Vec<_>>()
120                .join(" -> ");
121            
122            // Check if the leaf (first in display_path) was truncated
123            if let Some(first) = display_path.first() {
124                if first.truncated {
125                    chain = format!("{} -> {}", "[depth limit reached]".red(), chain);
126                }
127            }
128
129            output.push_str(&chain);
130            output.push('\n');
131        }
132        
133        if output.is_empty() {
134            // If no callers found, just print the root
135             output.push_str(&format!("{} (No incoming calls found)\n", tree.root.def.name));
136        }
137
138        output
139    }
140
141    fn collect_backward_paths<'a>(
142        &'a self,
143        node: &'a CallNode,
144        mut current_path: Vec<&'a CallNode>,
145        paths: &mut Vec<Vec<&'a CallNode>>,
146    ) {
147        current_path.push(node);
148        
149        if node.children.is_empty() {
150            // Leaf node (a caller that is not called by anyone found/searched)
151            // or depth limit reached.
152            // If truncated, we should indicate it.
153            if node.truncated {
154                // If truncated, it means there are more callers but we stopped.
155                // We can append a special marker or just include the path.
156                // Let's just include the path for now.
157            }
158            paths.push(current_path);
159        } else {
160            for child in &node.children {
161                self.collect_backward_paths(child, current_path.clone(), paths);
162            }
163        }
164    }
165
166    fn format_call_node(
167        &self,
168        node: &CallNode,
169        output: &mut String,
170        prefix: &str,
171        is_last: bool,
172        is_root: bool,
173    ) {
174        if !is_root {
175            output.push_str(prefix);
176            output.push_str(if is_last { "└─> " } else { "├─> " });
177        }
178
179        let content = format!(
180            "{} ({}:{})",
181            node.def.name.bold(),
182            node.def.file.display(),
183            node.def.line
184        );
185        output.push_str(&content);
186        
187        if node.truncated {
188             output.push_str(&" [depth limit reached]".red().to_string());
189        }
190        
191        output.push('\n');
192
193        let child_count = node.children.len();
194        for (i, child) in node.children.iter().enumerate() {
195            let is_last_child = i == child_count - 1;
196            let child_prefix = if is_root {
197                String::new()
198            } else {
199                format!("{}{}   ", prefix, if is_last { " " } else { "│" })
200            };
201            self.format_call_node(child, output, &child_prefix, is_last_child, false);
202        }
203    }
204
205
206    /// Format a single node and its children
207    fn format_node(
208        &self,
209        node: &TreeNode,
210        output: &mut String,
211        prefix: &str,
212        is_last: bool,
213        is_root: bool,
214    ) {
215        // Format the current node
216        if !is_root {
217            output.push_str(prefix);
218            output.push_str(if is_last { "└─> " } else { "├─> " });
219        }
220
221        // Add node content
222        let content = self.format_content(node);
223        output.push_str(&content);
224
225        // Add location if present
226        if let Some(location) = &node.location {
227            let location_str = format!(
228                " ({}:{})",
229                location.file.display(),
230                location.line
231            );
232            output.push_str(&location_str);
233        }
234
235        output.push('\n');
236
237        // Format children
238        let child_count = node.children.len();
239        for (i, child) in node.children.iter().enumerate() {
240            let is_last_child = i == child_count - 1;
241            let child_prefix = if is_root {
242                String::new()
243            } else {
244                format!("{}{}   ", prefix, if is_last { " " } else { "│" })
245            };
246
247            self.format_node(child, output, &child_prefix, is_last_child, false);
248        }
249    }
250
251    /// Format node content based on node type
252    fn format_content(&self, node: &TreeNode) -> String {
253        match node.node_type {
254            NodeType::Root => {
255                format!("'{}' (search query)", node.content)
256            }
257            NodeType::Translation => {
258                // Truncate if too long
259                self.truncate(&node.content, self.max_width - 20)
260            }
261            NodeType::KeyPath => {
262                format!("Key: {}", node.content)
263            }
264            NodeType::CodeRef => {
265                // Truncate code context
266                let truncated = self.truncate(&node.content.trim(), self.max_width - 30);
267                format!("Code: {}", truncated)
268            }
269        }
270    }
271
272    /// Truncate a string to fit within max length
273    fn truncate(&self, s: &str, max_len: usize) -> String {
274        if s.len() <= max_len {
275            s.to_string()
276        } else {
277            format!("{}...", &s[..max_len.saturating_sub(3)])
278        }
279    }
280}
281
282impl Default for TreeFormatter {
283    fn default() -> Self {
284        Self::new()
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291    use crate::tree::{Location, TreeNode};
292    use std::path::PathBuf;
293
294    #[test]
295    fn test_formatter_creation() {
296        let formatter = TreeFormatter::new();
297        assert_eq!(formatter.max_width, 80);
298    }
299
300    #[test]
301    fn test_formatter_with_custom_width() {
302        let formatter = TreeFormatter::with_width(120);
303        assert_eq!(formatter.max_width, 120);
304    }
305
306    #[test]
307    fn test_format_empty_tree() {
308        let tree = ReferenceTree::with_search_text("test".to_string());
309        let formatter = TreeFormatter::new();
310        let output = formatter.format(&tree);
311
312        assert!(output.contains("'test'"));
313        assert!(output.contains("search query"));
314    }
315
316    #[test]
317    fn test_format_tree_with_translation() {
318        let mut root = TreeNode::new(NodeType::Root, "add new".to_string());
319        let translation = TreeNode::with_location(
320            NodeType::Translation,
321            "invoice.labels.add_new: 'add new'".to_string(),
322            Location::new(PathBuf::from("en.yml"), 4),
323        );
324        root.add_child(translation);
325
326        let tree = ReferenceTree::new(root);
327        let formatter = TreeFormatter::new();
328        let output = formatter.format(&tree);
329
330        assert!(output.contains("'add new'"));
331        assert!(output.contains("invoice.labels.add_new"));
332        assert!(output.contains("en.yml:4"));
333        assert!(output.contains("└─>") || output.contains("├─>"));
334    }
335
336    #[test]
337    fn test_format_complete_tree() {
338        let mut root = TreeNode::new(NodeType::Root, "add new".to_string());
339
340        let mut translation = TreeNode::with_location(
341            NodeType::Translation,
342            "invoice.labels.add_new: 'add new'".to_string(),
343            Location::new(PathBuf::from("en.yml"), 4),
344        );
345
346        let mut key_path = TreeNode::new(
347            NodeType::KeyPath,
348            "invoice.labels.add_new".to_string(),
349        );
350
351        let code_ref = TreeNode::with_location(
352            NodeType::CodeRef,
353            "I18n.t('invoice.labels.add_new')".to_string(),
354            Location::new(PathBuf::from("invoices.ts"), 14),
355        );
356
357        key_path.add_child(code_ref);
358        translation.add_child(key_path);
359        root.add_child(translation);
360
361        let tree = ReferenceTree::new(root);
362        let formatter = TreeFormatter::new();
363        let output = formatter.format(&tree);
364
365        // Verify all parts are present
366        assert!(output.contains("'add new'"));
367        assert!(output.contains("invoice.labels.add_new"));
368        assert!(output.contains("Key:"));
369        assert!(output.contains("Code:"));
370        assert!(output.contains("I18n.t"));
371        assert!(output.contains("en.yml:4"));
372        assert!(output.contains("invoices.ts:14"));
373    }
374
375    #[test]
376    fn test_format_multiple_children() {
377        let mut root = TreeNode::new(NodeType::Root, "test".to_string());
378
379        let child1 = TreeNode::with_location(
380            NodeType::Translation,
381            "key1: 'value1'".to_string(),
382            Location::new(PathBuf::from("file1.yml"), 1),
383        );
384
385        let child2 = TreeNode::with_location(
386            NodeType::Translation,
387            "key2: 'value2'".to_string(),
388            Location::new(PathBuf::from("file2.yml"), 2),
389        );
390
391        root.add_child(child1);
392        root.add_child(child2);
393
394        let tree = ReferenceTree::new(root);
395        let formatter = TreeFormatter::new();
396        let output = formatter.format(&tree);
397
398        // Should have both children
399        assert!(output.contains("key1"));
400        assert!(output.contains("key2"));
401        assert!(output.contains("file1.yml:1"));
402        assert!(output.contains("file2.yml:2"));
403
404        // Should have proper tree connectors
405        assert!(output.contains("├─>"));
406        assert!(output.contains("└─>"));
407    }
408
409    #[test]
410    fn test_truncate_long_content() {
411        let formatter = TreeFormatter::with_width(50);
412        let long_string = "a".repeat(100);
413        let truncated = formatter.truncate(&long_string, 20);
414
415        assert!(truncated.len() <= 20);
416        assert!(truncated.ends_with("..."));
417    }
418
419    #[test]
420    fn test_truncate_short_content() {
421        let formatter = TreeFormatter::new();
422        let short_string = "short";
423        let result = formatter.truncate(short_string, 20);
424
425        assert_eq!(result, "short");
426    }
427
428    #[test]
429    fn test_format_content_root() {
430        let formatter = TreeFormatter::new();
431        let node = TreeNode::new(NodeType::Root, "test query".to_string());
432        let content = formatter.format_content(&node);
433
434        assert!(content.contains("test query"));
435        assert!(content.contains("search query"));
436    }
437
438    #[test]
439    fn test_format_content_key_path() {
440        let formatter = TreeFormatter::new();
441        let node = TreeNode::new(NodeType::KeyPath, "invoice.labels.add_new".to_string());
442        let content = formatter.format_content(&node);
443
444        assert!(content.contains("Key:"));
445        assert!(content.contains("invoice.labels.add_new"));
446    }
447
448    #[test]
449    fn test_format_content_code_ref() {
450        let formatter = TreeFormatter::new();
451        let node = TreeNode::new(
452            NodeType::CodeRef,
453            "  I18n.t('invoice.labels.add_new')  ".to_string(),
454        );
455        let content = formatter.format_content(&node);
456
457        assert!(content.contains("Code:"));
458        assert!(content.contains("I18n.t"));
459        // Should trim whitespace
460        assert!(!content.starts_with("  "));
461    }
462
463    #[test]
464    fn test_format_deep_nesting() {
465        let mut root = TreeNode::new(NodeType::Root, "test".to_string());
466        let mut level1 = TreeNode::new(NodeType::Translation, "level1".to_string());
467        let mut level2 = TreeNode::new(NodeType::KeyPath, "level2".to_string());
468        let level3 = TreeNode::new(NodeType::CodeRef, "level3".to_string());
469
470        level2.add_child(level3);
471        level1.add_child(level2);
472        root.add_child(level1);
473
474        let tree = ReferenceTree::new(root);
475        let formatter = TreeFormatter::new();
476        let output = formatter.format(&tree);
477
478        // Should have proper indentation
479        let lines: Vec<&str> = output.lines().collect();
480        assert!(lines.len() >= 4);
481
482        // Check that deeper levels have more indentation
483        assert!(lines[2].starts_with(' ') || lines[2].starts_with('│'));
484    }
485}