Skip to main content

rulewright/infra/
fix.rs

1use std::collections::BTreeMap;
2
3use crate::{
4    atomic,
5    checksum::{self, Checksum},
6    lock::{Lock, LockError},
7    path::{Path, PathBuf},
8};
9
10pub(crate) type SourceSnapshots = BTreeMap<String, Checksum>;
11
12#[derive(Debug, thiserror::Error)]
13pub(crate) enum ApplyError {
14    #[error(transparent)]
15    Filesystem(#[from] crate::error::Error),
16    #[error(transparent)]
17    Lock(#[from] LockError),
18    #[error("fix plan for {0} has no source snapshot")]
19    MissingSnapshot(PathBuf),
20    #[error("{0} changed after it was analyzed; no fixes were applied")]
21    StaleSource(PathBuf),
22    #[error(
23        "invalid fix range {start_line}..={end_line} for {path} ({line_count} source line(s)); no fixes were applied"
24    )]
25    InvalidRange {
26        path: PathBuf,
27        start_line: usize,
28        end_line: usize,
29        line_count: usize,
30    },
31    #[error("{0} contains mixed or unsupported line endings; no fixes were applied")]
32    MixedLineEndings(PathBuf),
33    #[error("fixes would make {path} invalid {language}: {message}; no fixes were applied")]
34    InvalidSyntax {
35        path: PathBuf,
36        language: &'static str,
37        message: String,
38    },
39}
40
41/// An auto-fix: replace a line range with new text.
42#[derive(Clone, Debug)]
43pub struct Fix {
44    pub start_line: usize,
45    pub end_line: usize,
46    pub replacement: String,
47}
48
49/// One whole-file replacement produced by a rust-analyzer syntax-tree edit.
50#[derive(Clone, Debug)]
51pub(crate) struct TreeFix {
52    pub(crate) rel: String,
53    pub(crate) rule: &'static str,
54    pub(crate) replacement: String,
55}
56
57impl Fix {
58    pub fn replace_line(line: usize, new: impl Into<String>) -> Self {
59        Fix {
60            start_line: line,
61            end_line: line,
62            replacement: new.into(),
63        }
64    }
65
66    pub fn replace_lines(
67        start_line: usize,
68        end_line: usize,
69        replacement: impl Into<String>,
70    ) -> Self {
71        Fix {
72            start_line,
73            end_line,
74            replacement: replacement.into(),
75        }
76    }
77
78    #[must_use]
79    pub fn delete(start_line: usize, end_line: usize) -> Self {
80        Fix {
81            start_line,
82            end_line,
83            replacement: String::new(),
84        }
85    }
86}
87
88/// Apply `(rel_path, fix)` auto-fixes under a `.rulewright.lock`.
89pub(crate) fn apply_fixes(
90    fixes: &[(String, Fix)],
91    snapshots: &SourceSnapshots,
92    root: &Path,
93) -> Result<usize, ApplyError> {
94    let mut by_file: BTreeMap<&str, Vec<&Fix>> = BTreeMap::new();
95
96    for (rel, fix) in fixes {
97        by_file.entry(rel).or_default().push(fix);
98    }
99
100    let lock_path = root.join(".rulewright.lock");
101    let _lock = Lock::try_acquire(&lock_path)?;
102    let sources = verified_sources(by_file.keys().copied(), snapshots, root)?;
103
104    validate_line_fix_plan(&by_file, &sources, root)?;
105
106    let mut total = 0;
107    let mut replacements = Vec::new();
108
109    for (rel, mut file_fixes) in by_file {
110        let path = root.join(rel);
111        let Some(contents) = sources.get(rel) else {
112            return Err(ApplyError::MissingSnapshot(path));
113        };
114        let mut lines: Vec<String> = contents.lines().map(String::from).collect();
115        let mut file_total = 0;
116
117        file_fixes.sort_by_key(|a| std::cmp::Reverse(a.start_line));
118
119        // Bottom-to-top single pass: fixes at higher lines can't shift lower ones.
120        let mut lowest_touched = usize::MAX;
121
122        for fix in file_fixes {
123            let start = fix.start_line - 1;
124            let end = fix.end_line;
125
126            if end > lowest_touched {
127                continue;
128            }
129
130            if fix.replacement.is_empty() {
131                lines.drain(start..end);
132            } else {
133                let new_lines: Vec<String> = fix.replacement.lines().map(String::from).collect();
134
135                lines.splice(start..end, new_lines);
136            }
137
138            lowest_touched = start;
139            file_total += 1;
140        }
141
142        if file_total == 0 {
143            continue;
144        }
145
146        let newline = line_ending(contents, &path)?;
147        let mut output = lines.join(newline);
148
149        if contents.ends_with('\n') {
150            output.push_str(newline);
151        }
152
153        validate_syntax(&path, contents, &output)?;
154        replacements.push((path, output));
155        total += file_total;
156    }
157
158    for (path, replacement) in replacements {
159        atomic::replace(&path, replacement.as_bytes())?;
160    }
161
162    Ok(total)
163}
164
165/// Apply independent whole-file tree edits for one rule under the rulewright lock.
166pub(crate) fn apply_tree_fixes(
167    fixes: &[TreeFix],
168    snapshots: &SourceSnapshots,
169    root: &Path,
170) -> Result<usize, ApplyError> {
171    let lock_path = root.join(".rulewright.lock");
172    let _lock = Lock::try_acquire(&lock_path)?;
173    let sources = verified_sources(fixes.iter().map(|fix| fix.rel.as_str()), snapshots, root)?;
174
175    for fix in fixes {
176        let path = root.join(&fix.rel);
177        let Some(contents) = sources.get(&fix.rel) else {
178            return Err(ApplyError::MissingSnapshot(path));
179        };
180
181        line_ending(contents, &path)?;
182    }
183
184    let mut replacements = Vec::with_capacity(fixes.len());
185
186    for fix in fixes {
187        let path = root.join(&fix.rel);
188        let Some(contents) = sources.get(&fix.rel) else {
189            return Err(ApplyError::MissingSnapshot(path));
190        };
191        let replacement = with_line_ending(&fix.replacement, line_ending(contents, &path)?);
192
193        validate_syntax(&path, contents, &replacement)?;
194        replacements.push((path, replacement));
195    }
196
197    for (path, replacement) in replacements {
198        atomic::replace(&path, replacement.as_bytes())?;
199    }
200
201    Ok(fixes.len())
202}
203
204fn validate_syntax(path: &Path, original: &str, replacement: &str) -> Result<(), ApplyError> {
205    match path.extension().and_then(|extension| extension.to_str()) {
206        Some("rs") => {
207            let original =
208                ra_ap_syntax::SourceFile::parse(original, ra_ap_syntax::Edition::Edition2024);
209
210            if !original.errors().is_empty() {
211                return Ok(());
212            }
213
214            let parsed =
215                ra_ap_syntax::SourceFile::parse(replacement, ra_ap_syntax::Edition::Edition2024);
216
217            if let Some(error) = parsed.errors().first() {
218                return Err(ApplyError::InvalidSyntax {
219                    path: path.to_path_buf(),
220                    language: "Rust",
221                    message: error.to_string(),
222                });
223            }
224        }
225
226        Some("toml") => {
227            if toml::from_str::<toml::Value>(original).is_ok()
228                && let Err(error) = toml::from_str::<toml::Value>(replacement)
229            {
230                return Err(ApplyError::InvalidSyntax {
231                    path: path.to_path_buf(),
232                    language: "TOML",
233                    message: error.to_string(),
234                });
235            }
236        }
237
238        _ => {}
239    }
240
241    Ok(())
242}
243
244fn verified_sources<'a>(
245    rels: impl Iterator<Item = &'a str>,
246    snapshots: &SourceSnapshots,
247    root: &Path,
248) -> Result<BTreeMap<String, String>, ApplyError> {
249    let mut sources = BTreeMap::new();
250
251    for rel in rels {
252        let path = root.join(rel);
253        let Some(expected) = snapshots.get(rel) else {
254            return Err(ApplyError::MissingSnapshot(path));
255        };
256        let contents = crate::file::read_text(&path)?;
257
258        if checksum::bytes(&contents) != *expected {
259            return Err(ApplyError::StaleSource(path));
260        }
261
262        sources.insert(rel.to_owned(), contents);
263    }
264
265    Ok(sources)
266}
267
268fn validate_line_fix_plan(
269    by_file: &BTreeMap<&str, Vec<&Fix>>,
270    sources: &BTreeMap<String, String>,
271    root: &Path,
272) -> Result<(), ApplyError> {
273    for (rel, fixes) in by_file {
274        let path = root.join(rel);
275        let Some(contents) = sources.get(*rel) else {
276            return Err(ApplyError::MissingSnapshot(path));
277        };
278        let line_count = contents.lines().count();
279
280        line_ending(contents, &path)?;
281
282        for fix in fixes {
283            if fix.start_line == 0 || fix.end_line < fix.start_line || fix.end_line > line_count {
284                return Err(ApplyError::InvalidRange {
285                    path,
286                    start_line: fix.start_line,
287                    end_line: fix.end_line,
288                    line_count,
289                });
290            }
291        }
292    }
293
294    Ok(())
295}
296
297fn line_ending(contents: &str, path: &Path) -> Result<&'static str, ApplyError> {
298    let bytes = contents.as_bytes();
299    let mut saw_lf = false;
300    let mut saw_crlf = false;
301    let mut index = 0;
302
303    while index < bytes.len() {
304        let Some(&byte) = bytes.get(index) else {
305            break;
306        };
307
308        match byte {
309            b'\r' if bytes.get(index + 1) == Some(&b'\n') => {
310                saw_crlf = true;
311                index += 2;
312            }
313
314            b'\r' => return Err(ApplyError::MixedLineEndings(path.to_path_buf())),
315
316            b'\n' => {
317                saw_lf = true;
318                index += 1;
319            }
320
321            _ => index += 1,
322        }
323    }
324
325    if saw_lf && saw_crlf {
326        Err(ApplyError::MixedLineEndings(path.to_path_buf()))
327    } else if saw_crlf {
328        Ok("\r\n")
329    } else {
330        Ok("\n")
331    }
332}
333
334fn with_line_ending(contents: &str, newline: &str) -> String {
335    let normalized = contents.replace("\r\n", "\n");
336
337    if newline == "\n" {
338        normalized
339    } else {
340        normalized.replace('\n', newline)
341    }
342}
343
344#[cfg(test)]
345#[path = "fix_validation_tests.rs"]
346mod validation_tests;
347
348#[cfg(test)]
349mod tests {
350    use googletest::prelude::*;
351
352    use super::*;
353    use crate::{file, temporary::Directory};
354
355    #[gtest]
356    fn line_fixes_preserve_crlf() -> Result<()> {
357        let directory = Directory::new().or_fail()?;
358        let path = directory.path().join("source.rs");
359        let original = "first\r\nsecond\r\n";
360
361        file::write_text(&path, original).or_fail()?;
362        let snapshots =
363            SourceSnapshots::from([("source.rs".to_string(), checksum::bytes(original))]);
364        let applied = apply_fixes(
365            &[("source.rs".to_string(), Fix::replace_line(2, "changed"))],
366            &snapshots,
367            directory.path(),
368        )
369        .or_fail()?;
370
371        verify_eq!(applied, 1)?;
372
373        verify_eq!(file::read_text(&path).or_fail()?, "first\r\nchanged\r\n")
374    }
375
376    #[gtest]
377    fn stale_source_aborts_before_any_file_is_changed() -> Result<()> {
378        let directory = Directory::new().or_fail()?;
379        let first = directory.path().join("first.rs");
380        let second = directory.path().join("second.rs");
381
382        file::write_text(&first, "current\n").or_fail()?;
383        file::write_text(&second, "current\n").or_fail()?;
384        let snapshots = SourceSnapshots::from([
385            ("first.rs".to_string(), checksum::bytes("stale\n")),
386            ("second.rs".to_string(), checksum::bytes("current\n")),
387        ]);
388        let error = apply_fixes(
389            &[
390                ("first.rs".to_string(), Fix::replace_line(1, "changed")),
391                ("second.rs".to_string(), Fix::replace_line(1, "changed")),
392            ],
393            &snapshots,
394            directory.path(),
395        )
396        .unwrap_err();
397
398        verify_that!(
399            error.to_string(),
400            contains_substring("changed after it was analyzed")
401        )?;
402        verify_eq!(file::read_text(&first).or_fail()?, "current\n")?;
403
404        verify_eq!(file::read_text(&second).or_fail()?, "current\n")
405    }
406
407    #[gtest]
408    fn lock_failure_is_reported_as_an_error() -> Result<()> {
409        let directory = Directory::new().or_fail()?;
410        let path = directory.path().join("source.rs");
411
412        file::write_text(&path, "old\n").or_fail()?;
413        let snapshots =
414            SourceSnapshots::from([("source.rs".to_string(), checksum::bytes("old\n"))]);
415        let _lock = Lock::try_acquire(&directory.path().join(".rulewright.lock")).or_fail()?;
416        let error = apply_fixes(
417            &[("source.rs".to_string(), Fix::replace_line(1, "new"))],
418            &snapshots,
419            directory.path(),
420        )
421        .unwrap_err();
422
423        verify_that!(error.to_string(), contains_substring("another process"))?;
424
425        verify_eq!(file::read_text(&path).or_fail()?, "old\n")
426    }
427
428    #[gtest]
429    fn malformed_range_aborts_before_any_file_is_changed() -> Result<()> {
430        let directory = Directory::new().or_fail()?;
431        let first = directory.path().join("first.rs");
432        let second = directory.path().join("second.rs");
433
434        file::write_text(&first, "first\n").or_fail()?;
435        file::write_text(&second, "second\n").or_fail()?;
436        let snapshots = SourceSnapshots::from([
437            ("first.rs".to_string(), checksum::bytes("first\n")),
438            ("second.rs".to_string(), checksum::bytes("second\n")),
439        ]);
440        let error = apply_fixes(
441            &[
442                ("first.rs".to_string(), Fix::replace_line(1, "changed")),
443                ("second.rs".to_string(), Fix::replace_lines(0, 1, "invalid")),
444            ],
445            &snapshots,
446            directory.path(),
447        )
448        .unwrap_err();
449
450        verify_that!(error.to_string(), contains_substring("invalid fix range"))?;
451        verify_eq!(file::read_text(&first).or_fail()?, "first\n")?;
452
453        verify_eq!(file::read_text(&second).or_fail()?, "second\n")
454    }
455
456    #[gtest]
457    fn line_fix_rejects_mixed_endings_without_mutation() -> Result<()> {
458        let directory = Directory::new().or_fail()?;
459        let path = directory.path().join("source.rs");
460        let original = "first\r\nsecond\n";
461
462        file::write_text(&path, original).or_fail()?;
463        let snapshots =
464            SourceSnapshots::from([("source.rs".to_string(), checksum::bytes(original))]);
465        let error = apply_fixes(
466            &[("source.rs".to_string(), Fix::replace_line(1, "changed"))],
467            &snapshots,
468            directory.path(),
469        )
470        .unwrap_err();
471
472        verify_that!(error.to_string(), contains_substring("mixed"))?;
473
474        verify_eq!(file::read_text(&path).or_fail()?, original)
475    }
476
477    #[gtest]
478    fn tree_fix_rejects_mixed_endings_without_mutation() -> Result<()> {
479        let directory = Directory::new().or_fail()?;
480        let path = directory.path().join("source.rs");
481        let original = "first\r\nsecond\n";
482
483        file::write_text(&path, original).or_fail()?;
484        let snapshots =
485            SourceSnapshots::from([("source.rs".to_string(), checksum::bytes(original))]);
486        let error = apply_tree_fixes(
487            &[TreeFix {
488                rel: "source.rs".to_string(),
489                rule: "fixture",
490                replacement: "changed\n".to_string(),
491            }],
492            &snapshots,
493            directory.path(),
494        )
495        .unwrap_err();
496
497        verify_that!(error.to_string(), contains_substring("mixed"))?;
498
499        verify_eq!(file::read_text(&path).or_fail()?, original)
500    }
501}