1use std::collections::{HashMap, HashSet};
2use std::path::PathBuf;
3
4use leta_types::*;
5
6pub fn format_truncation_with_count(
7 command_with_larger_head: &str,
8 displayed_count: u32,
9 total_count: u32,
10 command_base: &str,
11) -> String {
12 format!(
13 "[showing {} of {} results, use `{}` to show more, or `{} -N0` to show all]",
14 displayed_count, total_count, command_with_larger_head, command_base
15 )
16}
17
18pub fn format_truncation_unknown_total(
19 command_with_larger_head: &str,
20 displayed_count: u32,
21 command_base: &str,
22) -> String {
23 format!(
24 "[showing first {} results, use `{}` to show more, or `{} -N0` to show all]",
25 displayed_count, command_with_larger_head, command_base
26 )
27}
28
29pub fn format_grep_result(result: &GrepResult, head: u32, command_base: &str) -> String {
30 if let Some(warning) = &result.warning {
31 return format!("Warning: {}", warning);
32 }
33 let mut output = format_symbols(&result.symbols);
34
35 if result.truncated {
36 if !output.is_empty() {
37 output.push_str("\n\n");
38 }
39 let next_head = head * 2;
40 let cmd = format!("{} --head {}", command_base, next_head);
41 if let Some(total) = result.total_count {
42 output.push_str(&format_truncation_with_count(
43 &cmd,
44 result.symbols.len() as u32,
45 total,
46 command_base,
47 ));
48 } else {
49 output.push_str(&format_truncation_unknown_total(
50 &cmd,
51 result.symbols.len() as u32,
52 command_base,
53 ));
54 }
55 }
56
57 output
58}
59
60pub fn format_references_result(
61 result: &ReferencesResult,
62 head: u32,
63 command_base: &str,
64) -> String {
65 let mut output = format_locations(&result.locations);
66
67 if result.truncated {
68 if !output.is_empty() {
69 output.push('\n');
70 }
71 let next_head = head * 2;
72 let cmd = format!("{} --head {}", command_base, next_head);
73 if let Some(total) = result.total_count {
74 output.push_str(&format_truncation_with_count(
75 &cmd,
76 result.locations.len() as u32,
77 total,
78 command_base,
79 ));
80 } else {
81 output.push_str(&format_truncation_unknown_total(
82 &cmd,
83 result.locations.len() as u32,
84 command_base,
85 ));
86 }
87 }
88
89 output
90}
91
92pub fn format_declaration_result(
93 result: &DeclarationResult,
94 head: u32,
95 command_base: &str,
96) -> String {
97 let mut output = format_locations(&result.locations);
98
99 if result.truncated {
100 if !output.is_empty() {
101 output.push('\n');
102 }
103 let next_head = head * 2;
104 let cmd = format!("{} --head {}", command_base, next_head);
105 if let Some(total) = result.total_count {
106 output.push_str(&format_truncation_with_count(
107 &cmd,
108 result.locations.len() as u32,
109 total,
110 command_base,
111 ));
112 } else {
113 output.push_str(&format_truncation_unknown_total(
114 &cmd,
115 result.locations.len() as u32,
116 command_base,
117 ));
118 }
119 }
120
121 output
122}
123
124pub fn format_implementations_result(
125 result: &ImplementationsResult,
126 head: u32,
127 command_base: &str,
128) -> String {
129 if let Some(error) = &result.error {
130 return format!("Error: {}", error);
131 }
132 let mut output = format_locations(&result.locations);
133
134 if result.truncated {
135 if !output.is_empty() {
136 output.push('\n');
137 }
138 let next_head = head * 2;
139 let cmd = format!("{} --head {}", command_base, next_head);
140 if let Some(total) = result.total_count {
141 output.push_str(&format_truncation_with_count(
142 &cmd,
143 result.locations.len() as u32,
144 total,
145 command_base,
146 ));
147 } else {
148 output.push_str(&format_truncation_unknown_total(
149 &cmd,
150 result.locations.len() as u32,
151 command_base,
152 ));
153 }
154 }
155
156 output
157}
158
159pub fn format_subtypes_result(result: &SubtypesResult, head: u32, command_base: &str) -> String {
160 let mut output = format_locations(&result.locations);
161
162 if result.truncated {
163 if !output.is_empty() {
164 output.push('\n');
165 }
166 let next_head = head * 2;
167 let cmd = format!("{} --head {}", command_base, next_head);
168 if let Some(total) = result.total_count {
169 output.push_str(&format_truncation_with_count(
170 &cmd,
171 result.locations.len() as u32,
172 total,
173 command_base,
174 ));
175 } else {
176 output.push_str(&format_truncation_unknown_total(
177 &cmd,
178 result.locations.len() as u32,
179 command_base,
180 ));
181 }
182 }
183
184 output
185}
186
187pub fn format_supertypes_result(
188 result: &SupertypesResult,
189 head: u32,
190 command_base: &str,
191) -> String {
192 let mut output = format_locations(&result.locations);
193
194 if result.truncated {
195 if !output.is_empty() {
196 output.push('\n');
197 }
198 let next_head = head * 2;
199 let cmd = format!("{} --head {}", command_base, next_head);
200 if let Some(total) = result.total_count {
201 output.push_str(&format_truncation_with_count(
202 &cmd,
203 result.locations.len() as u32,
204 total,
205 command_base,
206 ));
207 } else {
208 output.push_str(&format_truncation_unknown_total(
209 &cmd,
210 result.locations.len() as u32,
211 command_base,
212 ));
213 }
214 }
215
216 output
217}
218
219pub fn format_show_result(result: &ShowResult, head: u32) -> String {
220 let location = if result.start_line == result.end_line {
221 format!("{}:{}", result.path, result.start_line)
222 } else {
223 format!("{}:{}-{}", result.path, result.start_line, result.end_line)
224 };
225
226 let mut lines = vec![location, String::new(), result.content.clone()];
227
228 if result.truncated {
229 let total_lines = result.total_lines.unwrap_or(head);
230 let symbol = result.symbol.as_deref().unwrap_or("SYMBOL");
231 lines.push(String::new());
232 lines.push(format!(
233 "[truncated after {} lines, use `leta show \"{}\" --head {}` to show the full {} lines]",
234 head, symbol, total_lines, total_lines
235 ));
236 }
237
238 lines.join("\n")
239}
240
241pub fn format_rename_result(result: &RenameResult) -> String {
242 let mut files: Vec<_> = result.files_changed.iter().collect();
243 files.sort();
244 format!(
245 "Renamed in {} file(s):\n{}",
246 files.len(),
247 files
248 .iter()
249 .map(|f| format!(" {}", f))
250 .collect::<Vec<_>>()
251 .join("\n")
252 )
253}
254
255pub fn format_move_file_result(result: &MoveFileResult) -> String {
256 let mut files: Vec<_> = result.files_changed.iter().collect();
257 files.sort();
258 if result.imports_updated {
259 format!(
260 "Moved file and updated imports in {} file(s):\n{}",
261 files.len(),
262 files
263 .iter()
264 .map(|f| format!(" {}", f))
265 .collect::<Vec<_>>()
266 .join("\n")
267 )
268 } else if let Some(first) = files.first() {
269 format!("Moved file (imports not updated):\n {}", first)
270 } else {
271 "File moved".to_string()
272 }
273}
274
275pub fn format_restart_workspace_result(result: &RestartWorkspaceResult) -> String {
276 format!(
277 "Restarted {} server(s): {}",
278 result.restarted.len(),
279 result.restarted.join(", ")
280 )
281}
282
283pub fn format_remove_workspace_result(result: &RemoveWorkspaceResult) -> String {
284 format!(
285 "Stopped {} server(s): {}",
286 result.servers_stopped.len(),
287 result.servers_stopped.join(", ")
288 )
289}
290
291pub fn format_files_result(result: &FilesResult, head: u32, command_base: &str) -> String {
292 if result.files.is_empty() && result.excluded_dirs.is_empty() {
293 return String::new();
294 }
295
296 let tree = build_tree(&result.files, &result.excluded_dirs);
297 let mut lines = Vec::new();
298 render_tree(&tree, &mut lines, 0);
299
300 if result.truncated {
301 lines.push(String::new());
302 let next_head = head * 2;
303 let cmd = format!("{} --head {}", command_base, next_head);
304 lines.push(format_truncation_unknown_total(
305 &cmd,
306 result.files.len() as u32,
307 command_base,
308 ));
309 }
310
311 lines.join("\n")
312}
313
314pub fn format_calls_result(result: &CallsResult, head: u32, command_base: &str) -> String {
315 if let Some(error) = &result.error {
316 return format!("Error: {}", error);
317 }
318 if let Some(message) = &result.message {
319 return message.clone();
320 }
321 let mut output = String::new();
322 if let Some(root) = &result.root {
323 output = format_call_tree(root);
324 } else if let Some(path) = &result.path {
325 output = format_call_path(path);
326 }
327
328 if result.truncated {
329 if !output.is_empty() {
330 output.push_str("\n\n");
331 }
332 let next_head = head * 2;
333 let cmd = format!("{} --head {}", command_base, next_head);
334 output.push_str(&format_truncation_unknown_total(&cmd, head, command_base));
335 }
336
337 output
338}
339
340pub fn format_describe_session_result(
341 result: &DescribeSessionResult,
342 show_profiling: bool,
343) -> String {
344 let mut lines = vec![format!("Daemon PID: {}", result.daemon_pid)];
345
346 if !result.caches.is_empty() {
347 lines.push("\nCaches:".to_string());
348 if let Some(hover) = result.caches.get("hover_cache") {
349 lines.push(format!(
350 " Hover: {} / {} ({} entries)",
351 format_size(hover.current_bytes),
352 format_size(hover.max_bytes),
353 hover.entries
354 ));
355 }
356 if let Some(symbol) = result.caches.get("symbol_cache") {
357 lines.push(format!(
358 " Symbol: {} / {} ({} entries)",
359 format_size(symbol.current_bytes),
360 format_size(symbol.max_bytes),
361 symbol.entries
362 ));
363 }
364 }
365
366 let profiling_map: HashMap<&str, &WorkspaceProfilingData> = result
367 .profiling
368 .as_ref()
369 .map(|data| {
370 data.iter()
371 .map(|p| (p.workspace_root.as_str(), p))
372 .collect()
373 })
374 .unwrap_or_default();
375
376 let mut workspace_roots: std::collections::HashSet<&str> = result
377 .workspaces
378 .iter()
379 .map(|ws| ws.root.as_str())
380 .collect();
381
382 for root in profiling_map.keys() {
383 workspace_roots.insert(root);
384 }
385
386 if workspace_roots.is_empty() {
387 lines.push("\nNo active workspaces".to_string());
388 } else {
389 lines.push("\nActive workspaces:".to_string());
390
391 let mut sorted_roots: Vec<_> = workspace_roots.into_iter().collect();
392 sorted_roots.sort();
393
394 for root in sorted_roots {
395 lines.push(format!("\n {}", root));
396
397 let workspaces_for_root: Vec<_> = result
398 .workspaces
399 .iter()
400 .filter(|ws| ws.root == root)
401 .collect();
402
403 let profiling_data = profiling_map.get(root);
404
405 for ws in &workspaces_for_root {
406 let status = if ws.server_pid.is_some() {
407 "running"
408 } else {
409 "stopped"
410 };
411 let pid_str = ws
412 .server_pid
413 .map(|p| format!(", PID {}", p))
414 .unwrap_or_default();
415
416 lines.push(format!(
417 " {} ({}{}) [{} open files]",
418 ws.language,
419 status,
420 pid_str,
421 ws.open_documents.len()
422 ));
423
424 if show_profiling {
425 if let Some(profile) = profiling_data.and_then(|p| {
426 p.server_profiles
427 .iter()
428 .find(|sp| sp.server_name == ws.language)
429 }) {
430 if let Some(startup) = &profile.startup {
431 lines.push(format!(
432 " Startup: {}ms (init: {}ms, ready: {}ms)",
433 startup.total_time_ms, startup.init_time_ms, startup.ready_time_ms
434 ));
435 lines.extend(format_function_stats(&startup.functions, " ", 5));
436 }
437 if let Some(indexing) = &profile.indexing {
438 let cache = &indexing.cache;
439 let symbol_total = cache.symbol_hits + cache.symbol_misses;
440 let cache_str = if symbol_total > 0 {
441 format!(
442 ", cache {}/{} ({:.0}%)",
443 cache.symbol_hits,
444 symbol_total,
445 cache.symbol_hit_rate()
446 )
447 } else {
448 String::new()
449 };
450 lines.push(format!(
451 " Indexing: {}ms ({} files{})",
452 indexing.total_time_ms, indexing.file_count, cache_str
453 ));
454 lines.extend(format_function_stats(
455 &indexing.functions,
456 " ",
457 10,
458 ));
459 }
460 }
461 }
462 }
463
464 if show_profiling {
465 if let Some(profile) = profiling_data {
466 lines.push(format!(
467 " Total: {}ms ({} files)",
468 profile.total_time_ms, profile.total_files
469 ));
470 }
471 }
472 }
473 }
474
475 lines.join("\n")
476}
477
478fn format_duration_us(us: u64) -> String {
479 if us >= 1_000_000 {
480 format!("{:.2}s", us as f64 / 1_000_000.0)
481 } else if us >= 1_000 {
482 format!("{:.1}ms", us as f64 / 1_000.0)
483 } else {
484 format!("{}µs", us)
485 }
486}
487
488pub fn format_function_name(name: &str) -> &str {
489 name.strip_prefix("leta_daemon::handlers::")
490 .or_else(|| name.strip_prefix("leta_daemon::"))
491 .or_else(|| name.strip_prefix("leta_lsp::"))
492 .or_else(|| name.strip_prefix("leta_"))
493 .unwrap_or(name)
494 .trim_end_matches("::{{closure}}")
495}
496
497pub fn format_function_stats(
498 functions: &[FunctionStats],
499 indent: &str,
500 max_lines: usize,
501) -> Vec<String> {
502 let mut lines = Vec::new();
503 if functions.is_empty() {
504 return lines;
505 }
506 lines.push(format!(
507 "{}{:<50} {:>6} {:>10} {:>10} {:>10}",
508 indent, "Function", "Calls", "Avg", "P90", "Total"
509 ));
510 for func in functions.iter().take(max_lines) {
511 let name = format_function_name(&func.name);
512 lines.push(format!(
513 "{}{:<50} {:>6} {:>10} {:>10} {:>10}",
514 indent,
515 name,
516 func.calls,
517 format_duration_us(func.avg_us),
518 format_duration_us(func.p90_us),
519 format_duration_us(func.total_us),
520 ));
521 }
522 lines
523}
524
525pub fn format_resolve_symbol_result(result: &ResolveSymbolResult) -> String {
526 if let Some(error) = &result.error {
527 let mut lines = vec![format!("Error: {}", error)];
528 if let Some(matches) = &result.matches {
529 for m in matches {
530 let container = m
531 .container
532 .as_ref()
533 .map(|c| format!(" in {}", c))
534 .unwrap_or_default();
535 let kind = format!("[{}] ", m.kind);
536 let detail = m
537 .detail
538 .as_ref()
539 .map(|d| format!(" ({})", d))
540 .unwrap_or_default();
541 let ref_str = m.reference.as_deref().unwrap_or("");
542 lines.push(format!(" {}", ref_str));
543 lines.push(format!(
544 " {}:{} {}{}{}{}",
545 m.path, m.line, kind, m.name, detail, container
546 ));
547 }
548 if let Some(total) = result.total_matches {
549 let shown = matches.len() as u32;
550 if total > shown {
551 lines.push(format!(" ... and {} more", total - shown));
552 }
553 }
554 }
555 return lines.join("\n");
556 }
557 format!(
558 "{}:{}",
559 result.path.as_deref().unwrap_or(""),
560 result.line.unwrap_or(0)
561 )
562}
563
564fn format_locations(locations: &[LocationInfo]) -> String {
565 let mut lines = Vec::new();
566 for loc in locations {
567 if let (Some(name), Some(kind)) = (&loc.name, &loc.kind) {
568 let mut parts = vec![
569 format!("{}:{}", loc.path, loc.line),
570 format!("[{}]", kind),
571 name.clone(),
572 ];
573 if let Some(detail) = &loc.detail {
574 if !detail.is_empty() && detail != "()" {
575 parts.push(format!("({})", detail));
576 }
577 }
578 lines.push(parts.join(" "));
579 } else if let Some(context) = &loc.context_lines {
580 let context_start = loc.context_start.unwrap_or(loc.line);
581 let context_end = context_start + context.len() as u32 - 1;
582 lines.push(format!("{}:{}-{}", loc.path, context_start, context_end));
583 for line in context {
584 lines.push(line.clone());
585 }
586 lines.push(String::new());
587 } else {
588 let line_content = get_line_content(&loc.path, loc.line);
589 if let Some(content) = line_content {
590 lines.push(format!("{}:{} {}", loc.path, loc.line, content));
591 } else {
592 lines.push(format!("{}:{}", loc.path, loc.line));
593 }
594 }
595 }
596 lines.join("\n")
597}
598
599fn get_line_content(path: &str, line: u32) -> Option<String> {
600 let file_path = PathBuf::from(path);
601 let file_path = if file_path.is_absolute() {
602 file_path
603 } else {
604 std::env::current_dir().ok()?.join(&file_path)
605 };
606
607 let content = std::fs::read_to_string(&file_path).ok()?;
608 let lines: Vec<&str> = content.lines().collect();
609 if line > 0 && (line as usize) <= lines.len() {
610 Some(lines[line as usize - 1].to_string())
611 } else {
612 None
613 }
614}
615
616pub fn format_file_line(file: &FileInfo) -> String {
617 format!(
618 "{} ({}, {} lines)",
619 file.path,
620 format_size(file.bytes),
621 file.lines
622 )
623}
624
625pub struct FileTreePrinter {
628 current_path: Vec<String>,
629}
630
631impl FileTreePrinter {
632 pub fn new() -> Self {
633 Self {
634 current_path: Vec::new(),
635 }
636 }
637
638 pub fn format_file(&mut self, file: &FileInfo) -> String {
641 let parts: Vec<&str> = file.path.split('/').collect();
642 let (dirs, filename) = parts.split_at(parts.len().saturating_sub(1));
643
644 let mut output = String::new();
645
646 let mut common_depth = 0;
648 for (i, dir) in dirs.iter().enumerate() {
649 if i < self.current_path.len() && self.current_path[i] == *dir {
650 common_depth = i + 1;
651 } else {
652 break;
653 }
654 }
655
656 self.current_path.truncate(common_depth);
658
659 for (i, dir) in dirs.iter().enumerate().skip(common_depth) {
661 let indent = " ".repeat(i);
662 output.push_str(&format!("{}{}/\n", indent, dir));
663 self.current_path.push(dir.to_string());
664 }
665
666 let indent = " ".repeat(dirs.len());
668 let info_str = format!("{}, {} lines", format_size(file.bytes), file.lines);
669 if let Some(name) = filename.first() {
670 output.push_str(&format!("{}{} ({})", indent, name, info_str));
671 }
672
673 output
674 }
675
676 pub fn format_excluded_dir(&mut self, path: &str) -> String {
677 let parts: Vec<&str> = path.split('/').collect();
678 if parts.is_empty() {
679 return String::new();
680 }
681
682 let (parent_dirs, dir_name) = parts.split_at(parts.len().saturating_sub(1));
683
684 let mut output = String::new();
685
686 let mut common_depth = 0;
687 for (i, dir) in parent_dirs.iter().enumerate() {
688 if i < self.current_path.len() && self.current_path[i] == *dir {
689 common_depth = i + 1;
690 } else {
691 break;
692 }
693 }
694
695 self.current_path.truncate(common_depth);
696
697 for (i, dir) in parent_dirs.iter().enumerate().skip(common_depth) {
698 let indent = " ".repeat(i);
699 output.push_str(&format!("{}{}/\n", indent, dir));
700 self.current_path.push(dir.to_string());
701 }
702
703 let indent = " ".repeat(parent_dirs.len());
704 if let Some(name) = dir_name.first() {
705 output.push_str(&format!("{}{} (excluded)", indent, name));
706 }
707
708 output
709 }
710}
711
712impl Default for FileTreePrinter {
713 fn default() -> Self {
714 Self::new()
715 }
716}
717
718pub fn format_symbol_line(sym: &SymbolInfo) -> String {
719 let location = format!("{}:{}", sym.path, sym.line);
720 let mut parts = vec![location, format!("[{}]", sym.kind), sym.name.clone()];
721 if let Some(detail) = &sym.detail {
722 if !detail.is_empty() && detail != "()" {
723 parts.push(format!("({})", detail));
724 }
725 }
726 if let Some(container) = &sym.container {
727 parts.push(format!("in {}", container));
728 }
729 parts.join(" ")
730}
731
732fn format_symbols(symbols: &[SymbolInfo]) -> String {
733 let mut lines = Vec::new();
734 for sym in symbols {
735 lines.push(format_symbol_line(sym));
736 }
737 lines.join("\n")
738}
739
740pub fn format_size(size: u64) -> String {
741 if size < 1024 {
742 format!("{}B", size)
743 } else if size < 1024 * 1024 {
744 format!("{:.1}KB", size as f64 / 1024.0)
745 } else {
746 format!("{:.1}MB", size as f64 / (1024.0 * 1024.0))
747 }
748}
749
750pub fn format_profiling(profiling: &ProfilingData) -> String {
751 let mut lines = Vec::new();
752
753 let cache = &profiling.cache;
754 let symbol_total = cache.symbol_hits + cache.symbol_misses;
755 let hover_total = cache.hover_hits + cache.hover_misses;
756
757 if symbol_total > 0 || hover_total > 0 {
758 lines.push("CACHE".to_string());
759 if symbol_total > 0 {
760 lines.push(format!(
761 " symbols: {}/{} hits ({:.1}%)",
762 cache.symbol_hits,
763 symbol_total,
764 cache.symbol_hit_rate()
765 ));
766 }
767 if hover_total > 0 {
768 lines.push(format!(
769 " hover: {}/{} hits ({:.1}%)",
770 cache.hover_hits,
771 hover_total,
772 cache.hover_hit_rate()
773 ));
774 }
775 lines.push(String::new());
776 }
777
778 if let Some(tree) = &profiling.span_tree {
779 lines.push(format!(
780 "{:<55} {:>7} {:>9} {:>9} {:>9}",
781 "Function", "Calls", "Avg", "P90", "Total"
782 ));
783 lines.push("-".repeat(90));
784
785 for root in &tree.roots {
786 format_span_node(root, &mut lines, 0, &tree.functions);
787 }
788
789 lines.push("-".repeat(90));
790 lines.push(format!(
791 "{:<55} {:>7} {:>9} {:>9} {:>9}",
792 "TOTAL",
793 "",
794 "",
795 "",
796 format_duration_us(tree.total_us)
797 ));
798 }
799
800 lines.join("\n")
801}
802
803fn get_func_stats<'a>(name: &str, functions: &'a [FunctionStats]) -> Option<&'a FunctionStats> {
804 functions.iter().find(|f| f.name == name)
805}
806
807fn format_span_node(
808 node: &SpanNode,
809 lines: &mut Vec<String>,
810 depth: usize,
811 functions: &[FunctionStats],
812) {
813 let indent = " ".repeat(depth);
814 let parallel_marker = if node.is_parallel { " ||" } else { "" };
815
816 let stats = get_func_stats(&node.name, functions);
817 let (calls, avg, p90) = if let Some(s) = stats {
818 (s.calls, s.avg_us, s.p90_us)
819 } else {
820 (
821 node.calls,
822 if node.calls > 0 {
823 node.total_us / node.calls as u64
824 } else {
825 0
826 },
827 0,
828 )
829 };
830
831 let name_with_indent = format!("{}{}{}", indent, node.name, parallel_marker);
832
833 lines.push(format!(
834 "{:<55} {:>7} {:>9} {:>9} {:>9}",
835 truncate_left(&name_with_indent, 55),
836 calls,
837 format_duration_us(avg),
838 format_duration_us(p90),
839 format_duration_us(node.total_us)
840 ));
841
842 let mut property_time_ms = 0.0f64;
844 if !node.properties.is_empty() {
845 let props_indent = " ".repeat(depth + 1);
846
847 let mut aggregated: std::collections::HashMap<&str, (f64, u32)> =
849 std::collections::HashMap::new();
850 for (k, v) in &node.properties {
851 if k.ends_with("_ms") {
852 if let Ok(ms) = v.parse::<f64>() {
853 property_time_ms += ms;
854 let entry = aggregated.entry(k.as_str()).or_insert((0.0, 0));
855 entry.0 += ms;
856 entry.1 += 1;
857 }
858 } else if let Ok(num) = v.parse::<f64>() {
859 let entry = aggregated.entry(k.as_str()).or_insert((0.0, 0));
860 entry.0 += num;
861 entry.1 += 1;
862 } else {
863 let entry = aggregated.entry(k.as_str()).or_insert((0.0, 0));
865 entry.1 += 1;
866 }
867 }
868
869 let mut props_str: Vec<String> = aggregated
871 .iter()
872 .map(|(k, (sum, count))| {
873 if *count > 1 {
874 if k.ends_with("_ms") {
875 format!(
876 "{}={:.1}ms total ({} calls)",
877 k.trim_end_matches("_ms"),
878 sum,
879 count
880 )
881 } else {
882 format!("{}={:.0} total", k, sum)
883 }
884 } else if k.ends_with("_ms") {
885 format!("{}={:.1}ms", k.trim_end_matches("_ms"), sum)
886 } else {
887 format!("{}={:.0}", k, sum)
888 }
889 })
890 .collect();
891 props_str.sort();
892
893 lines.push(format!("{} [{}]", props_indent, props_str.join(", ")));
894 }
895
896 for child in &node.children {
897 format_span_node(child, lines, depth + 1, functions);
898 }
899
900 let property_time_us = (property_time_ms * 1000.0) as u64;
905 let truly_unaccounted = node.self_us.saturating_sub(property_time_us);
906
907 if truly_unaccounted > 1000 && !node.children.is_empty() {
908 let unaccounted_name = format!("{}[unaccounted]", " ".repeat(depth + 1));
909 lines.push(format!(
910 "{:<55} {:>7} {:>9} {:>9} {:>9}",
911 unaccounted_name,
912 "",
913 "",
914 "",
915 format_duration_us(truly_unaccounted)
916 ));
917 }
918}
919
920fn truncate_left(s: &str, max_len: usize) -> String {
921 if s.len() <= max_len {
922 s.to_string()
923 } else {
924 format!("…{}", &s[s.len() - max_len + 1..])
925 }
926}
927
928enum TreeNode {
929 File(FileInfo),
930 Dir(HashMap<String, TreeNode>),
931 ExcludedDir,
932}
933
934fn build_tree(
935 files: &HashMap<String, FileInfo>,
936 excluded_dirs: &[String],
937) -> HashMap<String, TreeNode> {
938 let mut tree: HashMap<String, TreeNode> = HashMap::new();
939
940 for (rel_path, info) in files {
941 let parts: Vec<&str> = rel_path.split('/').collect();
942 let mut current = &mut tree;
943
944 for (i, part) in parts.iter().enumerate() {
945 if i == parts.len() - 1 {
946 current.insert(part.to_string(), TreeNode::File(info.clone()));
947 } else {
948 current = match current
949 .entry(part.to_string())
950 .or_insert_with(|| TreeNode::Dir(HashMap::new()))
951 {
952 TreeNode::Dir(map) => map,
953 _ => unreachable!(),
954 };
955 }
956 }
957 }
958
959 for excluded_path in excluded_dirs {
960 let parts: Vec<&str> = excluded_path.split('/').collect();
961 let mut current = &mut tree;
962
963 for (i, part) in parts.iter().enumerate() {
964 if i == parts.len() - 1 {
965 current
966 .entry(part.to_string())
967 .or_insert(TreeNode::ExcludedDir);
968 } else {
969 current = match current
970 .entry(part.to_string())
971 .or_insert_with(|| TreeNode::Dir(HashMap::new()))
972 {
973 TreeNode::Dir(map) => map,
974 _ => break,
975 };
976 }
977 }
978 }
979
980 tree
981}
982
983fn render_tree(node: &HashMap<String, TreeNode>, lines: &mut Vec<String>, indent: usize) {
984 let mut entries: Vec<_> = node.keys().collect();
985 entries.sort();
986
987 let prefix = " ".repeat(indent);
988
989 for name in entries {
990 let child = node.get(name).unwrap();
991
992 match child {
993 TreeNode::File(info) => {
994 let info_str = format_file_info(info);
995 lines.push(format!("{}{} ({})", prefix, name, info_str));
996 }
997 TreeNode::Dir(children) => {
998 lines.push(format!("{}{}/", prefix, name));
999 render_tree(children, lines, indent + 1);
1000 }
1001 TreeNode::ExcludedDir => {
1002 lines.push(format!("{}{} (excluded)", prefix, name));
1003 }
1004 }
1005 }
1006}
1007
1008fn format_file_info(info: &FileInfo) -> String {
1009 format!("{}, {} lines", format_size(info.bytes), info.lines)
1010}
1011
1012fn is_stdlib_path(path: &str) -> bool {
1013 path.contains("/typeshed-fallback/stdlib/")
1014 || path.contains("/typeshed/stdlib/")
1015 || (path.contains("/libexec/src/") && !path.contains("/mod/"))
1016 || (path.ends_with(".d.ts")
1017 && path
1018 .split('/')
1019 .next_back()
1020 .map(|f| f.starts_with("lib."))
1021 .unwrap_or(false))
1022 || path.contains("/rustlib/src/rust/library/")
1023}
1024
1025fn should_show_detail(detail: &Option<String>) -> bool {
1026 detail
1027 .as_ref()
1028 .map(|d| !d.is_empty() && d != "()")
1029 .unwrap_or(false)
1030}
1031
1032fn format_call_tree(node: &CallNode) -> String {
1033 let mut lines = Vec::new();
1034
1035 let mut parts: Vec<String> = Vec::new();
1036 if let Some(path) = &node.path {
1037 parts.push(format!("{}:{}", path, node.line.unwrap_or(0)));
1038 }
1039 if let Some(kind) = &node.kind {
1040 parts.push(format!("[{}]", kind));
1041 }
1042 parts.push(node.name.clone());
1043 if should_show_detail(&node.detail) {
1044 parts.push(format!("({})", node.detail.as_ref().unwrap()));
1045 }
1046 lines.push(parts.join(" "));
1047
1048 if let Some(calls) = &node.calls {
1049 lines.push(String::new());
1050 lines.push("Outgoing calls:".to_string());
1051 if !calls.is_empty() {
1052 render_calls_tree(calls, &mut lines, " ", true);
1053 }
1054 } else if let Some(called_by) = &node.called_by {
1055 lines.push(String::new());
1056 lines.push("Incoming calls:".to_string());
1057 if !called_by.is_empty() {
1058 render_calls_tree(called_by, &mut lines, " ", false);
1059 }
1060 }
1061
1062 lines.join("\n")
1063}
1064
1065fn render_calls_tree(items: &[CallNode], lines: &mut Vec<String>, prefix: &str, is_outgoing: bool) {
1066 for (i, item) in items.iter().enumerate() {
1067 let is_last = i == items.len() - 1;
1068 let connector = if is_last { "└── " } else { "├── " };
1069 let child_prefix = format!("{}{}", prefix, if is_last { " " } else { "│ " });
1070
1071 let path = item.path.as_deref().unwrap_or("");
1072 let line = item.line.unwrap_or(0);
1073
1074 let mut parts: Vec<String> = Vec::new();
1075 if !item.in_workspace || is_stdlib_path(path) {
1076 if let Some(kind) = &item.kind {
1077 parts.push(format!("[{}]", kind));
1078 }
1079 } else {
1080 parts.push(format!("{}:{}", path, line));
1081 if let Some(kind) = &item.kind {
1082 parts.push(format!("[{}]", kind));
1083 }
1084 }
1085 parts.push(item.name.clone());
1086 if should_show_detail(&item.detail) {
1087 parts.push(format!("({})", item.detail.as_ref().unwrap()));
1088 }
1089 lines.push(format!("{}{}{}", prefix, connector, parts.join(" ")));
1090
1091 let children = if is_outgoing {
1092 &item.calls
1093 } else {
1094 &item.called_by
1095 };
1096 if let Some(children) = children {
1097 render_calls_tree(children, lines, &child_prefix, is_outgoing);
1098 }
1099 }
1100}
1101
1102fn format_call_path(path: &[CallNode]) -> String {
1103 if path.is_empty() {
1104 return "Empty path".to_string();
1105 }
1106
1107 let mut lines = vec!["Call path:".to_string()];
1108 for (i, item) in path.iter().enumerate() {
1109 let file_path = item.path.as_deref().unwrap_or("");
1110 let line = item.line.unwrap_or(0);
1111
1112 let mut parts = vec![format!("{}:{}", file_path, line)];
1113 if let Some(kind) = &item.kind {
1114 parts.push(format!("[{}]", kind));
1115 }
1116 parts.push(item.name.clone());
1117 if should_show_detail(&item.detail) {
1118 parts.push(format!("({})", item.detail.as_ref().unwrap()));
1119 }
1120
1121 let arrow = if i == 0 { "" } else { " → " };
1122 lines.push(format!("{}{}", arrow, parts.join(" ")));
1123 }
1124
1125 lines.join("\n")
1126}
1127
1128pub fn format_graph_result(result: &GraphResult, include_orphans: bool) -> String {
1129 use leta_types::{CallGraphEdge, CallGraphSymbol};
1130
1131 let node_key = |s: &CallGraphSymbol| format!("{}:{}:{}", s.path, s.line, s.name);
1132
1133 let mut outgoing: HashMap<String, Vec<&CallGraphEdge>> = HashMap::new();
1134 let mut has_incoming: HashSet<String> = HashSet::new();
1135 let mut has_outgoing: HashSet<String> = HashSet::new();
1136 let mut self_recursive: HashSet<String> = HashSet::new();
1137 let mut node_map: HashMap<String, &CallGraphSymbol> = HashMap::new();
1138
1139 for node in &result.nodes {
1140 node_map.insert(node_key(node), node);
1141 }
1142
1143 for edge in &result.edges {
1144 let caller_key = node_key(&edge.caller);
1145 let callee_key = node_key(&edge.callee);
1146
1147 node_map.entry(callee_key.clone()).or_insert(&edge.callee);
1148
1149 if caller_key == callee_key {
1150 self_recursive.insert(caller_key);
1151 continue;
1152 }
1153
1154 outgoing.entry(caller_key.clone()).or_default().push(edge);
1155 has_incoming.insert(callee_key);
1156 has_outgoing.insert(caller_key);
1157 }
1158
1159 for children in outgoing.values_mut() {
1160 children.sort_by_key(|e| e.call_site_line.unwrap_or(u32::MAX));
1161 }
1162
1163 let mut roots: Vec<String> = Vec::new();
1164 for key in has_outgoing.iter() {
1165 if !has_incoming.contains(key) {
1166 roots.push(key.clone());
1167 }
1168 }
1169 for key in self_recursive.iter() {
1170 if !has_incoming.contains(key) && !has_outgoing.contains(key) {
1171 roots.push(key.clone());
1172 }
1173 }
1174
1175 fn count_reachable(
1176 key: &str,
1177 outgoing: &HashMap<String, Vec<&CallGraphEdge>>,
1178 seen: &mut HashSet<String>,
1179 ) -> usize {
1180 if !seen.insert(key.to_string()) {
1181 return 0;
1182 }
1183 let mut count = 1;
1184 let node_key = |s: &CallGraphSymbol| format!("{}:{}:{}", s.path, s.line, s.name);
1185 if let Some(children) = outgoing.get(key) {
1186 for edge in children {
1187 count += count_reachable(&node_key(&edge.callee), outgoing, seen);
1188 }
1189 }
1190 count
1191 }
1192
1193 roots.sort_by(|a, b| {
1194 let size_a = count_reachable(a, &outgoing, &mut HashSet::new());
1195 let size_b = count_reachable(b, &outgoing, &mut HashSet::new());
1196 size_b.cmp(&size_a).then_with(|| a.cmp(b))
1197 });
1198
1199 let mut roots_by_lang: Vec<(String, Vec<String>)> = Vec::new();
1200 let mut lang_map: HashMap<String, Vec<String>> = HashMap::new();
1201 for root_key in roots {
1202 let lang = node_map
1203 .get(root_key.as_str())
1204 .map(|n| language_from_path(&n.path))
1205 .unwrap_or_else(|| "Unknown".to_string());
1206 lang_map.entry(lang).or_default().push(root_key);
1207 }
1208 for (lang, lang_roots) in lang_map {
1209 roots_by_lang.push((lang, lang_roots));
1210 }
1211 roots_by_lang.sort_by(|a, b| {
1212 let size_a: usize =
1213 a.1.iter()
1214 .map(|r| count_reachable(r, &outgoing, &mut HashSet::new()))
1215 .sum();
1216 let size_b: usize =
1217 b.1.iter()
1218 .map(|r| count_reachable(r, &outgoing, &mut HashSet::new()))
1219 .sum();
1220 size_b.cmp(&size_a).then_with(|| a.0.cmp(&b.0))
1221 });
1222
1223 let mut visited: HashSet<String> = HashSet::new();
1224 let mut lines: Vec<String> = Vec::new();
1225 let show_lang_headers = roots_by_lang.len() > 1;
1226
1227 for (lang, lang_roots) in &roots_by_lang {
1228 if show_lang_headers {
1229 if !lines.is_empty() {
1230 lines.push(String::new());
1231 }
1232 lines.push(lang.clone());
1233 lines.push("═".repeat(lang.len()));
1234 lines.push(String::new());
1235 }
1236
1237 for root_key in lang_roots {
1238 let node = match node_map.get(root_key.as_str()) {
1239 Some(n) => n,
1240 None => continue,
1241 };
1242
1243 let mut root_line = format_graph_node_label(node, true);
1244 let has_children = outgoing.contains_key(root_key.as_str());
1245 let is_self_recursive = self_recursive.contains(root_key.as_str());
1246
1247 if is_self_recursive && !has_children {
1248 root_line.push_str(" ↻");
1249 lines.push(root_line);
1250 lines.push(String::new());
1251 visited.insert(root_key.clone());
1252 continue;
1253 }
1254
1255 if is_self_recursive {
1256 root_line.push_str(" ↻");
1257 }
1258
1259 lines.push(root_line);
1260 visited.insert(root_key.clone());
1261
1262 if let Some(children) = outgoing.get(root_key.as_str()) {
1263 render_graph_tree(
1264 children,
1265 &outgoing,
1266 &self_recursive,
1267 &mut visited,
1268 &mut lines,
1269 "",
1270 );
1271 }
1272
1273 lines.push(String::new());
1274 }
1275 }
1276
1277 if include_orphans {
1281 for node in &result.nodes {
1282 let key = node_key(node);
1283 if !has_incoming.contains(&key)
1284 && !has_outgoing.contains(&key)
1285 && !self_recursive.contains(&key)
1286 {
1287 lines.push(format!("{} (orphan)", format_graph_node_label(node, true)));
1288 lines.push(String::new());
1289 }
1290 }
1291 }
1292
1293 lines.join("\n")
1294}
1295
1296fn format_graph_node_label(node: &leta_types::CallGraphSymbol, show_path: bool) -> String {
1297 let mut parts = Vec::new();
1298 if show_path {
1299 parts.push(format!("{}:{}", node.path, node.line));
1300 }
1301 parts.push(format!("[{}]", node.kind));
1302 parts.push(node.name.clone());
1303 if let Some(detail) = &node.detail {
1304 if !detail.is_empty() && detail != "()" {
1305 let oneline: String = detail.split_whitespace().collect::<Vec<_>>().join(" ");
1306 let trimmed = if show_path {
1307 oneline.split(" • ").next().unwrap_or(&oneline)
1308 } else {
1309 &oneline
1310 };
1311 parts.push(format!("({})", trimmed));
1312 }
1313 }
1314 parts.join(" ")
1315}
1316
1317fn render_graph_tree(
1318 children: &[&leta_types::CallGraphEdge],
1319 outgoing: &HashMap<String, Vec<&leta_types::CallGraphEdge>>,
1320 self_recursive: &HashSet<String>,
1321 visited: &mut HashSet<String>,
1322 lines: &mut Vec<String>,
1323 prefix: &str,
1324) {
1325 let node_key = |s: &leta_types::CallGraphSymbol| format!("{}:{}:{}", s.path, s.line, s.name);
1326
1327 for (i, edge) in children.iter().enumerate() {
1328 let callee = &edge.callee;
1329 let in_workspace = edge.in_workspace;
1330 let is_last = i == children.len() - 1;
1331 let connector = if is_last { "└── " } else { "├── " };
1332 let child_prefix = format!("{}{}", prefix, if is_last { " " } else { "│ " });
1333
1334 let key = node_key(callee);
1335 let label = format_graph_node_label(callee, in_workspace);
1336 let is_self_recursive = self_recursive.contains(&key);
1337
1338 if !visited.insert(key.clone()) {
1339 let mut line = format!("{}{}{}", prefix, connector, label);
1340 if is_self_recursive {
1341 line.push_str(" ↻");
1342 }
1343 if outgoing.contains_key(key.as_str()) {
1344 line.push_str(" ↑");
1345 }
1346 lines.push(line);
1347 continue;
1348 }
1349
1350 let grandchildren = outgoing.get(key.as_str());
1351 let mut line = format!("{}{}{}", prefix, connector, label);
1352 if is_self_recursive {
1353 line.push_str(" ↻");
1354 }
1355 lines.push(line);
1356
1357 if let Some(grandchildren) = grandchildren {
1358 render_graph_tree(
1359 grandchildren,
1360 outgoing,
1361 self_recursive,
1362 visited,
1363 lines,
1364 &child_prefix,
1365 );
1366 }
1367 }
1368}
1369
1370fn language_from_path(path: &str) -> String {
1371 let ext = path.rsplit('.').next().unwrap_or("");
1372 match ext {
1373 "py" => "Python",
1374 "rs" => "Rust",
1375 "go" => "Go",
1376 "ts" | "tsx" => "TypeScript",
1377 "js" | "jsx" => "JavaScript",
1378 "java" => "Java",
1379 "rb" => "Ruby",
1380 "cpp" | "cc" | "cxx" | "c" | "h" | "hpp" => "C/C++",
1381 "php" => "PHP",
1382 "lua" => "Lua",
1383 "ml" | "mli" => "OCaml",
1384 "zig" => "Zig",
1385 _ => ext,
1386 }
1387 .to_string()
1388}