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}