Skip to main content

mollify_core/
fix.rs

1//! Safe auto-fix: removes only `confidence: certain`, `auto_fixable` unused
2//! symbols and unused imports (never files, never lower-confidence findings).
3//! Dry-run by default at the CLI; this module computes a plan and can apply it.
4
5use 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
18/// Compute the set of safe edits (deleting unused-symbol line ranges).
19pub 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
45/// Apply edits in place. Deletes the inclusive line ranges, bottom-up per file
46/// so earlier line numbers stay valid. Returns the number of edits applied.
47pub 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        // Bottom-up; skip overlaps defensively.
55        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; // out of range or overlapping a prior removal
64            }
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        // _priv is private+unused => certain+autofixable; pub is likely (not in plan).
95        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}