use std::ops::Range;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Fix {
pub start: usize,
pub end: usize,
pub replacement: String,
pub safe: bool,
}
impl Fix {
#[must_use]
pub const fn range(&self) -> Range<usize> {
self.start..self.end
}
#[must_use]
pub const fn overlaps(&self, other: &Self) -> bool {
self.start < other.end && other.start < self.end
}
#[must_use]
pub const fn fits(&self, len: usize) -> bool {
self.start <= self.end && self.end <= len
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FixOutcome {
pub source: String,
pub applied: usize,
pub skipped: usize,
}
#[must_use]
pub fn apply(source: &str, fixes: &[Fix]) -> FixOutcome {
let mut candidates: Vec<&Fix> = fixes
.iter()
.filter(|fix| fix.safe && fix.fits(source.len()))
.collect();
candidates.sort_by(|a, b| a.start.cmp(&b.start).then_with(|| a.end.cmp(&b.end)));
let mut chosen: Vec<&Fix> = Vec::with_capacity(candidates.len());
let mut skipped = 0usize;
for fix in candidates {
if chosen.last().is_some_and(|last| last.overlaps(fix)) {
skipped += 1;
continue;
}
chosen.push(fix);
}
let mut source = source.to_owned();
let applied = chosen.len();
for fix in chosen.iter().rev() {
if source.is_char_boundary(fix.start) && source.is_char_boundary(fix.end) {
source.replace_range(fix.range(), &fix.replacement);
}
}
FixOutcome {
source,
applied,
skipped,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fix(start: usize, end: usize, replacement: &str) -> Fix {
Fix {
start,
end,
replacement: replacement.to_owned(),
safe: true,
}
}
fn suggestion(start: usize, end: usize, replacement: &str) -> Fix {
Fix {
safe: false,
..fix(start, end, replacement)
}
}
#[test]
fn a_single_fix_replaces_its_range() {
let result = apply("const a = 1;", &[fix(0, 5, "let")]);
assert_eq!(result.source, "let a = 1;");
assert_eq!(result.applied, 1);
assert_eq!(result.skipped, 0);
}
#[test]
fn several_fixes_all_land_in_the_right_places() {
let result = apply(
"aaa bbb ccc",
&[fix(0, 3, "xxxx"), fix(4, 7, "y"), fix(8, 11, "zzzzz")],
);
assert_eq!(result.source, "xxxx y zzzzz");
assert_eq!(result.applied, 3);
}
#[test]
fn a_suggestion_is_not_applied() {
let result = apply("const a = 1;", &[suggestion(0, 5, "let")]);
assert_eq!(result.source, "const a = 1;");
assert_eq!(result.applied, 0);
}
#[test]
fn overlapping_fixes_are_skipped_and_counted() {
let result = apply("aaaa", &[fix(0, 3, "x"), fix(1, 4, "y")]);
assert_eq!(result.applied, 1);
assert_eq!(result.skipped, 1);
assert_eq!(result.source, "xa");
}
#[test]
fn adjacent_fixes_are_both_applied() {
let result = apply("abcd", &[fix(0, 2, "X"), fix(2, 4, "Y")]);
assert_eq!(result.applied, 2);
assert_eq!(result.source, "XY");
}
#[test]
fn which_of_two_overlapping_fixes_wins_does_not_depend_on_order() {
let one = apply("aaaa", &[fix(0, 3, "x"), fix(1, 4, "y")]);
let other = apply("aaaa", &[fix(1, 4, "y"), fix(0, 3, "x")]);
assert_eq!(one, other);
}
#[test]
fn a_range_past_the_end_is_declined() {
let result = apply("short", &[fix(0, 500, "x")]);
assert_eq!(result.source, "short");
assert_eq!(result.applied, 0);
}
#[test]
fn an_inverted_range_is_declined() {
let result = apply("const a = 1;", &[fix(5, 2, "x")]);
assert_eq!(result.source, "const a = 1;");
assert_eq!(result.applied, 0);
}
#[test]
fn a_range_splitting_a_character_is_declined_rather_than_panicking() {
let source = "a → b";
let result = apply(source, &[fix(2, 3, "x")]);
assert_eq!(result.source, source);
}
#[test]
fn a_multi_byte_range_on_its_boundaries_is_applied() {
let source = "a → b";
let arrow = source.find('→').expect("present");
let result = apply(source, &[fix(arrow, arrow + '→'.len_utf8(), "->")]);
assert_eq!(result.source, "a -> b");
}
#[test]
fn an_empty_replacement_deletes() {
let result = apply("const a = 1;\n", &[fix(0, 13, "")]);
assert_eq!(result.source, "");
assert_eq!(result.applied, 1);
}
#[test]
fn an_empty_range_inserts() {
let result = apply("ab", &[fix(1, 1, "X")]);
assert_eq!(result.source, "aXb");
}
#[test]
fn no_fixes_leaves_the_source_alone() {
let result = apply("const a = 1;", &[]);
assert_eq!(result.source, "const a = 1;");
assert_eq!(result.applied, 0);
assert_eq!(result.skipped, 0);
}
#[test]
fn a_suggestion_overlapping_a_safe_fix_does_not_block_it() {
let result = apply("aaaa", &[suggestion(0, 4, "z"), fix(0, 2, "X")]);
assert_eq!(result.source, "Xaa");
assert_eq!(result.applied, 1);
assert_eq!(
result.skipped, 0,
"a suggestion should not count as skipped"
);
}
}