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