supa_mdx_lint/
fix.rs

1use std::{borrow::Cow, cmp::Ordering, fs};
2
3use anyhow::Result;
4use bon::bon;
5use log::{debug, error, trace};
6use serde::{Deserialize, Serialize};
7
8use crate::{
9    app_error::AppError,
10    context::Context,
11    location::{AdjustedRange, DenormalizedLocation, Offsets},
12    output::LintOutput,
13    rope::Rope,
14    utils::words::{is_sentence_start, WordIterator},
15    Linter,
16};
17
18#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)]
19pub enum LintCorrection {
20    Insert(LintCorrectionInsert),
21    Delete(LintCorrectionDelete),
22    Replace(LintCorrectionReplace),
23}
24
25#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)]
26pub struct LintCorrectionInsert {
27    /// Text is inserted in front of the start point. The end point is ignored.
28    pub(crate) location: DenormalizedLocation,
29    pub(crate) text: String,
30}
31
32#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)]
33pub struct LintCorrectionDelete {
34    pub(crate) location: DenormalizedLocation,
35}
36
37// Required to implement sealed trait Offsets
38impl crate::private::Sealed for LintCorrectionDelete {}
39
40impl Offsets for LintCorrectionDelete {
41    fn start(&self) -> usize {
42        self.location.offset_range.start.into()
43    }
44
45    fn end(&self) -> usize {
46        self.location.offset_range.end.into()
47    }
48}
49
50#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)]
51pub struct LintCorrectionReplace {
52    pub(crate) location: DenormalizedLocation,
53    pub(crate) text: String,
54}
55
56// Required to implement sealed trait Offsets
57impl crate::private::Sealed for LintCorrectionInsert {}
58
59impl Offsets for LintCorrectionInsert {
60    fn start(&self) -> usize {
61        self.location.offset_range.start.into()
62    }
63
64    fn end(&self) -> usize {
65        self.location.offset_range.end.into()
66    }
67}
68
69impl LintCorrectionInsert {
70    pub fn text(&self) -> &str {
71        &self.text
72    }
73}
74
75// Required to implement sealed trait Offsets
76impl crate::private::Sealed for LintCorrectionReplace {}
77
78impl Offsets for LintCorrectionReplace {
79    fn start(&self) -> usize {
80        self.location.offset_range.start.into()
81    }
82
83    fn end(&self) -> usize {
84        self.location.offset_range.end.into()
85    }
86}
87
88impl LintCorrectionReplace {
89    pub fn text(&self) -> &str {
90        &self.text
91    }
92}
93
94impl PartialOrd for LintCorrection {
95    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
96        Some(self.cmp(other))
97    }
98}
99
100impl Ord for LintCorrection {
101    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
102        match (self, other) {
103            (LintCorrection::Insert(insert_a), LintCorrection::Insert(insert_b)) => {
104                insert_a.location.start.cmp(&insert_b.location.start)
105            }
106            (LintCorrection::Insert(insert), LintCorrection::Delete(delete)) => {
107                if delete.location.start.le(&insert.location.start)
108                    && delete.location.end.gt(&insert.location.start)
109                {
110                    // The delete wraps the insert, so only one of these fixes
111                    // should take place. Represent this as equality.
112                    return Ordering::Equal;
113                }
114
115                // The two don't overlap, so the delete is either fully before
116                // or fully after the insert. We can arbitrarily choose between
117                // the start and the end point for comparison.
118                delete.location.start.cmp(&insert.location.start)
119            }
120            (LintCorrection::Insert(insert), LintCorrection::Replace(replace)) => {
121                if replace.location.start.le(&insert.location.start)
122                    && replace.location.end.gt(&insert.location.start)
123                {
124                    // The replace wraps the insert, so only one of these fixes
125                    // should take place. Represent this as equality.
126                    return Ordering::Equal;
127                }
128
129                // The two don't overlap, so the replace is either fully before
130                // or fully after the insert. We can arbitrarily choose between
131                // the start and the end point for comparison.
132                replace.location.start.cmp(&insert.location.start)
133            }
134            (LintCorrection::Delete(_), LintCorrection::Insert(_)) => other.cmp(self).reverse(),
135            (LintCorrection::Delete(delete_a), LintCorrection::Delete(delete_b)) => {
136                let flip = delete_a.location.start.gt(&delete_b.location.start);
137                if flip {
138                    return other.cmp(self).reverse();
139                }
140
141                if delete_a.location.end.gt(&delete_b.location.start) {
142                    // The deletes overlap either fully or partially, so only
143                    // one overall fix should take place. Represent this as
144                    // equality.
145                    return Ordering::Equal;
146                }
147
148                Ordering::Less
149            }
150            (LintCorrection::Delete(delete), LintCorrection::Replace(replace)) => {
151                let flip = delete.location.start.gt(&replace.location.start);
152                if flip {
153                    return other.cmp(self).reverse();
154                }
155
156                if delete.location.end.gt(&replace.location.start) {
157                    // The deletes overlap either fully or partially, so only
158                    // one overall fix should take place. Represent this as
159                    // equality.
160                    return Ordering::Equal;
161                }
162
163                Ordering::Less
164            }
165            (LintCorrection::Replace(_), LintCorrection::Insert(_)) => other.cmp(self).reverse(),
166            (LintCorrection::Replace(replace), LintCorrection::Delete(delete)) => {
167                let flip = replace.location.start.gt(&delete.location.start);
168                if flip {
169                    return other.cmp(self).reverse();
170                }
171
172                if replace.location.end.gt(&delete.location.start) {
173                    // The ranges overlap either fully or partially, so only
174                    // one overall fix should take place. Represent this as
175                    // equality.
176                    return Ordering::Equal;
177                }
178
179                Ordering::Less
180            }
181            (LintCorrection::Replace(replace_a), LintCorrection::Replace(replace_b)) => {
182                let flip = replace_a.location.start.gt(&replace_b.location.start);
183                if flip {
184                    return other.cmp(self).reverse();
185                }
186
187                if replace_a.location.end.gt(&replace_b.location.start) {
188                    // The ranges overlap either fully or partially, so only
189                    // one overall fix should take place. Represent this as
190                    // equality.
191                    return Ordering::Equal;
192                }
193
194                Ordering::Less
195            }
196        }
197    }
198}
199
200#[bon]
201impl LintCorrection {
202    /// Given two conflicting fixes, choose one to apply, or create a new fix
203    /// that merges the two. Returns `None` if the's not clear which one to
204    /// apply.
205    ///
206    /// Should only be called after checking that the fixes do in fact conflict.
207    fn choose_or_merge(self, other: Self) -> Option<Self> {
208        match (self, other) {
209            (LintCorrection::Insert(_), LintCorrection::Insert(_)) => {
210                // The fixes conflict and it's not clear which one to apply.
211                // Inserting multiple alternate texts in the same place is
212                // likely a mistake.
213                None
214            }
215            (LintCorrection::Insert(_), LintCorrection::Delete(delete)) => {
216                // The delete overlaps the insert, so apply the delete.
217                Some(LintCorrection::Delete(delete))
218            }
219            (LintCorrection::Insert(_), LintCorrection::Replace(replace)) => {
220                // The replace overlaps the insert, so apply the replace.
221                Some(LintCorrection::Replace(replace))
222            }
223            (LintCorrection::Delete(delete), LintCorrection::Insert(_)) => {
224                // The delete overlaps the insert, so apply the delete.
225                Some(LintCorrection::Delete(delete))
226            }
227            (LintCorrection::Delete(delete_a), LintCorrection::Delete(delete_b)) => {
228                // The deletes overlap, so merge them.
229                let new_range = AdjustedRange::span_between(
230                    &delete_a.location.offset_range,
231                    &delete_b.location.offset_range,
232                );
233                let start = if delete_a.location.offset_range.start
234                    < delete_b.location.offset_range.start
235                {
236                    delete_a.location.start
237                } else {
238                    delete_b.location.start
239                };
240                let end = if delete_a.location.offset_range.end > delete_b.location.offset_range.end
241                {
242                    delete_a.location.end
243                } else {
244                    delete_b.location.end
245                };
246                let location = DenormalizedLocation {
247                    offset_range: new_range,
248                    start,
249                    end,
250                };
251
252                Some(LintCorrection::Delete(LintCorrectionDelete { location }))
253            }
254            (LintCorrection::Delete(delete), LintCorrection::Replace(replace)) => {
255                // If one completely overlaps the other, apply it. Otherwise,
256                // return None.
257                if delete.location.start.lt(&replace.location.start)
258                    && delete.location.end.gt(&replace.location.end)
259                {
260                    // The delete wraps the replace, so apply the delete.
261                    Some(LintCorrection::Delete(delete))
262                } else if replace.location.start.lt(&delete.location.start)
263                    && replace.location.end.gt(&delete.location.end)
264                {
265                    // The replace wraps the delete, so apply the replace.
266                    Some(LintCorrection::Replace(replace))
267                } else {
268                    None
269                }
270            }
271            (LintCorrection::Replace(replace), LintCorrection::Insert(_)) => {
272                // The replace overlaps the insert, so apply the replace.
273                Some(LintCorrection::Replace(replace))
274            }
275            (LintCorrection::Replace(replace), LintCorrection::Delete(delete)) => {
276                // If one completely overlaps the other, apply it. Otherwise,
277                // return None.
278                if delete.location.start.lt(&replace.location.start)
279                    && delete.location.end.gt(&replace.location.end)
280                {
281                    // The delete wraps the replace, so apply the delete.
282                    Some(LintCorrection::Delete(delete))
283                } else if replace.location.start.lt(&delete.location.start)
284                    && replace.location.end.gt(&delete.location.end)
285                {
286                    // The replace wraps the delete, so apply the replace.
287                    Some(LintCorrection::Replace(replace))
288                } else {
289                    None
290                }
291            }
292            (LintCorrection::Replace(replace_a), LintCorrection::Replace(replace_b)) => {
293                // If one completely overlaps the other, apply it. Otherwise,
294                // return None.
295                if replace_b.location.start.lt(&replace_a.location.start)
296                    && replace_b.location.end.gt(&replace_a.location.end)
297                {
298                    // The replace_b wraps the replace_a, so apply the replace_b.
299                    Some(LintCorrection::Replace(replace_b))
300                } else if replace_a.location.start.lt(&replace_b.location.start)
301                    && replace_a.location.end.gt(&replace_b.location.end)
302                {
303                    // The replace_a wraps the replace_b, so apply the replace_a.
304                    Some(LintCorrection::Replace(replace_a))
305                } else {
306                    None
307                }
308            }
309        }
310    }
311
312    #[builder]
313    pub(crate) fn create_word_splice_correction(
314        context: &Context<'_>,
315        outer_range: &AdjustedRange,
316        splice_range: &AdjustedRange,
317        #[builder(default = true)] count_beginning_as_sentence_start: bool,
318        replace: Option<Cow<'_, str>>,
319    ) -> Self {
320        let outer_text = context.rope().byte_slice(outer_range.to_usize_range());
321        let is_sentence_start = is_sentence_start()
322            .slice(outer_text)
323            .query_offset(splice_range.start.into_usize() - outer_range.start.into_usize())
324            .count_beginning_as_sentence_start(count_beginning_as_sentence_start)
325            .call();
326
327        let location = DenormalizedLocation::from_offset_range(splice_range.clone(), context);
328
329        match replace {
330            Some(replace) => {
331                let replace = if is_sentence_start {
332                    replace.chars().next().unwrap().to_uppercase().to_string() + &replace[1..]
333                } else {
334                    replace.to_string()
335                };
336
337                LintCorrection::Replace(LintCorrectionReplace {
338                    location,
339                    text: replace,
340                })
341            }
342            None => {
343                let mut iter = WordIterator::new(
344                    context.rope().byte_slice(splice_range.end.into_usize()..),
345                    splice_range.end.into(),
346                    Default::default(),
347                );
348
349                if let Some((offset, _, _)) = iter.next() {
350                    let mut between = context
351                        .rope()
352                        .byte_slice(splice_range.end.into()..offset)
353                        .chars();
354                    if between.all(|c| c.is_whitespace()) {
355                        if is_sentence_start {
356                            let location = DenormalizedLocation::from_offset_range(
357                                AdjustedRange::new(splice_range.start, (offset + 1).into()),
358                                context,
359                            );
360                            LintCorrection::Replace(LintCorrectionReplace {
361                                location,
362                                text: context
363                                    .rope()
364                                    .byte_slice(offset..)
365                                    .chars()
366                                    .next()
367                                    .unwrap()
368                                    .to_string()
369                                    .to_uppercase(),
370                            })
371                        } else {
372                            LintCorrection::Delete(LintCorrectionDelete {
373                                location: DenormalizedLocation::from_offset_range(
374                                    AdjustedRange::new(splice_range.start, offset.into()),
375                                    context,
376                                ),
377                            })
378                        }
379                    } else {
380                        LintCorrection::Delete(LintCorrectionDelete { location })
381                    }
382                } else {
383                    LintCorrection::Delete(LintCorrectionDelete { location })
384                }
385            }
386        }
387    }
388}
389
390impl Linter {
391    /// Auto-fix any fixable errors.
392    ///
393    /// Returns a tuple of (number of files fixed, number of errors fixed).
394    pub fn fix(&self, diagnostics: &[LintOutput]) -> Result<(usize, usize)> {
395        let mut files_fixed: usize = 0;
396        let mut errors_fixed: usize = 0;
397
398        let fixable_outputs: Vec<&LintOutput> = diagnostics
399            .iter()
400            .filter(|diagnostic| diagnostic.errors().iter().any(|error| error.fix.is_some()))
401            .collect();
402        if fixable_outputs.is_empty() {
403            debug!("No fixable errors found for this set of diagnostics.");
404            trace!("Diagnostics: {:#?}", diagnostics);
405            return Ok((files_fixed, errors_fixed));
406        }
407
408        for diagnostic in fixable_outputs {
409            let local_errors_fixed = Self::fix_single_file(diagnostic).inspect_err(|err| {
410                error!("Error fixing file {}: {}", diagnostic.file_path(), err)
411            })?;
412            errors_fixed += local_errors_fixed;
413            files_fixed += 1;
414        }
415
416        Ok((files_fixed, errors_fixed))
417    }
418
419    fn fix_single_file(diagnostic: &LintOutput) -> Result<usize> {
420        let mut errors_fixed = 0;
421
422        let file = diagnostic.file_path();
423        debug!("Fixing errors in {file}");
424
425        let content = fs::read_to_string(file).map_err(|err| {
426            AppError::FileSystemError(format!("reading file {file} for auto-fixing"), err)
427        })?;
428        let mut rope = Rope::from(content.as_str());
429
430        let fixes_to_apply = Self::calculate_fixes_to_apply(file, diagnostic);
431        debug!("Fixes to apply for file {file}: {fixes_to_apply:#?}");
432
433        for fix in fixes_to_apply {
434            match fix {
435                LintCorrection::Insert(lint_fix_insert) => {
436                    rope.insert(
437                        lint_fix_insert.location.offset_range.start.into(),
438                        lint_fix_insert.text,
439                    );
440                    errors_fixed += 1;
441                }
442                LintCorrection::Delete(lint_fix_delete) => {
443                    let start: usize = lint_fix_delete.location.offset_range.start.into();
444                    let end: usize = lint_fix_delete.location.offset_range.end.into();
445                    rope.replace(start..end, "");
446                    errors_fixed += 1;
447                }
448                LintCorrection::Replace(lint_fix_replace) => {
449                    let start: usize = lint_fix_replace.location.offset_range.start.into();
450                    let end: usize = lint_fix_replace.location.offset_range.end.into();
451                    rope.replace(start..end, lint_fix_replace.text.as_str());
452                    errors_fixed += 1;
453                }
454            }
455        }
456
457        let content = rope.to_string();
458        fs::write(diagnostic.file_path(), content).map_err(|err| {
459            AppError::FileSystemError(format!("writing file {file} post-fixing"), err)
460        })?;
461
462        Ok(errors_fixed)
463    }
464
465    fn calculate_fixes_to_apply(file: &str, diagnostic: &LintOutput) -> Vec<LintCorrection> {
466        let mut requested_fixes: Vec<LintCorrection> = diagnostic
467            .errors()
468            .iter()
469            .filter_map(|err| err.fix.clone())
470            .flatten()
471            .collect();
472        requested_fixes.sort();
473        // Reversing so that fixes are applied in reverse order, avoiding
474        // offset shift.
475        let requested_fixes = requested_fixes.into_iter().rev();
476        debug!("Requested fixes for file {file}: {requested_fixes:#?}");
477
478        let mut fixes_to_apply: Vec<LintCorrection> = Vec::new();
479        for fix in requested_fixes {
480            if let Some(last_scheduled_fix) = fixes_to_apply.last() {
481                if last_scheduled_fix.eq(&fix) {
482                    // The fixes conflict, so pick one to fix, or merge
483                    // them.
484                    let last_scheduled_fix = fixes_to_apply.pop().unwrap();
485                    if let Some(new_fix) = last_scheduled_fix.choose_or_merge(fix) {
486                        fixes_to_apply.push(new_fix);
487                    }
488                } else {
489                    // The fixes don't conflict, so apply both.
490                    fixes_to_apply.push(fix.clone());
491                }
492            } else {
493                fixes_to_apply.push(fix.clone());
494            }
495        }
496
497        fixes_to_apply
498    }
499}
500
501#[cfg(test)]
502mod tests {
503    use crate::parse;
504
505    use super::*;
506
507    #[test]
508    fn test_create_word_splice_correction_midsentence() {
509        let parsed = parse("Here is a simple sentence.").unwrap();
510        let context = Context::builder().parse_result(&parsed).build().unwrap();
511
512        let outer_range = AdjustedRange::new(0.into(), 26.into());
513        let splice_range = AdjustedRange::new(10.into(), 16.into());
514
515        let expected = LintCorrection::Delete(LintCorrectionDelete {
516            location: DenormalizedLocation::from_offset_range(
517                AdjustedRange::new(10.into(), 17.into()),
518                &context,
519            ),
520        });
521        let actual = LintCorrection::create_word_splice_correction()
522            .context(&context)
523            .outer_range(&outer_range)
524            .splice_range(&splice_range)
525            .call();
526        assert_eq!(expected, actual);
527    }
528
529    #[test]
530    fn test_create_word_splice_correction_midsentence_replace() {
531        let parsed = parse("Here is a simple sentence.").unwrap();
532        let context = Context::builder().parse_result(&parsed).build().unwrap();
533
534        let outer_range = AdjustedRange::new(0.into(), 26.into());
535        let splice_range = AdjustedRange::new(10.into(), 16.into());
536
537        let expected = LintCorrection::Replace(LintCorrectionReplace {
538            text: "lovely".to_string(),
539            location: DenormalizedLocation::from_offset_range(
540                AdjustedRange::new(10.into(), 16.into()),
541                &context,
542            ),
543        });
544        let actual = LintCorrection::create_word_splice_correction()
545            .context(&context)
546            .outer_range(&outer_range)
547            .splice_range(&splice_range)
548            .replace("lovely".into())
549            .call();
550        assert_eq!(expected, actual);
551    }
552
553    #[test]
554    fn test_create_word_splice_correction_new_sentence() {
555        let parsed = parse("What a lovely day. Please take a biscuit.").unwrap();
556        let context = Context::builder().parse_result(&parsed).build().unwrap();
557
558        let outer_range = AdjustedRange::new(0.into(), 41.into());
559        let splice_range = AdjustedRange::new(19.into(), 25.into());
560
561        let expected = LintCorrection::Replace(LintCorrectionReplace {
562            text: "T".to_string(),
563            location: DenormalizedLocation::from_offset_range(
564                AdjustedRange::new(19.into(), 27.into()),
565                &context,
566            ),
567        });
568        let actual = LintCorrection::create_word_splice_correction()
569            .context(&context)
570            .outer_range(&outer_range)
571            .splice_range(&splice_range)
572            .call();
573        assert_eq!(expected, actual);
574    }
575
576    #[test]
577    fn test_create_word_splice_correction_new_sentence_replace() {
578        let parsed = parse("What a lovely day. Please take a biscuit.").unwrap();
579        let context = Context::builder().parse_result(&parsed).build().unwrap();
580
581        let outer_range = AdjustedRange::new(0.into(), 41.into());
582        let splice_range = AdjustedRange::new(19.into(), 25.into());
583
584        let expected = LintCorrection::Replace(LintCorrectionReplace {
585            text: "Kindly".to_string(),
586            location: DenormalizedLocation::from_offset_range(
587                AdjustedRange::new(19.into(), 25.into()),
588                &context,
589            ),
590        });
591        let actual = LintCorrection::create_word_splice_correction()
592            .context(&context)
593            .outer_range(&outer_range)
594            .splice_range(&splice_range)
595            .replace("kindly".into())
596            .call();
597        assert_eq!(expected, actual);
598    }
599
600    #[test]
601    fn test_create_word_splice_correction_start() {
602        let parsed = parse("Please take a biscuit.").unwrap();
603        let context = Context::builder().parse_result(&parsed).build().unwrap();
604
605        let outer_range = AdjustedRange::new(0.into(), 22.into());
606        let splice_range = AdjustedRange::new(0.into(), 6.into());
607
608        let expected = LintCorrection::Replace(LintCorrectionReplace {
609            text: "T".to_string(),
610            location: DenormalizedLocation::from_offset_range(
611                AdjustedRange::new(0.into(), 8.into()),
612                &context,
613            ),
614        });
615        let actual = LintCorrection::create_word_splice_correction()
616            .context(&context)
617            .outer_range(&outer_range)
618            .splice_range(&splice_range)
619            .call();
620        assert_eq!(expected, actual);
621    }
622}