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