Skip to main content

shuck_linter/
fix.rs

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}