Skip to main content

ailint_core/
fix.rs

1//! Deterministic auto-fix pipeline for [`Violation::fixes`].
2//!
3//! Groups edits by file, sorts descending by byte-offset, refuses to touch
4//! any file with overlapping edits, and rewrites the survivors on disk.
5
6use std::collections::BTreeMap;
7use std::fs;
8use std::path::{Path, PathBuf};
9
10use anyhow::{Context, Result};
11
12use crate::rules::{TextEdit, Violation};
13
14/// Outcome of applying `--fix` to one file.
15#[derive(Debug, Clone)]
16pub struct FileFixResult {
17    /// File that was considered.
18    pub path: PathBuf,
19    /// Number of edits applied. Zero when the file was skipped.
20    pub applied: usize,
21    /// Present when the file was skipped because two edits overlapped.
22    pub conflict: Option<FixConflict>,
23}
24
25/// A pair of edits from the same file that overlap in byte range.
26#[derive(Debug, Clone)]
27pub struct FixConflict {
28    /// Byte range of the first (higher-offset) edit.
29    pub first: std::ops::Range<usize>,
30    /// Byte range of the second (lower-offset) edit that overlaps it.
31    pub second: std::ops::Range<usize>,
32}
33
34/// Apply every `TextEdit` on `violations` to disk, one file at a time.
35///
36/// Edits within a file are applied in reverse byte-order so earlier offsets
37/// remain valid. Any file with overlapping edits is skipped whole and its
38/// result carries `Some(FixConflict)` so the caller can warn or error.
39pub fn apply_all(violations: &[Violation]) -> Result<Vec<FileFixResult>> {
40    let mut by_file: BTreeMap<PathBuf, Vec<TextEdit>> = BTreeMap::new();
41    for v in violations {
42        if v.fixes.is_empty() {
43            continue;
44        }
45        by_file
46            .entry(v.file.clone())
47            .or_default()
48            .extend(v.fixes.iter().cloned());
49    }
50
51    let mut results = Vec::with_capacity(by_file.len());
52    for (path, edits) in by_file {
53        results.push(apply_to_file(&path, edits)?);
54    }
55    Ok(results)
56}
57
58fn apply_to_file(path: &Path, mut edits: Vec<TextEdit>) -> Result<FileFixResult> {
59    // Descending by start so earlier offsets are not invalidated as we splice.
60    edits.sort_by_key(|e| std::cmp::Reverse(e.range.start));
61
62    // Overlap check: adjacent items in the sorted list cannot share bytes.
63    for pair in edits.windows(2) {
64        let higher = &pair[0];
65        let lower = &pair[1];
66        if lower.range.end > higher.range.start {
67            return Ok(FileFixResult {
68                path: path.to_path_buf(),
69                applied: 0,
70                conflict: Some(FixConflict {
71                    first: higher.range.clone(),
72                    second: lower.range.clone(),
73                }),
74            });
75        }
76    }
77
78    let mut raw = fs::read_to_string(path)
79        .with_context(|| format!("failed to read {} for fixing", path.display()))?;
80    let applied = edits.len();
81    for edit in edits {
82        if edit.range.end > raw.len() {
83            anyhow::bail!(
84                "fix range {:?} out of bounds ({} bytes) in {}",
85                edit.range,
86                raw.len(),
87                path.display()
88            );
89        }
90        raw.replace_range(edit.range, &edit.replacement);
91    }
92    fs::write(path, raw)
93        .with_context(|| format!("failed to write fixed content to {}", path.display()))?;
94
95    Ok(FileFixResult {
96        path: path.to_path_buf(),
97        applied,
98        conflict: None,
99    })
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use crate::rules::{RuleId, Severity};
106
107    fn tmp_file(name: &str, body: &str) -> PathBuf {
108        let mut p = std::env::temp_dir();
109        p.push(format!("ailint-fix-{}-{name}", std::process::id()));
110        std::fs::write(&p, body).unwrap();
111        p
112    }
113
114    fn violation_with(path: PathBuf, edits: Vec<TextEdit>) -> Violation {
115        let mut v = Violation::new(RuleId::new(999, "test"), Severity::Warning, path, "test");
116        v.fixes = edits;
117        v
118    }
119
120    #[test]
121    fn applies_single_edit() {
122        let path = tmp_file("single.md", "hello world");
123        let v = violation_with(
124            path.clone(),
125            vec![TextEdit {
126                range: 6..11,
127                replacement: "there".into(),
128            }],
129        );
130        let results = apply_all(&[v]).unwrap();
131        assert_eq!(results.len(), 1);
132        assert_eq!(results[0].applied, 1);
133        assert!(results[0].conflict.is_none());
134        assert_eq!(std::fs::read_to_string(&path).unwrap(), "hello there");
135        let _ = std::fs::remove_file(&path);
136    }
137
138    #[test]
139    fn applies_two_non_overlapping_in_reverse_order() {
140        let path = tmp_file("two.md", "AAA BBB CCC");
141        // If applied in forward order the second range would be shifted.
142        // Reverse order proves the sort keeps offsets valid.
143        let v = violation_with(
144            path.clone(),
145            vec![
146                TextEdit {
147                    range: 0..3,
148                    replacement: "aaaa".into(),
149                },
150                TextEdit {
151                    range: 8..11,
152                    replacement: "cc".into(),
153                },
154            ],
155        );
156        let results = apply_all(&[v]).unwrap();
157        assert_eq!(results[0].applied, 2);
158        assert_eq!(std::fs::read_to_string(&path).unwrap(), "aaaa BBB cc");
159        let _ = std::fs::remove_file(&path);
160    }
161
162    #[test]
163    fn refuses_overlapping_edits() {
164        let path = tmp_file("overlap.md", "hello world");
165        let v = violation_with(
166            path.clone(),
167            vec![
168                TextEdit {
169                    range: 0..5,
170                    replacement: "HI".into(),
171                },
172                TextEdit {
173                    range: 3..8,
174                    replacement: "XX".into(),
175                },
176            ],
177        );
178        let results = apply_all(&[v]).unwrap();
179        assert_eq!(results[0].applied, 0);
180        assert!(results[0].conflict.is_some());
181        assert_eq!(
182            std::fs::read_to_string(&path).unwrap(),
183            "hello world",
184            "content must be untouched when edits conflict"
185        );
186        let _ = std::fs::remove_file(&path);
187    }
188}