1use std::cmp::Ordering;
2
3use compact_str::CompactString;
4use shuck_ast::{Span, TextRange, TextSize};
5
6use crate::Diagnostic;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
9pub enum Applicability {
10 Safe,
11 Unsafe,
12}
13
14impl Applicability {
15 pub const fn includes(self, applicability: Self) -> bool {
16 match self {
17 Self::Safe => matches!(applicability, Self::Safe),
18 Self::Unsafe => true,
19 }
20 }
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
24pub enum FixAvailability {
25 None,
26 Sometimes,
27 Always,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, Hash)]
31pub struct Edit {
32 range: TextRange,
33 content: CompactString,
34}
35
36impl Edit {
37 pub fn deletion(span: Span) -> Self {
38 Self::deletion_at(span.start.offset, span.end.offset)
39 }
40
41 pub fn deletion_at(start: usize, end: usize) -> Self {
42 Self::replacement_at(start, end, CompactString::default())
43 }
44
45 pub fn replacement(content: impl Into<CompactString>, span: Span) -> Self {
46 Self::replacement_at(span.start.offset, span.end.offset, content)
47 }
48
49 pub fn replacement_at(start: usize, end: usize, content: impl Into<CompactString>) -> Self {
50 let (start, end) = ordered_offsets(start, end);
51 Self {
52 range: TextRange::new(TextSize::new(start as u32), TextSize::new(end as u32)),
53 content: content.into(),
54 }
55 }
56
57 pub fn insertion(offset: usize, content: impl Into<CompactString>) -> Self {
58 Self::replacement_at(offset, offset, content)
59 }
60
61 pub const fn range(&self) -> TextRange {
62 self.range
63 }
64
65 pub fn content(&self) -> &str {
66 &self.content
67 }
68
69 fn start_offset(&self) -> usize {
70 usize::from(self.range.start())
71 }
72
73 fn end_offset(&self) -> usize {
74 usize::from(self.range.end())
75 }
76
77 fn is_insertion(&self) -> bool {
78 self.range.is_empty()
79 }
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct Fix {
84 edits: Box<[Edit]>,
85 applicability: Applicability,
86}
87
88impl Fix {
89 pub fn new(applicability: Applicability, edits: impl IntoIterator<Item = Edit>) -> Self {
90 let edits = edits.into_iter().collect::<Vec<_>>().into_boxed_slice();
91 debug_assert!(!edits.is_empty(), "fixes should contain at least one edit");
92 Self {
93 edits,
94 applicability,
95 }
96 }
97
98 pub fn safe_edit(edit: Edit) -> Self {
99 Self::safe_edits([edit])
100 }
101
102 pub fn safe_edits(edits: impl IntoIterator<Item = Edit>) -> Self {
103 Self::new(Applicability::Safe, edits)
104 }
105
106 pub fn unsafe_edit(edit: Edit) -> Self {
107 Self::unsafe_edits([edit])
108 }
109
110 pub fn unsafe_edits(edits: impl IntoIterator<Item = Edit>) -> Self {
111 Self::new(Applicability::Unsafe, edits)
112 }
113
114 pub fn edits(&self) -> &[Edit] {
115 &self.edits
116 }
117
118 pub const fn applicability(&self) -> Applicability {
119 self.applicability
120 }
121}
122
123#[derive(Debug, Clone, PartialEq, Eq)]
124pub struct AppliedFixes {
125 pub code: String,
126 pub fixes_applied: usize,
127}
128
129#[derive(Debug, Clone)]
130struct CandidateFix {
131 edits: Vec<Edit>,
132}
133
134pub fn apply_fixes(
135 source: &str,
136 diagnostics: &[Diagnostic],
137 applicability: Applicability,
138) -> AppliedFixes {
139 let mut candidate_fixes = diagnostics
140 .iter()
141 .filter_map(|diagnostic| {
142 let fix = diagnostic.fix.as_ref()?;
143 applicability
144 .includes(fix.applicability())
145 .then(|| prepare_fix(fix))
146 })
147 .collect::<Vec<_>>();
148 candidate_fixes.sort_by(compare_candidate_fixes);
149
150 let mut applied_fixes = 0;
151 let mut applied_edits = Vec::new();
152 for candidate in candidate_fixes {
153 if has_internal_conflicts(&candidate.edits) {
154 continue;
155 }
156 if candidate.edits.iter().any(|edit| {
157 applied_edits
158 .iter()
159 .any(|other| edits_conflict(edit, other))
160 }) {
161 continue;
162 }
163 applied_edits.extend(candidate.edits);
164 applied_fixes += 1;
165 }
166
167 if applied_fixes == 0 {
168 return AppliedFixes {
169 code: source.to_owned(),
170 fixes_applied: 0,
171 };
172 }
173
174 applied_edits.sort_by(compare_edits);
175 let mut output = String::with_capacity(source.len());
176 let mut cursor = 0;
177 for edit in applied_edits {
178 let start = edit.start_offset();
179 let end = edit.end_offset();
180 debug_assert!(start <= end);
181 debug_assert!(end <= source.len());
182 debug_assert!(source.is_char_boundary(start));
183 debug_assert!(source.is_char_boundary(end));
184
185 output.push_str(&source[cursor..start]);
186 output.push_str(edit.content());
187 cursor = end;
188 }
189 output.push_str(&source[cursor..]);
190
191 AppliedFixes {
192 code: output,
193 fixes_applied: applied_fixes,
194 }
195}
196
197fn prepare_fix(fix: &Fix) -> CandidateFix {
198 let mut edits = fix.edits().to_vec();
199 edits.sort_by(compare_edits);
200 CandidateFix { edits }
201}
202
203fn compare_candidate_fixes(left: &CandidateFix, right: &CandidateFix) -> Ordering {
204 for (left_edit, right_edit) in left.edits.iter().zip(&right.edits) {
205 let ordering = compare_edits(left_edit, right_edit);
206 if !ordering.is_eq() {
207 return ordering;
208 }
209 }
210
211 left.edits.len().cmp(&right.edits.len())
212}
213
214fn compare_edits(left: &Edit, right: &Edit) -> Ordering {
215 left.start_offset()
216 .cmp(&right.start_offset())
217 .then(left.end_offset().cmp(&right.end_offset()))
218 .then(left.content().cmp(right.content()))
219}
220
221fn has_internal_conflicts(edits: &[Edit]) -> bool {
222 edits
223 .windows(2)
224 .any(|window| edits_conflict(&window[0], &window[1]))
225}
226
227fn edits_conflict(left: &Edit, right: &Edit) -> bool {
228 let left_start = left.start_offset();
229 let left_end = left.end_offset();
230 let right_start = right.start_offset();
231 let right_end = right.end_offset();
232
233 if left.is_insertion() && right.is_insertion() {
234 return left_start == right_start;
235 }
236
237 if left.is_insertion() {
238 return right_start <= left_start && left_start <= right_end;
239 }
240
241 if right.is_insertion() {
242 return left_start <= right_start && right_start <= left_end;
243 }
244
245 left_start < right_end && right_start < left_end
246}
247
248fn ordered_offsets(start: usize, end: usize) -> (usize, usize) {
249 if start <= end {
250 (start, end)
251 } else {
252 (end, start)
253 }
254}
255
256#[cfg(test)]
257mod tests {
258 use shuck_ast::{Position, Span};
259
260 use crate::{Diagnostic, Rule, Severity};
261
262 use super::{Applicability, Edit, Fix, apply_fixes};
263
264 fn diagnostic_with_fix(message: &str, fix: Fix) -> Diagnostic {
265 Diagnostic {
266 rule: Rule::AmpersandSemicolon,
267 message: message.to_owned(),
268 severity: Severity::Warning,
269 span: span(0, 0),
270 fix: Some(fix),
271 fix_title: Some("apply test fix".to_owned()),
272 }
273 }
274
275 fn span(start: usize, end: usize) -> Span {
276 Span::from_positions(
277 Position {
278 line: 1,
279 column: start + 1,
280 offset: start,
281 },
282 Position {
283 line: 1,
284 column: end + 1,
285 offset: end,
286 },
287 )
288 }
289
290 #[test]
291 fn applies_one_safe_deletion_edit() {
292 let source = "echo x &;\n";
293 let diagnostics = vec![diagnostic_with_fix(
294 "delete the semicolon",
295 Fix::safe_edit(Edit::deletion(span(8, 9))),
296 )];
297
298 let fixed = apply_fixes(source, &diagnostics, Applicability::Safe);
299
300 assert_eq!(fixed.code, "echo x &\n");
301 assert_eq!(fixed.fixes_applied, 1);
302 }
303
304 #[test]
305 fn applies_multiple_non_overlapping_edits() {
306 let source = "a;b;c\n";
307 let diagnostics = vec![
308 diagnostic_with_fix("first", Fix::safe_edit(Edit::deletion(span(1, 2)))),
309 diagnostic_with_fix("second", Fix::safe_edit(Edit::deletion(span(3, 4)))),
310 ];
311
312 let fixed = apply_fixes(source, &diagnostics, Applicability::Safe);
313
314 assert_eq!(fixed.code, "abc\n");
315 assert_eq!(fixed.fixes_applied, 2);
316 }
317
318 #[test]
319 fn deconflicts_overlapping_edits_deterministically() {
320 let source = "abcde\n";
321 let diagnostics = vec![
322 diagnostic_with_fix(
323 "later wide edit",
324 Fix::safe_edit(Edit::replacement_at(1, 4, "X")),
325 ),
326 diagnostic_with_fix(
327 "earlier narrow edit",
328 Fix::safe_edit(Edit::replacement_at(1, 2, "Y")),
329 ),
330 ];
331
332 let fixed = apply_fixes(source, &diagnostics, Applicability::Safe);
333
334 assert_eq!(fixed.code, "aYcde\n");
335 assert_eq!(fixed.fixes_applied, 1);
336 }
337}