1use std::collections::HashMap;
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 loc.name.is_some() && loc.kind.is_some() {
568 let mut parts = vec![
569 format!("{}:{}", loc.path, loc.line),
570 format!("[{}]", loc.kind.as_ref().unwrap()),
571 loc.name.clone().unwrap(),
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
677impl Default for FileTreePrinter {
678 fn default() -> Self {
679 Self::new()
680 }
681}
682
683pub fn format_symbol_line(sym: &SymbolInfo) -> String {
684 let location = format!("{}:{}", sym.path, sym.line);
685 let mut parts = vec![location, format!("[{}]", sym.kind), sym.name.clone()];
686 if let Some(detail) = &sym.detail {
687 if !detail.is_empty() && detail != "()" {
688 parts.push(format!("({})", detail));
689 }
690 }
691 if let Some(container) = &sym.container {
692 parts.push(format!("in {}", container));
693 }
694 let mut output = parts.join(" ");
695
696 if let Some(doc) = &sym.documentation {
697 for doc_line in doc.trim().lines() {
698 output.push_str(&format!("\n {}", doc_line));
699 }
700 }
701 output
702}
703
704fn format_symbols(symbols: &[SymbolInfo]) -> String {
705 let mut lines = Vec::new();
706 for sym in symbols {
707 lines.push(format_symbol_line(sym));
708 if sym.documentation.is_some() {
709 lines.push(String::new());
710 }
711 }
712 lines.join("\n")
713}
714
715pub fn format_size(size: u64) -> String {
716 if size < 1024 {
717 format!("{}B", size)
718 } else if size < 1024 * 1024 {
719 format!("{:.1}KB", size as f64 / 1024.0)
720 } else {
721 format!("{:.1}MB", size as f64 / (1024.0 * 1024.0))
722 }
723}
724
725pub fn format_profiling(profiling: &ProfilingData) -> String {
726 let mut lines = Vec::new();
727
728 let cache = &profiling.cache;
729 let symbol_total = cache.symbol_hits + cache.symbol_misses;
730 let hover_total = cache.hover_hits + cache.hover_misses;
731
732 if symbol_total > 0 || hover_total > 0 {
733 lines.push("CACHE".to_string());
734 if symbol_total > 0 {
735 lines.push(format!(
736 " symbols: {}/{} hits ({:.1}%)",
737 cache.symbol_hits,
738 symbol_total,
739 cache.symbol_hit_rate()
740 ));
741 }
742 if hover_total > 0 {
743 lines.push(format!(
744 " hover: {}/{} hits ({:.1}%)",
745 cache.hover_hits,
746 hover_total,
747 cache.hover_hit_rate()
748 ));
749 }
750 lines.push(String::new());
751 }
752
753 if let Some(tree) = &profiling.span_tree {
754 lines.push(format!(
755 "{:<55} {:>7} {:>9} {:>9} {:>9}",
756 "Function", "Calls", "Avg", "P90", "Total"
757 ));
758 lines.push("-".repeat(90));
759
760 for root in &tree.roots {
761 format_span_node(root, &mut lines, 0, &tree.functions);
762 }
763
764 lines.push("-".repeat(90));
765 lines.push(format!(
766 "{:<55} {:>7} {:>9} {:>9} {:>9}",
767 "TOTAL",
768 "",
769 "",
770 "",
771 format_duration_us(tree.total_us)
772 ));
773 }
774
775 lines.join("\n")
776}
777
778fn get_func_stats<'a>(name: &str, functions: &'a [FunctionStats]) -> Option<&'a FunctionStats> {
779 functions.iter().find(|f| f.name == name)
780}
781
782fn format_span_node(
783 node: &SpanNode,
784 lines: &mut Vec<String>,
785 depth: usize,
786 functions: &[FunctionStats],
787) {
788 let indent = " ".repeat(depth);
789 let parallel_marker = if node.is_parallel { " ||" } else { "" };
790
791 let stats = get_func_stats(&node.name, functions);
792 let (calls, avg, p90) = if let Some(s) = stats {
793 (s.calls, s.avg_us, s.p90_us)
794 } else {
795 (
796 node.calls,
797 if node.calls > 0 {
798 node.total_us / node.calls as u64
799 } else {
800 0
801 },
802 0,
803 )
804 };
805
806 let name_with_indent = format!("{}{}{}", indent, node.name, parallel_marker);
807
808 lines.push(format!(
809 "{:<55} {:>7} {:>9} {:>9} {:>9}",
810 truncate_left(&name_with_indent, 55),
811 calls,
812 format_duration_us(avg),
813 format_duration_us(p90),
814 format_duration_us(node.total_us)
815 ));
816
817 let mut property_time_ms = 0.0f64;
819 if !node.properties.is_empty() {
820 let props_indent = " ".repeat(depth + 1);
821
822 let mut aggregated: std::collections::HashMap<&str, (f64, u32)> =
824 std::collections::HashMap::new();
825 for (k, v) in &node.properties {
826 if k.ends_with("_ms") {
827 if let Ok(ms) = v.parse::<f64>() {
828 property_time_ms += ms;
829 let entry = aggregated.entry(k.as_str()).or_insert((0.0, 0));
830 entry.0 += ms;
831 entry.1 += 1;
832 }
833 } else if let Ok(num) = v.parse::<f64>() {
834 let entry = aggregated.entry(k.as_str()).or_insert((0.0, 0));
835 entry.0 += num;
836 entry.1 += 1;
837 } else {
838 let entry = aggregated.entry(k.as_str()).or_insert((0.0, 0));
840 entry.1 += 1;
841 }
842 }
843
844 let mut props_str: Vec<String> = aggregated
846 .iter()
847 .map(|(k, (sum, count))| {
848 if *count > 1 {
849 if k.ends_with("_ms") {
850 format!(
851 "{}={:.1}ms total ({} calls)",
852 k.trim_end_matches("_ms"),
853 sum,
854 count
855 )
856 } else {
857 format!("{}={:.0} total", k, sum)
858 }
859 } else if k.ends_with("_ms") {
860 format!("{}={:.1}ms", k.trim_end_matches("_ms"), sum)
861 } else {
862 format!("{}={:.0}", k, sum)
863 }
864 })
865 .collect();
866 props_str.sort();
867
868 lines.push(format!("{} [{}]", props_indent, props_str.join(", ")));
869 }
870
871 for child in &node.children {
872 format_span_node(child, lines, depth + 1, functions);
873 }
874
875 let property_time_us = (property_time_ms * 1000.0) as u64;
880 let truly_unaccounted = node.self_us.saturating_sub(property_time_us);
881
882 if truly_unaccounted > 1000 && !node.children.is_empty() {
883 let unaccounted_name = format!("{}[unaccounted]", " ".repeat(depth + 1));
884 lines.push(format!(
885 "{:<55} {:>7} {:>9} {:>9} {:>9}",
886 unaccounted_name,
887 "",
888 "",
889 "",
890 format_duration_us(truly_unaccounted)
891 ));
892 }
893}
894
895fn truncate_left(s: &str, max_len: usize) -> String {
896 if s.len() <= max_len {
897 s.to_string()
898 } else {
899 format!("…{}", &s[s.len() - max_len + 1..])
900 }
901}
902
903enum TreeNode {
904 File(FileInfo),
905 Dir(HashMap<String, TreeNode>),
906 ExcludedDir,
907}
908
909fn build_tree(
910 files: &HashMap<String, FileInfo>,
911 excluded_dirs: &[String],
912) -> HashMap<String, TreeNode> {
913 let mut tree: HashMap<String, TreeNode> = HashMap::new();
914
915 for (rel_path, info) in files {
916 let parts: Vec<&str> = rel_path.split('/').collect();
917 let mut current = &mut tree;
918
919 for (i, part) in parts.iter().enumerate() {
920 if i == parts.len() - 1 {
921 current.insert(part.to_string(), TreeNode::File(info.clone()));
922 } else {
923 current = match current
924 .entry(part.to_string())
925 .or_insert_with(|| TreeNode::Dir(HashMap::new()))
926 {
927 TreeNode::Dir(map) => map,
928 _ => unreachable!(),
929 };
930 }
931 }
932 }
933
934 for excluded_path in excluded_dirs {
935 let parts: Vec<&str> = excluded_path.split('/').collect();
936 let mut current = &mut tree;
937
938 for (i, part) in parts.iter().enumerate() {
939 if i == parts.len() - 1 {
940 current
941 .entry(part.to_string())
942 .or_insert(TreeNode::ExcludedDir);
943 } else {
944 current = match current
945 .entry(part.to_string())
946 .or_insert_with(|| TreeNode::Dir(HashMap::new()))
947 {
948 TreeNode::Dir(map) => map,
949 _ => break,
950 };
951 }
952 }
953 }
954
955 tree
956}
957
958fn render_tree(node: &HashMap<String, TreeNode>, lines: &mut Vec<String>, indent: usize) {
959 let mut entries: Vec<_> = node.keys().collect();
960 entries.sort();
961
962 let prefix = " ".repeat(indent);
963
964 for name in entries {
965 let child = node.get(name).unwrap();
966
967 match child {
968 TreeNode::File(info) => {
969 let info_str = format_file_info(info);
970 lines.push(format!("{}{} ({})", prefix, name, info_str));
971 }
972 TreeNode::Dir(children) => {
973 lines.push(format!("{}{}/", prefix, name));
974 render_tree(children, lines, indent + 1);
975 }
976 TreeNode::ExcludedDir => {
977 lines.push(format!("{}{} (excluded)", prefix, name));
978 }
979 }
980 }
981}
982
983fn format_file_info(info: &FileInfo) -> String {
984 format!("{}, {} lines", format_size(info.bytes), info.lines)
985}
986
987fn is_stdlib_path(path: &str) -> bool {
988 path.contains("/typeshed-fallback/stdlib/")
989 || path.contains("/typeshed/stdlib/")
990 || (path.contains("/libexec/src/") && !path.contains("/mod/"))
991 || (path.ends_with(".d.ts")
992 && path
993 .split('/')
994 .next_back()
995 .map(|f| f.starts_with("lib."))
996 .unwrap_or(false))
997 || path.contains("/rustlib/src/rust/library/")
998}
999
1000fn should_show_detail(detail: &Option<String>) -> bool {
1001 detail
1002 .as_ref()
1003 .map(|d| !d.is_empty() && d != "()")
1004 .unwrap_or(false)
1005}
1006
1007fn format_call_tree(node: &CallNode) -> String {
1008 let mut lines = Vec::new();
1009
1010 let mut parts: Vec<String> = Vec::new();
1011 if let Some(path) = &node.path {
1012 parts.push(format!("{}:{}", path, node.line.unwrap_or(0)));
1013 }
1014 if let Some(kind) = &node.kind {
1015 parts.push(format!("[{}]", kind));
1016 }
1017 parts.push(node.name.clone());
1018 if should_show_detail(&node.detail) {
1019 parts.push(format!("({})", node.detail.as_ref().unwrap()));
1020 }
1021 lines.push(parts.join(" "));
1022
1023 if let Some(calls) = &node.calls {
1024 lines.push(String::new());
1025 lines.push("Outgoing calls:".to_string());
1026 if !calls.is_empty() {
1027 render_calls_tree(calls, &mut lines, " ", true);
1028 }
1029 } else if let Some(called_by) = &node.called_by {
1030 lines.push(String::new());
1031 lines.push("Incoming calls:".to_string());
1032 if !called_by.is_empty() {
1033 render_calls_tree(called_by, &mut lines, " ", false);
1034 }
1035 }
1036
1037 lines.join("\n")
1038}
1039
1040fn render_calls_tree(items: &[CallNode], lines: &mut Vec<String>, prefix: &str, is_outgoing: bool) {
1041 for (i, item) in items.iter().enumerate() {
1042 let is_last = i == items.len() - 1;
1043 let connector = if is_last { "└── " } else { "├── " };
1044 let child_prefix = format!("{}{}", prefix, if is_last { " " } else { "│ " });
1045
1046 let path = item.path.as_deref().unwrap_or("");
1047 let line = item.line.unwrap_or(0);
1048
1049 let mut parts: Vec<String> = Vec::new();
1050 if is_stdlib_path(path) {
1051 if let Some(kind) = &item.kind {
1052 parts.push(format!("[{}]", kind));
1053 }
1054 } else {
1055 parts.push(format!("{}:{}", path, line));
1056 if let Some(kind) = &item.kind {
1057 parts.push(format!("[{}]", kind));
1058 }
1059 }
1060 parts.push(item.name.clone());
1061 if should_show_detail(&item.detail) {
1062 parts.push(format!("({})", item.detail.as_ref().unwrap()));
1063 }
1064 lines.push(format!("{}{}{}", prefix, connector, parts.join(" ")));
1065
1066 let children = if is_outgoing {
1067 &item.calls
1068 } else {
1069 &item.called_by
1070 };
1071 if let Some(children) = children {
1072 render_calls_tree(children, lines, &child_prefix, is_outgoing);
1073 }
1074 }
1075}
1076
1077fn format_call_path(path: &[CallNode]) -> String {
1078 if path.is_empty() {
1079 return "Empty path".to_string();
1080 }
1081
1082 let mut lines = vec!["Call path:".to_string()];
1083 for (i, item) in path.iter().enumerate() {
1084 let file_path = item.path.as_deref().unwrap_or("");
1085 let line = item.line.unwrap_or(0);
1086
1087 let mut parts = vec![format!("{}:{}", file_path, line)];
1088 if let Some(kind) = &item.kind {
1089 parts.push(format!("[{}]", kind));
1090 }
1091 parts.push(item.name.clone());
1092 if should_show_detail(&item.detail) {
1093 parts.push(format!("({})", item.detail.as_ref().unwrap()));
1094 }
1095
1096 let arrow = if i == 0 { "" } else { " → " };
1097 lines.push(format!("{}{}", arrow, parts.join(" ")));
1098 }
1099
1100 lines.join("\n")
1101}