1use crate::trace::{CallNode, CallTree, TraceDirection};
2use crate::tree::{NodeType, ReferenceTree, TreeNode};
3use crate::SearchResult;
4use colored::*;
5use regex::RegexBuilder;
6
7pub struct TreeFormatter {
9 max_width: usize,
10}
11
12impl TreeFormatter {
13 pub fn new() -> Self {
15 Self { max_width: 80 }
18 }
19
20 pub fn with_width(max_width: usize) -> Self {
22 Self { max_width }
23 }
24
25 pub fn format_result(&self, result: &SearchResult) -> String {
27 let mut output = String::new();
28
29 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 if !result.code_references.is_empty() {
46 output.push_str(&format!("{}\n", "=== Code References ===".bold()));
47 for code_ref in &result.code_references {
48 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 fn highlight_key_in_context(&self, context: &str, key: &str) -> String {
65 let escaped_key = regex::escape(key);
67
68 let re = match RegexBuilder::new(&escaped_key)
70 .case_insensitive(true)
71 .build()
72 {
73 Ok(r) => r,
74 Err(_) => return context.to_string(), };
76
77 let result = re.replace_all(context, |caps: ®ex::Captures| caps[0].bold().to_string());
79
80 result.to_string()
81 }
82
83 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 let mut paths = Vec::new();
116 Self::collect_backward_paths(&tree.root, vec![], &mut paths);
117
118 for path in paths {
119 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 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 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 if node.truncated {
180 }
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 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 if !is_root {
241 output.push_str(prefix);
242 output.push_str(if is_last { "└─> " } else { "├─> " });
243 }
244
245 let content = self.format_content(node);
247 output.push_str(&content);
248
249 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 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 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 self.truncate(&node.content, self.max_width - 20)
280 }
281 NodeType::KeyPath => {
282 format!("Key: {}", node.content)
283 }
284 NodeType::CodeRef => {
285 let truncated = self.truncate(node.content.trim(), self.max_width - 30);
287
288 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 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 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 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 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 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 let lines: Vec<&str> = output.lines().collect();
506 assert!(lines.len() >= 4);
507
508 assert!(lines[2].starts_with(' ') || lines[2].starts_with('│'));
510 }
511
512 #[test]
513 fn test_highlight_case_insensitive_lowercase() {
514 colored::control::set_override(true); let formatter = TreeFormatter::new();
516 let context = "const value = pmfc.getData();";
517 let key = "PMFC";
518 let result = formatter.highlight_key_in_context(context, key);
519
520 assert!(result.contains("pmfc"));
522 assert_ne!(result, context);
525 }
526
527 #[test]
528 fn test_highlight_case_insensitive_uppercase() {
529 colored::control::set_override(true); let formatter = TreeFormatter::new();
531 let context = "const value = PMFC.getData();";
532 let key = "pmfc";
533 let result = formatter.highlight_key_in_context(context, key);
534
535 assert!(result.contains("PMFC"));
537 assert_ne!(result, context);
538 }
539
540 #[test]
541 fn test_highlight_case_insensitive_mixed() {
542 colored::control::set_override(true); let formatter = TreeFormatter::new();
544 let context = "const a = PmFc.get(); const b = pmfc.set();";
545 let key = "PMFC";
546 let result = formatter.highlight_key_in_context(context, key);
547
548 assert!(result.contains("PmFc"));
550 assert!(result.contains("pmfc"));
551 assert_ne!(result, context);
552 }
553
554 #[test]
555 fn test_highlight_with_special_regex_chars() {
556 colored::control::set_override(true); let formatter = TreeFormatter::new();
558 let context = "price: $19.99";
559 let key = "$19.99";
560 let result = formatter.highlight_key_in_context(context, key);
561
562 assert!(result.contains("$19.99"));
564 assert_ne!(result, context);
565 }
566
567 #[test]
568 fn test_highlight_exact_match_still_works() {
569 colored::control::set_override(true); let formatter = TreeFormatter::new();
571 let context = "I18n.t('invoice.labels.add_new')";
572 let key = "invoice.labels.add_new";
573 let result = formatter.highlight_key_in_context(context, key);
574
575 assert!(result.contains("invoice.labels.add_new"));
577 assert_ne!(result, context);
578 }
579}