1use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12
13use anyhow::Result;
14use serde::{Deserialize, Serialize};
15
16use tldr_core::analysis::refcount::{count_identifiers_in_tree, is_rescued_by_refcount};
17use tldr_core::ast::parser::parse_file;
18use tldr_core::Language;
19
20use super::types::BugbotFinding;
21use crate::commands::dead::collect_module_infos_with_refcounts;
22use crate::commands::remaining::types::ASTChange;
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct BornDeadEvidence {
27 pub is_public: bool,
29 pub ref_count: usize,
31}
32
33pub fn compose_born_dead(
43 inserted: &[&ASTChange],
44 project: &Path,
45 language: &Language,
46) -> Result<Vec<BugbotFinding>> {
47 if inserted.is_empty() {
48 return Ok(Vec::new());
49 }
50
51 let (_module_infos, ref_counts) = collect_module_infos_with_refcounts(project, *language);
53
54 compose_born_dead_with_refcounts(inserted, &ref_counts)
55}
56
57pub fn compose_born_dead_scoped(
67 inserted: &[&ASTChange],
68 changed_files: &[PathBuf],
69 project: &Path,
70 language: &Language,
71) -> Result<Vec<BugbotFinding>> {
72 if inserted.is_empty() {
73 return Ok(Vec::new());
74 }
75
76 let mut ref_counts: HashMap<String, usize> = HashMap::new();
78 for file in changed_files {
79 if !file.exists() {
80 continue;
81 }
82 if let Ok((tree, source, lang)) = parse_file(file) {
83 let file_counts = count_identifiers_in_tree(&tree, source.as_bytes(), lang);
84 for (name, count) in file_counts {
85 *ref_counts.entry(name).or_insert(0) += count;
86 }
87 }
88 }
89
90 let importer_files = find_importer_files(changed_files, project, language);
94 for file in &importer_files {
95 if !file.exists() {
96 continue;
97 }
98 if changed_files.iter().any(|cf| cf == file) {
100 continue;
101 }
102 if let Ok((tree, source, lang)) = parse_file(file) {
103 let file_counts = count_identifiers_in_tree(&tree, source.as_bytes(), lang);
104 for (name, count) in file_counts {
105 *ref_counts.entry(name).or_insert(0) += count;
106 }
107 }
108 }
109
110 compose_born_dead_with_refcounts(inserted, &ref_counts)
111}
112
113fn find_importer_files(
118 changed_files: &[PathBuf],
119 project: &Path,
120 language: &Language,
121) -> Vec<PathBuf> {
122 let mut importer_paths = Vec::new();
123 let mut seen = std::collections::HashSet::new();
124
125 for file in changed_files {
126 let rel = file.strip_prefix(project).unwrap_or(file);
129 let module_candidates = derive_module_names(rel);
130
131 for module_name in &module_candidates {
132 if let Ok(report) =
133 tldr_core::analysis::importers::find_importers(project, module_name, *language)
134 {
135 for importer in &report.importers {
136 let importer_path = project.join(&importer.file);
137 if seen.insert(importer_path.clone()) {
138 importer_paths.push(importer_path);
139 }
140 }
141 }
142 }
143 }
144
145 importer_paths
146}
147
148fn derive_module_names(rel_path: &Path) -> Vec<String> {
153 let mut names = Vec::new();
154 let stem = rel_path
155 .file_stem()
156 .and_then(|s| s.to_str())
157 .unwrap_or("");
158
159 if stem.is_empty() {
160 return names;
161 }
162
163 names.push(stem.to_string());
165
166 let components: Vec<&str> = rel_path
168 .components()
169 .filter_map(|c| c.as_os_str().to_str())
170 .collect();
171
172 if components.len() > 1 {
173 let skip = if components[0] == "src" || components[0] == "lib" {
175 1
176 } else {
177 0
178 };
179 let module_parts: Vec<&str> = components[skip..].to_vec();
180
181 if let Some(last) = module_parts.last() {
183 let mut parts: Vec<String> = module_parts[..module_parts.len() - 1]
184 .iter()
185 .map(|s| s.to_string())
186 .collect();
187 let last_stem = Path::new(last)
188 .file_stem()
189 .and_then(|s| s.to_str())
190 .unwrap_or(last);
191 if last_stem != "mod" && last_stem != "__init__" {
193 parts.push(last_stem.to_string());
194 }
195
196 if !parts.is_empty() {
197 let qualified = parts.join("::");
199 if qualified != stem {
200 names.push(qualified.clone());
201 }
202 names.push(format!("crate::{}", qualified));
204 }
205 }
206 }
207
208 names
209}
210
211pub fn compose_born_dead_with_refcounts(
216 inserted: &[&ASTChange],
217 ref_counts: &HashMap<String, usize>,
218) -> Result<Vec<BugbotFinding>> {
219 let mut findings = Vec::new();
220
221 for change in inserted {
222 let name = match change.name.as_deref() {
223 Some(n) => n,
224 None => continue, };
226
227 if is_test_function(name) {
229 continue;
230 }
231
232 let file_path = change
235 .new_location
236 .as_ref()
237 .map(|loc| loc.file.as_str())
238 .unwrap_or("");
239 if is_test_file(file_path) {
240 continue;
241 }
242
243 if is_entry_point(name) {
245 continue;
246 }
247
248 let new_text = change.new_text.as_deref().unwrap_or("");
250 if is_trait_impl(name, new_text) {
251 continue;
252 }
253
254 if is_rescued_by_refcount(name, ref_counts) {
256 continue;
257 }
258
259 let is_public = new_text.contains("pub fn ") || new_text.contains("pub async fn ");
261 let ref_count = lookup_ref_count(name, ref_counts);
262
263 let line = change
264 .new_location
265 .as_ref()
266 .map(|loc| loc.line as usize)
267 .unwrap_or(0);
268
269 let file = change
270 .new_location
271 .as_ref()
272 .map(|loc| PathBuf::from(&loc.file))
273 .unwrap_or_default();
274
275 let severity = if is_public { "medium" } else { "low" };
276
277 findings.push(BugbotFinding {
278 finding_type: "born-dead".to_string(),
279 severity: severity.to_string(),
280 file,
281 function: name.to_string(),
282 line,
283 message: format!(
284 "New function '{}' has no callers (ref_count: {})",
285 name, ref_count
286 ),
287 evidence: serde_json::to_value(&BornDeadEvidence {
288 is_public,
289 ref_count,
290 })
291 .unwrap_or_default(),
292 confidence: None,
293 finding_id: None,
294 });
295 }
296
297 Ok(findings)
298}
299
300fn lookup_ref_count(name: &str, ref_counts: &HashMap<String, usize>) -> usize {
302 let bare_name = if name.contains('.') {
304 name.rsplit('.').next().unwrap_or(name)
305 } else if name.contains(':') {
306 name.rsplit(':').next().unwrap_or(name)
307 } else {
308 name
309 };
310
311 if let Some(&count) = ref_counts.get(bare_name) {
312 return count;
313 }
314 if bare_name != name {
315 if let Some(&count) = ref_counts.get(name) {
316 return count;
317 }
318 }
319 0
320}
321
322pub fn is_test_function(name: &str) -> bool {
324 name.starts_with("test_")
325 || name == "test"
326 || name.starts_with("Test")
327 || name.starts_with("Benchmark")
328 || name.starts_with("Example")
329}
330
331fn is_test_file(path: &str) -> bool {
340 let path = path.replace('\\', "/");
342
343 if path.contains("/tests/")
345 || path.contains("/test/")
346 || path.contains("/__tests__/")
347 || path.contains("/testing/")
348 {
349 return true;
350 }
351
352 let filename = path.rsplit('/').next().unwrap_or(&path);
354 filename.ends_with("_test.rs")
355 || filename.ends_with("_tests.rs")
356 || filename.ends_with("_test.go")
357 || filename.ends_with("_test.py")
358 || filename.starts_with("test_")
359 || filename.ends_with(".test.ts")
360 || filename.ends_with(".test.tsx")
361 || filename.ends_with(".test.js")
362 || filename.ends_with(".test.jsx")
363 || filename.ends_with(".spec.ts")
364 || filename.ends_with(".spec.tsx")
365 || filename.ends_with(".spec.js")
366 || filename.ends_with(".spec.jsx")
367 || filename.ends_with("Test.java")
368 || filename.ends_with("Tests.java")
369}
370
371fn is_entry_point(name: &str) -> bool {
373 matches!(
374 name,
375 "main" | "__main__" | "cli" | "app" | "run" | "start" | "create_app" | "make_app" | "lib"
376 )
377}
378
379fn is_trait_impl(name: &str, _text: &str) -> bool {
393 matches!(
394 name,
395 "fmt" | "from" | "into" | "try_from" | "try_into"
396 | "clone" | "clone_from" | "default" | "drop"
397 | "deref" | "deref_mut" | "as_ref" | "as_mut"
398 | "borrow" | "borrow_mut"
399 | "eq" | "ne" | "partial_cmp" | "cmp" | "hash"
400 | "next" | "size_hint"
401 | "index" | "index_mut"
402 | "from_str" | "to_string" | "write_str"
403 | "serialize" | "deserialize"
404 | "poll"
405 )
406}
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411 use crate::commands::remaining::types::{ChangeType, Location, NodeKind};
412
413 fn make_insert(name: &str, text: &str, file: &str, line: u32) -> ASTChange {
415 ASTChange {
416 change_type: ChangeType::Insert,
417 node_kind: NodeKind::Function,
418 name: Some(name.to_string()),
419 old_location: None,
420 new_location: Some(Location {
421 file: file.to_string(),
422 line,
423 column: 0,
424 end_line: None,
425 end_column: None,
426 }),
427 old_text: None,
428 new_text: Some(text.to_string()),
429 similarity: None,
430 children: None,
431 base_changes: None,
432 }
433 }
434
435 #[test]
436 fn test_born_dead_new_unused_function() {
437 let insert = make_insert(
438 "helper",
439 "fn helper() { println!(\"unused\"); }",
440 "src/lib.rs",
441 10,
442 );
443 let inserted: Vec<&ASTChange> = vec![&insert];
444
445 let mut ref_counts = HashMap::new();
447 ref_counts.insert("helper".to_string(), 1);
448
449 let findings =
450 compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
451
452 assert_eq!(findings.len(), 1, "Should detect one born-dead function");
453 assert_eq!(findings[0].finding_type, "born-dead");
454 assert_eq!(findings[0].function, "helper");
455 assert_eq!(findings[0].line, 10);
456 }
457
458 #[test]
459 fn test_born_dead_new_used_function() {
460 let insert = make_insert(
461 "helper",
462 "fn helper() { println!(\"used\"); }",
463 "src/lib.rs",
464 10,
465 );
466 let inserted: Vec<&ASTChange> = vec![&insert];
467
468 let mut ref_counts = HashMap::new();
470 ref_counts.insert("helper".to_string(), 3);
471
472 let findings =
473 compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
474
475 assert!(
476 findings.is_empty(),
477 "Function with callers should not be flagged, got: {:?}",
478 findings
479 );
480 }
481
482 #[test]
483 fn test_born_dead_public_medium_severity() {
484 let insert = make_insert(
485 "unused_pub",
486 "pub fn unused_pub() { }",
487 "src/lib.rs",
488 5,
489 );
490 let inserted: Vec<&ASTChange> = vec![&insert];
491
492 let mut ref_counts = HashMap::new();
493 ref_counts.insert("unused_pub".to_string(), 1);
494
495 let findings =
496 compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
497
498 assert_eq!(findings.len(), 1);
499 assert_eq!(
500 findings[0].severity, "medium",
501 "Public unused function should have medium severity"
502 );
503
504 let evidence: BornDeadEvidence =
506 serde_json::from_value(findings[0].evidence.clone()).expect("parse evidence");
507 assert!(evidence.is_public, "Evidence should mark as public");
508 assert_eq!(evidence.ref_count, 1);
509 }
510
511 #[test]
512 fn test_born_dead_private_low_severity() {
513 let insert = make_insert(
514 "unused_priv",
515 "fn unused_priv() { }",
516 "src/lib.rs",
517 5,
518 );
519 let inserted: Vec<&ASTChange> = vec![&insert];
520
521 let mut ref_counts = HashMap::new();
522 ref_counts.insert("unused_priv".to_string(), 1);
523
524 let findings =
525 compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
526
527 assert_eq!(findings.len(), 1);
528 assert_eq!(
529 findings[0].severity, "low",
530 "Private unused function should have low severity"
531 );
532
533 let evidence: BornDeadEvidence =
534 serde_json::from_value(findings[0].evidence.clone()).expect("parse evidence");
535 assert!(!evidence.is_public, "Evidence should mark as private");
536 }
537
538 #[test]
539 fn test_born_dead_test_function_suppressed() {
540 let insert = make_insert(
541 "test_something",
542 "fn test_something() { assert!(true); }",
543 "tests/my_test.rs",
544 1,
545 );
546 let inserted: Vec<&ASTChange> = vec![&insert];
547
548 let mut ref_counts = HashMap::new();
550 ref_counts.insert("test_something".to_string(), 1);
551
552 let findings =
553 compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
554
555 assert!(
556 findings.is_empty(),
557 "Test functions should be suppressed, got: {:?}",
558 findings
559 );
560 }
561
562 #[test]
563 fn test_born_dead_main_suppressed() {
564 let insert = make_insert("main", "fn main() { }", "src/main.rs", 1);
565 let inserted: Vec<&ASTChange> = vec![&insert];
566
567 let mut ref_counts = HashMap::new();
568 ref_counts.insert("main".to_string(), 1);
569
570 let findings =
571 compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
572
573 assert!(
574 findings.is_empty(),
575 "Entry point 'main' should be suppressed, got: {:?}",
576 findings
577 );
578 }
579
580 #[test]
581 fn test_born_dead_empty_inserted_list() {
582 let inserted: Vec<&ASTChange> = vec![];
583 let ref_counts = HashMap::new();
584
585 let findings =
586 compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
587
588 assert!(findings.is_empty(), "Empty input should produce no findings");
589 }
590
591 #[test]
592 fn test_born_dead_no_name_skipped() {
593 let change = ASTChange {
595 change_type: ChangeType::Insert,
596 node_kind: NodeKind::Function,
597 name: None,
598 old_location: None,
599 new_location: Some(Location {
600 file: "src/lib.rs".to_string(),
601 line: 1,
602 column: 0,
603 end_line: None,
604 end_column: None,
605 }),
606 old_text: None,
607 new_text: Some("fn () { }".to_string()),
608 similarity: None,
609 children: None,
610 base_changes: None,
611 };
612 let inserted: Vec<&ASTChange> = vec![&change];
613 let ref_counts = HashMap::new();
614
615 let findings =
616 compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
617
618 assert!(
619 findings.is_empty(),
620 "Change with no name should be skipped"
621 );
622 }
623
624 #[test]
625 fn test_born_dead_zero_refcount_means_dead() {
626 let insert = make_insert(
628 "orphan_func",
629 "fn orphan_func() { }",
630 "src/lib.rs",
631 20,
632 );
633 let inserted: Vec<&ASTChange> = vec![&insert];
634
635 let ref_counts = HashMap::new();
637
638 let findings =
639 compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
640
641 assert_eq!(findings.len(), 1, "Function not in refcounts should be dead");
642 assert_eq!(findings[0].function, "orphan_func");
643
644 let evidence: BornDeadEvidence =
645 serde_json::from_value(findings[0].evidence.clone()).expect("parse evidence");
646 assert_eq!(evidence.ref_count, 0);
647 }
648
649 #[test]
650 fn test_born_dead_multiple_findings() {
651 let insert1 = make_insert("dead_one", "fn dead_one() { }", "src/a.rs", 1);
652 let insert2 = make_insert("alive_one", "fn alive_one() { }", "src/a.rs", 10);
653 let insert3 = make_insert("dead_two", "pub fn dead_two() { }", "src/b.rs", 5);
654
655 let inserted: Vec<&ASTChange> = vec![&insert1, &insert2, &insert3];
656
657 let mut ref_counts = HashMap::new();
658 ref_counts.insert("dead_one".to_string(), 1);
659 ref_counts.insert("alive_one".to_string(), 4);
660 ref_counts.insert("dead_two".to_string(), 1);
661
662 let findings =
663 compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
664
665 assert_eq!(
666 findings.len(),
667 2,
668 "Should find 2 dead functions, got: {:?}",
669 findings
670 .iter()
671 .map(|f| &f.function)
672 .collect::<Vec<_>>()
673 );
674
675 let names: Vec<&str> = findings.iter().map(|f| f.function.as_str()).collect();
676 assert!(names.contains(&"dead_one"));
677 assert!(names.contains(&"dead_two"));
678 assert!(!names.contains(&"alive_one"));
679 }
680
681 #[test]
682 fn test_born_dead_benchmark_function_suppressed() {
683 let insert = make_insert(
684 "BenchmarkSomething",
685 "fn BenchmarkSomething() { }",
686 "benches/bench.rs",
687 1,
688 );
689 let inserted: Vec<&ASTChange> = vec![&insert];
690
691 let mut ref_counts = HashMap::new();
692 ref_counts.insert("BenchmarkSomething".to_string(), 1);
693
694 let findings =
695 compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
696
697 assert!(
698 findings.is_empty(),
699 "Benchmark functions should be suppressed"
700 );
701 }
702
703 #[test]
704 fn test_born_dead_short_name_not_rescued() {
705 let insert = make_insert("ab", "fn ab() { }", "src/lib.rs", 1);
708 let inserted: Vec<&ASTChange> = vec![&insert];
709
710 let mut ref_counts = HashMap::new();
712 ref_counts.insert("ab".to_string(), 4);
713
714 let findings =
715 compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
716
717 assert_eq!(
718 findings.len(),
719 1,
720 "Short-named function with count < 5 should not be rescued"
721 );
722 }
723
724 #[test]
725 fn test_lookup_ref_count_qualified_name() {
726 let mut ref_counts = HashMap::new();
727 ref_counts.insert("method".to_string(), 3);
728
729 assert_eq!(lookup_ref_count("Class.method", &ref_counts), 3);
730 assert_eq!(lookup_ref_count("module:method", &ref_counts), 3);
731 assert_eq!(lookup_ref_count("method", &ref_counts), 3);
732 assert_eq!(lookup_ref_count("unknown", &ref_counts), 0);
733 }
734
735 #[test]
736 fn test_is_test_function_patterns() {
737 assert!(is_test_function("test_something"));
738 assert!(is_test_function("test"));
739 assert!(is_test_function("TestFoo"));
740 assert!(is_test_function("BenchmarkBar"));
741 assert!(is_test_function("ExampleBaz"));
742 assert!(!is_test_function("helper"));
743 assert!(!is_test_function("testing_mode")); assert!(!is_test_function("contestant"));
745 }
746
747 #[test]
748 fn test_is_entry_point_patterns() {
749 assert!(is_entry_point("main"));
750 assert!(is_entry_point("lib"));
751 assert!(is_entry_point("__main__"));
752 assert!(is_entry_point("cli"));
753 assert!(!is_entry_point("helper"));
754 assert!(!is_entry_point("main_loop")); }
756
757 #[test]
758 fn test_is_trait_impl_std_trait_methods() {
759 let std_methods = [
761 "fmt", "from", "into", "try_from", "try_into",
762 "clone", "clone_from", "default", "drop",
763 "deref", "deref_mut", "as_ref", "as_mut",
764 "borrow", "borrow_mut",
765 "eq", "ne", "partial_cmp", "cmp", "hash",
766 "next", "size_hint",
767 "index", "index_mut",
768 "from_str", "to_string", "write_str",
769 "serialize", "deserialize",
770 "poll",
771 ];
772 for method in &std_methods {
773 assert!(
774 is_trait_impl(method, ""),
775 "'{}' should be recognised as a trait impl method",
776 method
777 );
778 }
779 }
780
781 #[test]
782 fn test_is_trait_impl_non_trait_methods() {
783 assert!(!is_trait_impl("helper", ""));
785 assert!(!is_trait_impl("process_data", ""));
786 assert!(!is_trait_impl("run", ""));
787 assert!(!is_trait_impl("build", ""));
788 assert!(!is_trait_impl("new", ""));
789 assert!(!is_trait_impl("main", ""));
790 assert!(!is_trait_impl("calculate", ""));
791 }
792
793 #[test]
794 fn test_born_dead_trait_impl_method_suppressed() {
795 let insert = make_insert(
798 "fmt",
799 "fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { Ok(()) }",
800 "src/lib.rs",
801 10,
802 );
803 let inserted: Vec<&ASTChange> = vec![&insert];
804
805 let mut ref_counts = HashMap::new();
806 ref_counts.insert("fmt".to_string(), 1);
807
808 let findings =
809 compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
810
811 assert!(
812 findings.is_empty(),
813 "Trait impl method 'fmt' should be suppressed, got: {:?}",
814 findings
815 );
816 }
817
818 #[test]
819 fn test_born_dead_test_file_suppressed() {
820 let insert = make_insert(
822 "validate_output_format",
823 "fn validate_output_format() { assert!(true); }",
824 "crates/cli/tests/integration_test.rs",
825 50,
826 );
827 let inserted: Vec<&ASTChange> = vec![&insert];
828 let ref_counts = HashMap::new();
829
830 let findings =
831 compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
832
833 assert!(
834 findings.is_empty(),
835 "Function in test file should be suppressed, got: {:?}",
836 findings
837 );
838 }
839
840 #[test]
841 fn test_born_dead_test_directory_suppressed() {
842 let insert = make_insert(
844 "setup_mock_server",
845 "fn setup_mock_server() {}",
846 "src/tests/helpers.rs",
847 10,
848 );
849 let inserted: Vec<&ASTChange> = vec![&insert];
850 let ref_counts = HashMap::new();
851
852 let findings =
853 compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
854
855 assert!(
856 findings.is_empty(),
857 "Function in tests directory should be suppressed, got: {:?}",
858 findings
859 );
860 }
861
862 #[test]
863 fn test_is_test_file_patterns() {
864 assert!(is_test_file("crates/cli/tests/integration_test.rs"));
866 assert!(is_test_file("src/tests/helpers.rs"));
867 assert!(is_test_file("src/test/java/FooTest.java"));
868 assert!(is_test_file("src/__tests__/foo.test.ts"));
869 assert!(is_test_file("tests/test_foo.py"));
870 assert!(is_test_file("pkg/handler_test.go"));
871 assert!(is_test_file("src/utils.spec.ts"));
872 assert!(is_test_file("FooTest.java"));
873 assert!(is_test_file("FooTests.java"));
874
875 assert!(!is_test_file("src/lib.rs"));
877 assert!(!is_test_file("src/main.py"));
878 assert!(!is_test_file("src/testing_utils.rs")); assert!(!is_test_file("src/contest.rs"));
880 }
881}