use similar::{ChangeTag, TextDiff};
#[derive(Debug, Clone)]
pub struct AnchorMap {
mapping: Vec<Option<u32>>,
}
impl AnchorMap {
#[must_use]
pub fn from_content(old: &str, new: &str) -> Self {
let diff = TextDiff::from_lines(old, new);
let old_line_count = if old.is_empty() {
0
} else {
count_lines(old)
};
let mut mapping: Vec<Option<u32>> = vec![None; old_line_count];
let mut old_ln: u32 = 1;
let mut new_ln: u32 = 1;
for change in diff.iter_all_changes() {
match change.tag() {
ChangeTag::Equal => {
if let Some(slot) = mapping.get_mut((old_ln as usize).saturating_sub(1)) {
*slot = Some(new_ln);
}
old_ln = old_ln.saturating_add(1);
new_ln = new_ln.saturating_add(1);
}
ChangeTag::Delete => {
if let Some(slot) = mapping.get_mut((old_ln as usize).saturating_sub(1)) {
*slot = None;
}
old_ln = old_ln.saturating_add(1);
}
ChangeTag::Insert => {
new_ln = new_ln.saturating_add(1);
}
}
}
Self { mapping }
}
#[must_use]
pub fn lookup(&self, old_line: u32) -> Option<u32> {
if old_line == 0 {
return None;
}
self.mapping.get((old_line as usize) - 1).copied().flatten()
}
#[must_use]
pub fn is_identity(&self) -> bool {
self.mapping
.iter()
.enumerate()
.all(|(i, slot)| *slot == Some((i as u32) + 1))
}
}
fn count_lines(s: &str) -> usize {
if s.is_empty() {
return 0;
}
let mut n = s.matches('\n').count();
if !s.ends_with('\n') {
n += 1;
}
n
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn identity_when_content_unchanged() {
let text = "one\ntwo\nthree\n";
let map = AnchorMap::from_content(text, text);
assert!(map.is_identity());
assert_eq!(map.lookup(1), Some(1));
assert_eq!(map.lookup(2), Some(2));
assert_eq!(map.lookup(3), Some(3));
}
#[test]
fn shift_down_when_line_inserted_above() {
let old = "alpha\nbeta\ngamma\n";
let new = "zero\nalpha\nbeta\ngamma\n";
let map = AnchorMap::from_content(old, new);
assert!(!map.is_identity());
assert_eq!(map.lookup(1), Some(2), "alpha shifted from 1 -> 2");
assert_eq!(map.lookup(2), Some(3), "beta shifted from 2 -> 3");
assert_eq!(map.lookup(3), Some(4), "gamma shifted from 3 -> 4");
}
#[test]
fn shift_up_when_line_deleted_above() {
let old = "zero\nalpha\nbeta\ngamma\n";
let new = "alpha\nbeta\ngamma\n";
let map = AnchorMap::from_content(old, new);
assert_eq!(map.lookup(1), None, "zero was deleted");
assert_eq!(map.lookup(2), Some(1));
assert_eq!(map.lookup(3), Some(2));
assert_eq!(map.lookup(4), Some(3));
}
#[test]
fn orphan_when_anchored_line_itself_deleted() {
let old = "keep\nremove me\nkeep-too\n";
let new = "keep\nkeep-too\n";
let map = AnchorMap::from_content(old, new);
assert_eq!(map.lookup(1), Some(1));
assert_eq!(map.lookup(2), None, "'remove me' was deleted");
assert_eq!(map.lookup(3), Some(2));
}
#[test]
fn orphan_with_last_seen_content_preserved() {
let old = "alpha\nbeta\ngamma\n";
let new = "alpha\ngamma\n";
let old_lines: Vec<&str> = old.lines().collect();
let map = AnchorMap::from_content(old, new);
struct FakeComment {
old_line: u32,
last_seen: String,
new_line: Option<u32>,
}
let mut comments: Vec<FakeComment> = (1..=3u32)
.map(|ln| FakeComment {
old_line: ln,
last_seen: old_lines[(ln - 1) as usize].to_string(),
new_line: None,
})
.collect();
for c in &mut comments {
c.new_line = map.lookup(c.old_line);
}
assert_eq!(comments[0].new_line, Some(1)); assert_eq!(
comments[1].new_line, None,
"beta was deleted, comment 1 should orphan"
);
assert_eq!(
comments[1].last_seen, "beta",
"orphaned comment remembers its source line"
);
assert_eq!(comments[2].new_line, Some(2)); }
#[test]
fn empty_new_file_orphans_everything() {
let old = "alpha\nbeta\ngamma\n";
let new = "";
let map = AnchorMap::from_content(old, new);
for ln in 1..=3u32 {
assert_eq!(map.lookup(ln), None, "line {ln} should orphan");
}
}
#[test]
fn empty_old_file_has_nothing_to_anchor() {
let old = "";
let new = "alpha\nbeta\n";
let map = AnchorMap::from_content(old, new);
assert_eq!(map.lookup(1), None);
assert!(map.is_identity(), "empty mapping is vacuously identity");
}
#[test]
fn identical_nonempty_content_is_identity() {
let text = "hello world\n";
let map = AnchorMap::from_content(text, text);
assert!(map.is_identity());
assert_eq!(map.lookup(1), Some(1));
}
#[test]
fn lookup_past_end_returns_none() {
let old = "alpha\nbeta\n";
let new = "alpha\nbeta\n";
let map = AnchorMap::from_content(old, new);
assert_eq!(map.lookup(0), None);
assert_eq!(map.lookup(99), None);
}
#[test]
fn insert_then_delete_keeps_surviving_lines_aligned() {
let old = "top\nold-middle\nbottom\n";
let new = "top\nnew-middle\nbottom\n";
let map = AnchorMap::from_content(old, new);
assert_eq!(map.lookup(1), Some(1));
assert_eq!(
map.lookup(2),
None,
"old-middle is treated as deleted, comment should orphan"
);
assert_eq!(map.lookup(3), Some(3));
}
}