#[derive(Debug, Clone, PartialEq, Eq)]
enum Shift {
Add(String),
Remove(String),
}
#[derive(Debug, PartialEq, Eq)]
pub(super) struct Reindented {
pub span: String,
pub replacement: String,
}
pub(super) fn find(content: &str, old: &str, new: &str) -> Option<Reindented> {
let old_lines: Vec<&str> = old.lines().collect();
if old_lines.iter().all(|l| l.trim().is_empty()) {
return None;
}
let file_lines: Vec<&str> = content.split_inclusive('\n').collect();
let n = old_lines.len();
if n > file_lines.len() {
return None;
}
let mut found: Option<(usize, Shift)> = None;
for start in 0..=file_lines.len() - n {
if let Some(shift) = uniform_shift(&file_lines[start..start + n], &old_lines) {
if found.is_some() {
return None; }
found = Some((start, shift));
}
}
let (start, shift) = found?;
let mut span: String = file_lines[start..start + n].concat();
if !old.ends_with('\n') {
let body = span.trim_end_matches(['\n', '\r']).len();
span.truncate(body);
}
let replacement = shift_lines(new, &shift)?;
Some(Reindented { span, replacement })
}
fn uniform_shift(file: &[&str], old: &[&str]) -> Option<Shift> {
let mut shift: Option<Shift> = None;
for (f, o) in file.iter().zip(old) {
let f = f.trim_end_matches(['\n', '\r']);
if f.trim().is_empty() && o.trim().is_empty() {
continue;
}
if f.trim() != o.trim() {
return None;
}
let f_indent = &f[..f.len() - f.trim_start().len()];
let o_indent = &o[..o.len() - o.trim_start().len()];
let this = if let Some(extra) = f_indent.strip_suffix(o_indent) {
Shift::Add(extra.to_string())
} else {
Shift::Remove(o_indent.strip_suffix(f_indent)?.to_string())
};
match &shift {
None => shift = Some(this),
Some(seen) if *seen == this => {}
Some(_) => return None,
}
}
shift.filter(|s| !matches!(s, Shift::Add(p) | Shift::Remove(p) if p.is_empty()))
}
fn shift_lines(new: &str, shift: &Shift) -> Option<String> {
let mut out = String::with_capacity(new.len() + 64);
for line in new.split_inclusive('\n') {
if line.trim().is_empty() {
out.push_str(line);
continue;
}
match shift {
Shift::Add(prefix) => {
out.push_str(prefix);
out.push_str(line);
}
Shift::Remove(prefix) => out.push_str(line.strip_prefix(prefix.as_str())?),
}
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
const FILE: &str = "impl A {\n fn f(&self) {\n let x = 1;\n g(x);\n }\n}\n";
#[test]
fn shallower_snippet_matches_and_new_string_is_shifted() {
let got = find(FILE, "let x = 1;\ng(x);", "let x = 2;\ng(x);").expect("unique match");
assert_eq!(got.span, " let x = 1;\n g(x);");
assert_eq!(got.replacement, " let x = 2;\n g(x);");
}
#[test]
fn deeper_snippet_is_shifted_back() {
let old = " fn f(&self) {\n let x = 1;";
let new = " fn f(&self) {\n let x = 3;";
let got = find(FILE, old, new).expect("unique match");
assert_eq!(got.span, " fn f(&self) {\n let x = 1;");
assert_eq!(got.replacement, " fn f(&self) {\n let x = 3;");
}
#[test]
fn uneven_offsets_do_not_match() {
assert!(find(FILE, "fn f(&self) {\nlet x = 1;", "x").is_none());
}
#[test]
fn ambiguous_places_do_not_match() {
let file = "a {\n x();\n}\nb {\n x();\n}\n";
assert!(find(file, "x();", "y();").is_none());
}
#[test]
fn replacement_that_cannot_take_the_shift_is_refused() {
let old = " let x = 1;";
assert!(find(FILE, old, " let x = 1;").is_none());
}
}