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
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                let key = &node.content;
279                let value = node.metadata.as_deref().unwrap_or("");
280
281                // Truncate value if too long
282                let truncated_value = self.truncate(value, self.max_width - key.len() - 10);
283
284                format!("{}: '{}'", key.yellow().bold(), truncated_value.green())
285            }
286            NodeType::KeyPath => {
287                format!("Key: {}", node.content)
288            }
289            NodeType::CodeRef => {
290                // Truncate code context
291                let truncated = self.truncate(node.content.trim(), self.max_width - 30);
292
293                // Highlight if metadata is present
294                let display_content = if let Some(key) = &node.metadata {
295                    self.highlight_key_in_context(&truncated, key)
296                } else {
297                    truncated
298                };
299
300                format!("Code: {}", display_content)
301            }
302        }
303    }
304
305    /// Truncate a string to fit within max length (safe for unicode)
306    fn truncate(&self, s: &str, max_len: usize) -> String {
307        if s.chars().count() <= max_len {
308            s.to_string()
309        } else {
310            let truncated: String = s.chars().take(max_len.saturating_sub(3)).collect();
311            format!("{}...", truncated)
312        }
313    }
314}
315
316impl Default for TreeFormatter {
317    fn default() -> Self {
318        Self::new()
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325    use crate::tree::{Location, TreeNode};
326    use std::path::PathBuf;
327
328    #[test]
329    fn test_formatter_creation() {
330        let formatter = TreeFormatter::new();
331        assert_eq!(formatter.max_width, 80);
332    }
333
334    #[test]
335    fn test_formatter_with_custom_width() {
336        let formatter = TreeFormatter::with_width(120);
337        assert_eq!(formatter.max_width, 120);
338    }
339
340    #[test]
341    fn test_format_empty_tree() {
342        let tree = ReferenceTree::with_search_text("test".to_string());
343        let formatter = TreeFormatter::new();
344        let output = formatter.format(&tree);
345
346        assert!(output.contains("'test'"));
347        assert!(output.contains("search query"));
348    }
349
350    #[test]
351    fn test_format_tree_with_translation() {
352        let mut root = TreeNode::new(NodeType::Root, "add new".to_string());
353        let mut translation = TreeNode::with_location(
354            NodeType::Translation,
355            "invoice.labels.add_new".to_string(),
356            Location::new(PathBuf::from("en.yml"), 4),
357        );
358        translation.metadata = Some("add new".to_string());
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        assert!(output.contains("'add new'"));
366        assert!(output.contains("invoice.labels.add_new"));
367        assert!(output.contains("en.yml:4"));
368        assert!(output.contains("└─>") || output.contains("├─>"));
369    }
370
371    #[test]
372    fn test_format_complete_tree() {
373        let mut root = TreeNode::new(NodeType::Root, "add new".to_string());
374
375        let mut translation = TreeNode::with_location(
376            NodeType::Translation,
377            "invoice.labels.add_new".to_string(),
378            Location::new(PathBuf::from("en.yml"), 4),
379        );
380        translation.metadata = Some("add new".to_string());
381
382        let mut key_path = TreeNode::new(NodeType::KeyPath, "invoice.labels.add_new".to_string());
383
384        let code_ref = TreeNode::with_location(
385            NodeType::CodeRef,
386            "I18n.t('invoice.labels.add_new')".to_string(),
387            Location::new(PathBuf::from("invoices.ts"), 14),
388        );
389
390        key_path.add_child(code_ref);
391        translation.add_child(key_path);
392        root.add_child(translation);
393
394        let tree = ReferenceTree::new(root);
395        let formatter = TreeFormatter::new();
396        let output = formatter.format(&tree);
397
398        // Verify all parts are present
399        assert!(output.contains("'add new'"));
400        assert!(output.contains("invoice.labels.add_new"));
401        assert!(output.contains("Key:"));
402        assert!(output.contains("Code:"));
403        assert!(output.contains("I18n.t"));
404        assert!(output.contains("en.yml:4"));
405        assert!(output.contains("invoices.ts:14"));
406    }
407
408    #[test]
409    fn test_format_multiple_children() {
410        let mut root = TreeNode::new(NodeType::Root, "test".to_string());
411
412        let mut child1 = TreeNode::with_location(
413            NodeType::Translation,
414            "key1".to_string(),
415            Location::new(PathBuf::from("file1.yml"), 1),
416        );
417        child1.metadata = Some("value1".to_string());
418
419        let mut child2 = TreeNode::with_location(
420            NodeType::Translation,
421            "key2".to_string(),
422            Location::new(PathBuf::from("file2.yml"), 2),
423        );
424        child2.metadata = Some("value2".to_string());
425
426        root.add_child(child1);
427        root.add_child(child2);
428
429        let tree = ReferenceTree::new(root);
430        let formatter = TreeFormatter::new();
431        let output = formatter.format(&tree);
432
433        // Should have both children
434        assert!(output.contains("key1"));
435        assert!(output.contains("key2"));
436        assert!(output.contains("file1.yml:1"));
437        assert!(output.contains("file2.yml:2"));
438
439        // Should have proper tree connectors
440        assert!(output.contains("├─>"));
441        assert!(output.contains("└─>"));
442    }
443
444    #[test]
445    fn test_truncate_long_content() {
446        let formatter = TreeFormatter::with_width(50);
447        let long_string = "a".repeat(100);
448        let truncated = formatter.truncate(&long_string, 20);
449
450        assert!(truncated.len() <= 20);
451        assert!(truncated.ends_with("..."));
452    }
453
454    #[test]
455    fn test_truncate_short_content() {
456        let formatter = TreeFormatter::new();
457        let short_string = "short";
458        let result = formatter.truncate(short_string, 20);
459
460        assert_eq!(result, "short");
461    }
462
463    #[test]
464    fn test_format_content_root() {
465        let formatter = TreeFormatter::new();
466        let node = TreeNode::new(NodeType::Root, "test query".to_string());
467        let content = formatter.format_content(&node);
468
469        assert!(content.contains("test query"));
470        assert!(content.contains("search query"));
471    }
472
473    #[test]
474    fn test_format_content_key_path() {
475        let formatter = TreeFormatter::new();
476        let node = TreeNode::new(NodeType::KeyPath, "invoice.labels.add_new".to_string());
477        let content = formatter.format_content(&node);
478
479        assert!(content.contains("Key:"));
480        assert!(content.contains("invoice.labels.add_new"));
481    }
482
483    #[test]
484    fn test_format_content_code_ref() {
485        let formatter = TreeFormatter::new();
486        let node = TreeNode::new(
487            NodeType::CodeRef,
488            "  I18n.t('invoice.labels.add_new')  ".to_string(),
489        );
490        let content = formatter.format_content(&node);
491
492        assert!(content.contains("Code:"));
493        assert!(content.contains("I18n.t"));
494        // Should trim whitespace
495        assert!(!content.starts_with("  "));
496    }
497
498    #[test]
499    fn test_format_deep_nesting() {
500        let mut root = TreeNode::new(NodeType::Root, "test".to_string());
501        let mut level1 = TreeNode::new(NodeType::Translation, "level1".to_string());
502        let mut level2 = TreeNode::new(NodeType::KeyPath, "level2".to_string());
503        let level3 = TreeNode::new(NodeType::CodeRef, "level3".to_string());
504
505        level2.add_child(level3);
506        level1.add_child(level2);
507        root.add_child(level1);
508
509        let tree = ReferenceTree::new(root);
510        let formatter = TreeFormatter::new();
511        let output = formatter.format(&tree);
512
513        // Should have proper indentation
514        let lines: Vec<&str> = output.lines().collect();
515        assert!(lines.len() >= 4);
516
517        // Check that deeper levels have more indentation
518        assert!(lines[2].starts_with(' ') || lines[2].starts_with('│'));
519    }
520
521    #[test]
522    fn test_highlight_case_insensitive_lowercase() {
523        colored::control::set_override(true); // Force colors for this test
524        let formatter = TreeFormatter::new();
525        let context = "const value = pmfc.getData();";
526        let key = "PMFC";
527        let result = formatter.highlight_key_in_context(context, key);
528
529        // Should highlight 'pmfc' even though we searched for 'PMFC'
530        assert!(result.contains("pmfc"));
531        // The bold version will have ANSI codes, so we can't do exact string matching
532        // But we can verify it's different from the original
533        assert_ne!(result, context);
534    }
535
536    #[test]
537    fn test_highlight_case_insensitive_uppercase() {
538        colored::control::set_override(true); // Force colors for this test
539        let formatter = TreeFormatter::new();
540        let context = "const value = PMFC.getData();";
541        let key = "pmfc";
542        let result = formatter.highlight_key_in_context(context, key);
543
544        // Should highlight 'PMFC' even though we searched for 'pmfc'
545        assert!(result.contains("PMFC"));
546        assert_ne!(result, context);
547    }
548
549    #[test]
550    fn test_highlight_case_insensitive_mixed() {
551        colored::control::set_override(true); // Force colors for this test
552        let formatter = TreeFormatter::new();
553        let context = "const a = PmFc.get(); const b = pmfc.set();";
554        let key = "PMFC";
555        let result = formatter.highlight_key_in_context(context, key);
556
557        // Should highlight both 'PmFc' and 'pmfc'
558        assert!(result.contains("PmFc"));
559        assert!(result.contains("pmfc"));
560        assert_ne!(result, context);
561    }
562
563    #[test]
564    fn test_highlight_with_special_regex_chars() {
565        colored::control::set_override(true); // Force colors for this test
566        let formatter = TreeFormatter::new();
567        let context = "price: $19.99";
568        let key = "$19.99";
569        let result = formatter.highlight_key_in_context(context, key);
570
571        // Should escape regex special chars and still match
572        assert!(result.contains("$19.99"));
573        assert_ne!(result, context);
574    }
575
576    #[test]
577    fn test_highlight_exact_match_still_works() {
578        colored::control::set_override(true); // Force colors for this test
579        let formatter = TreeFormatter::new();
580        let context = "I18n.t('invoice.labels.add_new')";
581        let key = "invoice.labels.add_new";
582        let result = formatter.highlight_key_in_context(context, key);
583
584        // Should still highlight exact matches
585        assert!(result.contains("invoice.labels.add_new"));
586        assert_ne!(result, context);
587    }
588}