1use crate::dead_code_report;
6use camino::{Utf8Path, Utf8PathBuf};
7use mollify_types::Confidence;
8use rustc_hash::FxHashMap;
9
10#[derive(Debug, Clone)]
11pub struct FixEdit {
12 pub path: Utf8PathBuf,
13 pub start_line: u32,
14 pub end_line: u32,
15 pub description: String,
16}
17
18pub fn plan(root: &Utf8Path) -> Vec<FixEdit> {
20 let report = dead_code_report(root);
21 let mut edits: Vec<FixEdit> = report
22 .findings
23 .into_iter()
24 .filter(|f| {
25 (f.rule == "unused-export" || f.rule == "unused-import")
26 && f.confidence == Confidence::Certain
27 && f.actions.first().is_some_and(|a| a.auto_fixable)
28 })
29 .map(|f| FixEdit {
30 start_line: f.location.line,
31 end_line: f.location.end_line.unwrap_or(f.location.line),
32 path: f.location.path,
33 description: f
34 .actions
35 .into_iter()
36 .next()
37 .map(|a| a.description)
38 .unwrap_or_default(),
39 })
40 .collect();
41 edits.sort_by(|a, b| a.path.cmp(&b.path).then(a.start_line.cmp(&b.start_line)));
42 edits
43}
44
45pub fn apply(edits: &[FixEdit]) -> std::io::Result<usize> {
48 let mut by_file: FxHashMap<&Utf8Path, Vec<&FixEdit>> = FxHashMap::default();
49 for e in edits {
50 by_file.entry(e.path.as_path()).or_default().push(e);
51 }
52 let mut applied = 0;
53 for (path, mut file_edits) in by_file {
54 file_edits.sort_by_key(|e| std::cmp::Reverse(e.start_line));
56 let content = std::fs::read_to_string(path)?;
57 let mut lines: Vec<&str> = content.lines().collect();
58 let mut last_removed_start = u32::MAX;
59 for e in file_edits {
60 let start = e.start_line.saturating_sub(1) as usize;
61 let end = (e.end_line as usize).min(lines.len());
62 if start >= lines.len() || e.end_line >= last_removed_start {
63 continue; }
65 lines.drain(start..end);
66 last_removed_start = e.start_line;
67 applied += 1;
68 }
69 let mut out = lines.join("\n");
70 if content.ends_with('\n') {
71 out.push('\n');
72 }
73 std::fs::write(path, out)?;
74 }
75 Ok(applied)
76}
77
78#[cfg(test)]
79mod tests {
80 use super::*;
81
82 fn temp(tag: &str) -> Utf8PathBuf {
83 let base =
84 std::env::temp_dir().join(format!("mollify-core-fix-{}-{tag}", std::process::id()));
85 let _ = std::fs::remove_dir_all(&base);
86 std::fs::create_dir_all(&base).unwrap();
87 Utf8PathBuf::from_path_buf(base).unwrap()
88 }
89
90 #[test]
91 fn plan_targets_only_certain_unused() {
92 let d = temp("plan");
93 std::fs::write(d.join("__main__.py"), "print('hi')\n").unwrap();
94 std::fs::write(
96 d.join("lib.py"),
97 "def _priv():\n return 1\n\ndef pub():\n return 2\n",
98 )
99 .unwrap();
100 let edits = plan(&d);
101 assert_eq!(edits.len(), 1, "got {edits:?}");
102 assert!(edits[0].path.as_str().ends_with("lib.py"));
103 assert_eq!(edits[0].start_line, 1);
104 std::fs::remove_dir_all(&d).ok();
105 }
106
107 #[test]
108 fn apply_removes_the_symbol() {
109 let d = temp("apply");
110 std::fs::write(d.join("__main__.py"), "print('hi')\n").unwrap();
111 let lib = d.join("lib.py");
112 std::fs::write(
113 &lib,
114 "def _priv():\n return 1\n\ndef keep():\n return 2\n",
115 )
116 .unwrap();
117 let edits = plan(&d);
118 let n = apply(&edits).unwrap();
119 assert_eq!(n, 1);
120 let after = std::fs::read_to_string(&lib).unwrap();
121 assert!(!after.contains("_priv"), "after: {after:?}");
122 assert!(after.contains("keep"));
123 std::fs::remove_dir_all(&d).ok();
124 }
125}