#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NearestMiss {
pub line: usize,
pub first_diverging_line: usize,
pub expected: String,
pub found: String,
pub block_len: usize,
}
impl NearestMiss {
pub fn blank_line_hint(&self) -> Option<String> {
self.expected.is_empty().then(|| {
format!(
"the find block's line {} (of {}) is empty — likely a stray blank or \
trailing line in the payload; trim it, or pass the anchor via text:",
self.first_diverging_line, self.block_len
)
})
}
}
pub fn find_starts<S: AsRef<str>>(lines: &[S], block: &[String]) -> Vec<usize> {
let k = block.len();
if k == 0 || lines.len() < k {
return Vec::new();
}
let mut starts = Vec::new();
let mut i = 0usize;
while i + k <= lines.len() {
if block
.iter()
.zip(&lines[i..i + k])
.all(|(b, l)| b == l.as_ref())
{
starts.push(i);
i += k; } else {
i += 1;
}
}
starts
}
fn is_blank(s: &str) -> bool {
s.trim().is_empty()
}
fn align_squeezed<S: AsRef<str>>(
lines: &[S],
block: &[String],
start: usize,
) -> Result<usize, (usize, usize)> {
let mut bi = 0usize;
let mut li = start;
while bi < block.len() {
if is_blank(&block[bi]) {
let run_start = bi;
while bi < block.len() && is_blank(&block[bi]) {
bi += 1;
}
if li >= lines.len() || !is_blank(lines[li].as_ref()) {
return Err((run_start, li));
}
while li < lines.len() && is_blank(lines[li].as_ref()) {
li += 1;
}
} else {
if li >= lines.len() || lines[li].as_ref() != block[bi] {
return Err((bi, li));
}
bi += 1;
li += 1;
}
}
Ok(li - start)
}
pub fn find_spans_squeezed<S: AsRef<str>>(lines: &[S], block: &[String]) -> Vec<(usize, usize)> {
if block.is_empty() {
return Vec::new();
}
let mut spans = Vec::new();
let mut i = 0usize;
while i < lines.len() {
if let Ok(len) = align_squeezed(lines, block, i) {
spans.push((i, len));
i += len.max(1); } else {
i += 1;
}
}
spans
}
pub fn nearest_miss<S: AsRef<str>>(lines: &[S], block: &[String]) -> Option<NearestMiss> {
if block.is_empty() || lines.is_empty() {
return None;
}
let mut best: Option<(usize, usize)> = None; for start in 0..lines.len() {
if lines[start].as_ref() != block[0] {
continue;
}
let mut len = 0usize;
while len < block.len()
&& start + len < lines.len()
&& lines[start + len].as_ref() == block[len]
{
len += 1;
}
if best.is_none_or(|(blen, _)| len > blen) {
best = Some((len, start));
}
}
if let Some((len, start)) = best {
let found = lines
.get(start + len)
.map(|l| l.as_ref().to_string())
.unwrap_or_default();
return Some(NearestMiss {
line: start + 1,
first_diverging_line: len + 1,
expected: block.get(len).cloned().unwrap_or_default(),
found,
block_len: block.len(),
});
}
let want = block[0].trim();
if want.is_empty() {
return None;
}
lines
.iter()
.position(|l| l.as_ref().trim() == want)
.map(|i| NearestMiss {
line: i + 1,
first_diverging_line: 1,
expected: block[0].clone(),
found: lines[i].as_ref().to_string(),
block_len: block.len(),
})
}
pub fn nearest_miss_with<S: AsRef<str>>(
lines: &[S],
block: &[String],
squeeze: bool,
) -> Option<NearestMiss> {
if squeeze {
nearest_miss_squeezed(lines, block)
} else {
nearest_miss(lines, block)
}
}
fn nearest_miss_squeezed<S: AsRef<str>>(lines: &[S], block: &[String]) -> Option<NearestMiss> {
if block.is_empty() || lines.is_empty() {
return None;
}
let first_anchors = |src: &str| {
if is_blank(&block[0]) {
is_blank(src)
} else {
src == block[0]
}
};
let mut best: Option<(usize, usize, usize)> = None;
for start in 0..lines.len() {
if !first_anchors(lines[start].as_ref()) {
continue;
}
if let Err((bi, li)) = align_squeezed(lines, block, start)
&& best.is_none_or(|(blen, _, _)| bi > blen)
{
best = Some((bi, start, li));
}
}
if let Some((bi, start, li)) = best {
let found = lines
.get(li)
.map(|l| l.as_ref().to_string())
.unwrap_or_default();
return Some(NearestMiss {
line: start + 1,
first_diverging_line: bi + 1,
expected: block.get(bi).cloned().unwrap_or_default(),
found,
block_len: block.len(),
});
}
let want = block[0].trim();
if want.is_empty() {
return None;
}
lines
.iter()
.position(|l| l.as_ref().trim() == want)
.map(|i| NearestMiss {
line: i + 1,
first_diverging_line: 1,
expected: block[0].clone(),
found: lines[i].as_ref().to_string(),
block_len: block.len(),
})
}
use crate::edit::Site;
pub fn edit_blocks(
path: &str,
content: &str,
block: &[String],
replacement: &[String],
) -> (String, usize, Vec<Site>) {
edit_blocks_with(path, content, block, replacement, false)
}
pub fn edit_blocks_with(
path: &str,
content: &str,
block: &[String],
replacement: &[String],
squeeze: bool,
) -> (String, usize, Vec<Site>) {
let segments: Vec<(&str, &str)> = content
.split_inclusive('\n')
.map(|seg| {
if let Some(b) = seg.strip_suffix("\r\n") {
(b, "\r\n")
} else if let Some(b) = seg.strip_suffix('\n') {
(b, "\n")
} else if let Some(b) = seg.strip_suffix('\r') {
(b, "\r")
} else {
(seg, "")
}
})
.collect();
let bodies: Vec<&str> = segments.iter().map(|(b, _)| *b).collect();
let default_nl = if content.contains("\r\n") {
"\r\n"
} else {
"\n"
};
let spans: Vec<(usize, usize)> = if squeeze {
find_spans_squeezed(&bodies, block)
} else {
find_starts(&bodies, block)
.into_iter()
.map(|s| (s, block.len()))
.collect()
};
if spans.is_empty() {
return (content.to_string(), 0, Vec::new());
}
let mut out = String::with_capacity(content.len());
let mut sites = Vec::new();
let mut next = spans.iter().peekable();
let mut i = 0usize;
while i < segments.len() {
if next.peek().is_some_and(|(s, _)| *s == i) {
let (_, span) = *next.next().unwrap();
let last_term = segments[i + span - 1].1;
let nl = segments[i..i + span]
.iter()
.map(|(_, t)| *t)
.find(|t| !t.is_empty())
.unwrap_or(default_nl);
for (r, rl) in replacement.iter().enumerate() {
out.push_str(rl);
out.push_str(if r + 1 == replacement.len() {
last_term
} else {
nl
});
}
let before = segments[i..i + span]
.iter()
.map(|(b, _)| *b)
.collect::<Vec<_>>()
.join("\n");
sites.push(Site {
path: path.to_string(),
line: i + 1,
before,
after: replacement.join("\n"),
});
i += span;
} else {
out.push_str(segments[i].0);
out.push_str(segments[i].1);
i += 1;
}
}
(out, spans.len(), sites)
}
#[cfg(test)]
mod tests {
use super::*;
fn block(lines: &[&str]) -> Vec<String> {
lines.iter().map(|s| s.to_string()).collect()
}
#[test]
fn matches_are_byte_exact_and_non_overlapping() {
let lines = ["a", "a", "a"];
assert_eq!(find_starts(&lines, &block(&["a", "a"])), vec![0]);
assert!(find_starts(&[" x"], &block(&["x"])).is_empty());
}
#[test]
fn nearest_miss_reports_first_divergence() {
let lines = ["fn a() {", " one();", " two();", "}"];
let b = block(&["fn a() {", " one();", " three();"]);
let m = nearest_miss(&lines, &b).unwrap();
assert_eq!(m.line, 1);
assert_eq!(m.first_diverging_line, 3);
assert_eq!(m.expected, " three();");
assert_eq!(m.found, " two();");
}
#[test]
fn nearest_miss_diagnoses_whitespace_drift_on_the_anchor_line() {
let lines = ["\tindented();"];
let b = block(&[" indented();"]);
let m = nearest_miss(&lines, &b).unwrap();
assert_eq!(m.line, 1);
assert_eq!(m.first_diverging_line, 1);
assert_eq!(m.found, "\tindented();");
}
#[test]
fn nearest_miss_past_eof_reports_empty_found() {
let lines = ["a"];
let b = block(&["a", "b"]);
let m = nearest_miss(&lines, &b).unwrap();
assert_eq!((m.line, m.first_diverging_line), (1, 2));
assert_eq!(m.found, "");
assert_eq!(m.block_len, 2);
}
#[test]
fn nearest_miss_carries_block_len_and_blank_line_hint() {
let lines = ["a", "fn x(", " body,"];
let b = block(&["a", "fn x(", ""]);
let m = nearest_miss(&lines, &b).unwrap();
assert_eq!(m.first_diverging_line, 3);
assert_eq!(m.block_len, 3);
assert_eq!(m.expected, "");
let hint = m
.blank_line_hint()
.expect("empty expected line yields a hint");
assert!(hint.contains("line 3 (of 3)"), "{hint}");
let b2 = block(&["a", "fn y("]);
let m2 = nearest_miss(&lines, &b2).unwrap();
assert!(m2.blank_line_hint().is_none());
}
#[test]
fn block_edit_preserves_missing_final_newline() {
let b = block(&["x"]);
let (out, n, _) = edit_blocks("f", "a\nx", &b, &block(&["y", "z"]));
assert_eq!(out, "a\ny\nz");
assert_eq!(n, 1);
}
#[test]
fn block_edit_matches_crlf_and_preserves_endings() {
let content = "struct Foo {\r\n a: u32,\r\n}\r\n\r\nfn keep() {}\r\n";
let find = block(&["struct Foo {", " a: u32,", "}"]);
let (out, n, sites) = edit_blocks("f", content, &find, &[]);
assert_eq!(n, 1);
assert_eq!(out, "\r\nfn keep() {}\r\n");
assert_eq!(sites[0].before, "struct Foo {\n a: u32,\n}");
let repl = block(&["struct Bar {", " b: u64,", "}"]);
let (out2, n2, _) = edit_blocks("f", content, &find, &repl);
assert_eq!(n2, 1);
assert_eq!(
out2,
"struct Bar {\r\n b: u64,\r\n}\r\n\r\nfn keep() {}\r\n"
);
}
#[test]
fn block_edit_preserves_crlf_missing_final_newline() {
let (out, n, _) = edit_blocks("f", "a\r\nx", &block(&["x"]), &block(&["y", "z"]));
assert_eq!(n, 1);
assert_eq!(out, "a\r\ny\r\nz");
}
#[test]
fn block_edit_replaces_multiple_sites() {
let b = block(&["x"]);
let (out, n, sites) = edit_blocks("f", "x\nm\nx\n", &b, &block(&["y"]));
assert_eq!(out, "y\nm\ny\n");
assert_eq!(n, 2);
assert_eq!(sites.iter().map(|s| s.line).collect::<Vec<_>>(), vec![1, 3]);
}
#[test]
fn squeeze_matches_blank_runs_of_any_length() {
let lines = ["foo()", "", "", "bar()"];
let b = block(&["foo()", "", "bar()"]);
assert!(find_starts(&lines, &b).is_empty());
assert_eq!(find_spans_squeezed(&lines, &b), vec![(0, 4)]);
let lines2 = ["foo()", "", "bar()"];
let b2 = block(&["foo()", "", "", "bar()"]);
assert_eq!(find_spans_squeezed(&lines2, &b2), vec![(0, 3)]);
let lines3 = ["a", " ", "\t", "b"];
let b3 = block(&["a", "", "b"]);
assert_eq!(find_spans_squeezed(&lines3, &b3), vec![(0, 4)]);
}
#[test]
fn squeeze_still_requires_at_least_one_blank_and_exact_nonblank() {
let lines = ["a", "b"];
let b = block(&["a", "", "b"]);
assert!(find_spans_squeezed(&lines, &b).is_empty());
let lines2 = ["a", "", "B"];
let b2 = block(&["a", "", "b"]);
assert!(find_spans_squeezed(&lines2, &b2).is_empty());
}
#[test]
fn squeeze_edit_replaces_the_full_source_span() {
let b = block(&["foo()", "", "bar()"]);
let repl = block(&["foo()", "", "bar()"]);
let (out, n, sites) = edit_blocks_with("f", "foo()\n\n\nbar()\nrest\n", &b, &repl, true);
assert_eq!(n, 1);
assert_eq!(out, "foo()\n\nbar()\nrest\n");
assert_eq!(sites[0].before, "foo()\n\n\nbar()");
}
#[test]
fn squeeze_nearest_miss_diverges_on_the_nonblank_line() {
let lines = ["foo()", "", "", "bar()"];
let b = block(&["foo()", "", "baz()"]);
let m = nearest_miss_with(&lines, &b, true).unwrap();
assert_eq!(m.first_diverging_line, 3);
assert_eq!(m.expected, "baz()");
assert_eq!(m.found, "bar()");
assert_eq!(m.line, 1);
}
}