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