Skip to main content

lanekeep_core/
fix.rs

1//! Fixes: a replacement a rule offers for what it reported.
2//!
3//! A fix is a byte range and the text to put there. Template-based replacement of a capture
4//! is the whole model — not a general edit script, not a patch format. A rule that matched a
5//! node knows that node's extent, and replacing it is the operation that covers almost every
6//! automatic fix worth having.
7//!
8//! # Safe and suggested
9//!
10//! A fix is either **safe** — applying it preserves what the code does — or a **suggestion**,
11//! which is a good idea a human should look at. `--fix` applies only the safe ones.
12//!
13//! The distinction is the rule author's to make and it is not checkable, which is exactly
14//! why the default is the cautious one: a rule that forgets to say gets a suggestion, and a
15//! suggestion that should have been safe costs a manual edit. The other default would let a
16//! forgotten flag silently rewrite someone's code.
17//!
18//! # Overlaps
19//!
20//! Two fixes that touch the same bytes cannot both be applied — the second would be editing
21//! text the first replaced, and the result is whatever the ordering happened to be. Applying
22//! one and skipping the other is the only sound choice; see [`apply`].
23
24use std::ops::Range;
25
26use serde::{Deserialize, Serialize};
27
28/// A replacement for a range of a file's bytes.
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30pub struct Fix {
31    /// Byte offset the replacement starts at.
32    pub start: usize,
33    /// Byte offset it ends at, exclusive.
34    pub end: usize,
35    /// What to put there.
36    pub replacement: String,
37    /// Whether applying it preserves behavior.
38    ///
39    /// `false` — a suggestion — is the default a rule gets by not saying, because the
40    /// cautious mistake costs a manual edit and the other one rewrites code silently.
41    pub safe: bool,
42}
43
44impl Fix {
45    /// The range this replaces.
46    #[must_use]
47    pub const fn range(&self) -> Range<usize> {
48        self.start..self.end
49    }
50
51    /// Whether two fixes touch the same bytes.
52    ///
53    /// Adjacent is not overlapping: one ending where the next begins is two edits to
54    /// different text, and both can be applied.
55    #[must_use]
56    pub const fn overlaps(&self, other: &Self) -> bool {
57        self.start < other.end && other.start < self.end
58    }
59
60    /// Whether this fix names a range that exists in a file of `len` bytes.
61    #[must_use]
62    pub const fn fits(&self, len: usize) -> bool {
63        self.start <= self.end && self.end <= len
64    }
65}
66
67/// What applying a set of fixes to one file produced.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct FixOutcome {
70    /// The file's new contents.
71    pub source: String,
72    /// How many fixes were applied.
73    pub applied: usize,
74    /// How many were skipped because another fix had already claimed those bytes.
75    ///
76    /// Reported rather than swallowed: a run that fixed three of five things and said it
77    /// fixed everything would leave someone believing the file was clean.
78    pub skipped: usize,
79}
80
81/// Apply fixes to a file's source.
82///
83/// Only safe fixes, only ranges that fit, and only one of any overlapping group.
84///
85/// Fixes are applied **last first**, so an earlier fix's offsets stay valid while later ones
86/// are still being written. Applying in forward order would require adjusting every
87/// subsequent offset by the length delta of every edit before it, which is the same
88/// computation with more chances to get it wrong.
89///
90/// Where two fixes overlap, the one starting earlier wins. Arbitrary, but it has to be
91/// *decided* rather than left to whatever order the rules happened to run in — two runs over
92/// identical input must produce identical output.
93#[must_use]
94pub fn apply(source: &str, fixes: &[Fix]) -> FixOutcome {
95    let mut candidates: Vec<&Fix> = fixes
96        .iter()
97        .filter(|fix| fix.safe && fix.fits(source.len()))
98        .collect();
99
100    // Sorted by start, then by end, so the choice among overlapping fixes does not depend on
101    // the order rules ran in. Length breaks a tie because a shorter replacement at the same
102    // start is the more conservative edit.
103    candidates.sort_by(|a, b| a.start.cmp(&b.start).then_with(|| a.end.cmp(&b.end)));
104
105    let mut chosen: Vec<&Fix> = Vec::with_capacity(candidates.len());
106    let mut skipped = 0usize;
107    for fix in candidates {
108        if chosen.last().is_some_and(|last| last.overlaps(fix)) {
109            skipped += 1;
110            continue;
111        }
112        chosen.push(fix);
113    }
114
115    let mut source = source.to_owned();
116    let applied = chosen.len();
117    for fix in chosen.iter().rev() {
118        // Guarded because a range can name a byte inside a multi-byte character, which
119        // `String::replace_range` would panic on. Silently declining is right: the fix was
120        // wrong, and a checker must not abort over a rule's bad arithmetic.
121        if source.is_char_boundary(fix.start) && source.is_char_boundary(fix.end) {
122            source.replace_range(fix.range(), &fix.replacement);
123        }
124    }
125
126    FixOutcome {
127        source,
128        applied,
129        skipped,
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    fn fix(start: usize, end: usize, replacement: &str) -> Fix {
138        Fix {
139            start,
140            end,
141            replacement: replacement.to_owned(),
142            safe: true,
143        }
144    }
145
146    fn suggestion(start: usize, end: usize, replacement: &str) -> Fix {
147        Fix {
148            safe: false,
149            ..fix(start, end, replacement)
150        }
151    }
152
153    #[test]
154    fn a_single_fix_replaces_its_range() {
155        let result = apply("const a = 1;", &[fix(0, 5, "let")]);
156        assert_eq!(result.source, "let a = 1;");
157        assert_eq!(result.applied, 1);
158        assert_eq!(result.skipped, 0);
159    }
160
161    #[test]
162    fn several_fixes_all_land_in_the_right_places() {
163        // The reason edits are applied last first: every earlier offset stays valid.
164        let result = apply(
165            "aaa bbb ccc",
166            &[fix(0, 3, "xxxx"), fix(4, 7, "y"), fix(8, 11, "zzzzz")],
167        );
168        assert_eq!(result.source, "xxxx y zzzzz");
169        assert_eq!(result.applied, 3);
170    }
171
172    #[test]
173    fn a_suggestion_is_not_applied() {
174        // A rule that did not say its fix preserves behavior does not get to rewrite code.
175        let result = apply("const a = 1;", &[suggestion(0, 5, "let")]);
176        assert_eq!(result.source, "const a = 1;");
177        assert_eq!(result.applied, 0);
178    }
179
180    #[test]
181    fn overlapping_fixes_are_skipped_and_counted() {
182        // A run that fixed some of them and said it fixed everything would leave someone
183        // believing the file was clean.
184        let result = apply("aaaa", &[fix(0, 3, "x"), fix(1, 4, "y")]);
185        assert_eq!(result.applied, 1);
186        assert_eq!(result.skipped, 1);
187        assert_eq!(result.source, "xa");
188    }
189
190    #[test]
191    fn adjacent_fixes_are_both_applied() {
192        // One ending where the next begins is two edits to different text.
193        let result = apply("abcd", &[fix(0, 2, "X"), fix(2, 4, "Y")]);
194        assert_eq!(result.applied, 2);
195        assert_eq!(result.source, "XY");
196    }
197
198    #[test]
199    fn which_of_two_overlapping_fixes_wins_does_not_depend_on_order() {
200        // Two runs over identical input must produce identical output, and rules do not run
201        // in a guaranteed order.
202        let one = apply("aaaa", &[fix(0, 3, "x"), fix(1, 4, "y")]);
203        let other = apply("aaaa", &[fix(1, 4, "y"), fix(0, 3, "x")]);
204        assert_eq!(one, other);
205    }
206
207    #[test]
208    fn a_range_past_the_end_is_declined() {
209        // A rule's arithmetic must not be able to abort a checker.
210        let result = apply("short", &[fix(0, 500, "x")]);
211        assert_eq!(result.source, "short");
212        assert_eq!(result.applied, 0);
213    }
214
215    #[test]
216    fn an_inverted_range_is_declined() {
217        let result = apply("const a = 1;", &[fix(5, 2, "x")]);
218        assert_eq!(result.source, "const a = 1;");
219        assert_eq!(result.applied, 0);
220    }
221
222    #[test]
223    fn a_range_splitting_a_character_is_declined_rather_than_panicking() {
224        // `→` is three bytes. Replacing one of them would panic in `replace_range`.
225        let source = "a → b";
226        let result = apply(source, &[fix(2, 3, "x")]);
227        assert_eq!(result.source, source);
228    }
229
230    #[test]
231    fn a_multi_byte_range_on_its_boundaries_is_applied() {
232        let source = "a → b";
233        let arrow = source.find('→').expect("present");
234        let result = apply(source, &[fix(arrow, arrow + '→'.len_utf8(), "->")]);
235        assert_eq!(result.source, "a -> b");
236    }
237
238    #[test]
239    fn an_empty_replacement_deletes() {
240        let result = apply("const a = 1;\n", &[fix(0, 13, "")]);
241        assert_eq!(result.source, "");
242        assert_eq!(result.applied, 1);
243    }
244
245    #[test]
246    fn an_empty_range_inserts() {
247        let result = apply("ab", &[fix(1, 1, "X")]);
248        assert_eq!(result.source, "aXb");
249    }
250
251    #[test]
252    fn no_fixes_leaves_the_source_alone() {
253        let result = apply("const a = 1;", &[]);
254        assert_eq!(result.source, "const a = 1;");
255        assert_eq!(result.applied, 0);
256        assert_eq!(result.skipped, 0);
257    }
258
259    #[test]
260    fn a_suggestion_overlapping_a_safe_fix_does_not_block_it() {
261        // Suggestions are filtered before overlaps are considered, so an unapplied one
262        // cannot consume the bytes a safe fix needs.
263        let result = apply("aaaa", &[suggestion(0, 4, "z"), fix(0, 2, "X")]);
264        assert_eq!(result.source, "Xaa");
265        assert_eq!(result.applied, 1);
266        assert_eq!(
267            result.skipped, 0,
268            "a suggestion should not count as skipped"
269        );
270    }
271}