cs/output/
formatter.rs

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