use std::ops::Range;
use ropey::Rope;
use similar::{ChangeTag, TextDiff};
use crate::markdown::parse_offsets::{block_ranges_by, BlockKind};
use super::hunk::{Decision, Hunk, HunkId, HunkKind, InlineSide, InlineSpan};
#[derive(Debug, Default, Clone)]
pub struct HunkIdAllocator {
next: u64,
}
impl HunkIdAllocator {
pub fn new() -> Self {
Self { next: 0 }
}
pub fn allocate(&mut self) -> HunkId {
let id = HunkId(self.next);
self.next = self.next.wrapping_add(1);
id
}
}
#[derive(Debug, Default)]
pub struct HunkComputation {
pub hunks: Vec<Hunk>,
pub uneven_table_fallback: bool,
}
pub fn compute(old_text: &str, new_text: &str, ids: &mut HunkIdAllocator) -> HunkComputation {
let diff = TextDiff::from_lines(old_text, new_text);
let mut hunks: Vec<Hunk> = Vec::new();
let mut old_start: Option<usize> = None;
let mut new_start: Option<usize> = None;
let mut old_end: usize = 0;
let mut new_end: usize = 0;
let mut last_old_seen: usize = 0;
let mut last_new_seen: usize = 0;
let flush = |old_s: &mut Option<usize>,
new_s: &mut Option<usize>,
old_e: &mut usize,
new_e: &mut usize,
hunks: &mut Vec<Hunk>,
ids: &mut HunkIdAllocator| {
if old_s.is_none() && new_s.is_none() {
return;
}
let old_lines = old_s.unwrap_or(*old_e)..*old_e;
let new_lines = new_s.unwrap_or(*new_e)..*new_e;
if old_lines.start == old_lines.end && new_lines.start == new_lines.end {
*old_s = None;
*new_s = None;
return;
}
let kind = Hunk::classify(&old_lines, &new_lines);
hunks.push(Hunk {
id: ids.allocate(),
old_lines,
new_lines,
inline: Vec::new(),
kind,
});
*old_s = None;
*new_s = None;
};
for change in diff.iter_all_changes() {
match change.tag() {
ChangeTag::Equal => {
flush(
&mut old_start,
&mut new_start,
&mut old_end,
&mut new_end,
&mut hunks,
ids,
);
if let Some(i) = change.old_index() {
last_old_seen = i + 1;
}
if let Some(i) = change.new_index() {
last_new_seen = i + 1;
}
}
ChangeTag::Delete => {
if old_start.is_none() {
old_start = Some(change.old_index().unwrap_or(last_old_seen));
old_end = old_start.unwrap();
}
if new_start.is_none() {
new_end = last_new_seen;
}
old_end = change.old_index().map(|i| i + 1).unwrap_or(old_end + 1);
last_old_seen = old_end;
}
ChangeTag::Insert => {
if new_start.is_none() {
new_start = Some(change.new_index().unwrap_or(last_new_seen));
new_end = new_start.unwrap();
}
if old_start.is_none() {
old_end = last_old_seen;
}
new_end = change.new_index().map(|i| i + 1).unwrap_or(new_end + 1);
last_new_seen = new_end;
}
}
}
flush(
&mut old_start,
&mut new_start,
&mut old_end,
&mut new_end,
&mut hunks,
ids,
);
let old_index = LineIndex::new(old_text);
let new_index = LineIndex::new(new_text);
for h in &mut hunks {
if h.kind == HunkKind::Replace {
populate_inline_spans(h, &old_index, &new_index);
}
}
let old_table_extents = table_line_extents(&old_index);
let new_table_extents = table_line_extents(&new_index);
let meta: Vec<(Option<usize>, Option<usize>)> = hunks
.iter()
.map(|h| {
(
find_extent_idx(&old_table_extents, &h.old_lines),
find_extent_idx(&new_table_extents, &h.new_lines),
)
})
.collect();
let mut ni_map = vec![NiMap::Unset; old_table_extents.len()];
for &(oi, ni) in &meta {
if let (Some(oi), Some(ni)) = (oi, ni) {
ni_map[oi] = match ni_map[oi] {
NiMap::Unset => NiMap::One(ni),
NiMap::One(prev) if prev == ni => NiMap::One(prev),
_ => NiMap::Conflict,
};
}
}
let mut split: Vec<Hunk> = Vec::with_capacity(hunks.len());
let mut split_done = vec![false; old_table_extents.len()];
let mut uneven_table_fallback = false;
for (h, &(old_idx, new_idx)) in hunks.into_iter().zip(meta.iter()) {
let (Some(oi), Some(ni)) = (old_idx, new_idx) else {
split.push(h);
continue;
};
if !matches!(ni_map[oi], NiMap::One(_)) {
split.push(h);
continue;
}
if split_done[oi] {
continue;
}
match split_table_hunk(
&old_table_extents[oi],
&new_table_extents[ni],
&old_index,
&new_index,
ids,
) {
SplitOutcome::Rows(rows) => {
split_done[oi] = true;
split.extend(rows);
}
SplitOutcome::Uneven => {
uneven_table_fallback = true;
split.push(h);
}
SplitOutcome::Degenerate => {
split.push(h);
}
}
}
HunkComputation {
hunks: split,
uneven_table_fallback,
}
}
#[derive(Clone, Copy)]
enum NiMap {
Unset,
One(usize),
Conflict,
}
enum SplitOutcome {
Rows(Vec<Hunk>),
Uneven,
Degenerate,
}
fn populate_inline_spans(hunk: &mut Hunk, old_index: &LineIndex, new_index: &LineIndex) {
let old_lines = old_index.slice(hunk.old_lines.clone());
let new_lines = new_index.slice(hunk.new_lines.clone());
let pair_count = old_lines.len().min(new_lines.len());
let mut spans = Vec::new();
for i in 0..pair_count {
let old_line = old_lines[i];
let new_line = new_lines[i];
if old_line == new_line {
continue;
}
let word_diff = TextDiff::from_words(old_line, new_line);
let mut old_pos = 0usize;
let mut new_pos = 0usize;
for change in word_diff.iter_all_changes() {
let text = change.value();
let char_count = text.chars().count();
match change.tag() {
ChangeTag::Equal => {
old_pos += char_count;
new_pos += char_count;
}
ChangeTag::Delete => {
if char_count > 0 {
spans.push(InlineSpan {
side: InlineSide::Old,
line_in_hunk: i,
chars: old_pos..old_pos + char_count,
});
}
old_pos += char_count;
}
ChangeTag::Insert => {
if char_count > 0 {
spans.push(InlineSpan {
side: InlineSide::New,
line_in_hunk: i,
chars: new_pos..new_pos + char_count,
});
}
new_pos += char_count;
}
}
}
}
hunk.inline = spans;
}
struct LineIndex<'a> {
text: &'a str,
starts: Vec<usize>,
}
impl<'a> LineIndex<'a> {
fn new(text: &'a str) -> Self {
let mut starts = vec![0usize];
for (i, b) in text.bytes().enumerate() {
if b == b'\n' {
starts.push(i + 1);
}
}
Self { text, starts }
}
fn len_lines(&self) -> usize {
self.starts.len()
}
fn byte_to_line(&self, byte: usize) -> usize {
match self.starts.binary_search(&byte) {
Ok(i) => i,
Err(i) => i - 1,
}
}
fn slice(&self, range: Range<usize>) -> Vec<&'a str> {
if range.start >= range.end {
return Vec::new();
}
let total = self.len_lines();
let end = range.end.min(total);
let start = range.start.min(end);
let mut out = Vec::with_capacity(end - start);
for i in start..end {
let line_start = self.starts[i];
let line_end = if i + 1 < total {
self.starts[i + 1]
} else {
self.text.len()
};
let raw = &self.text[line_start..line_end];
out.push(raw.strip_suffix('\n').unwrap_or(raw));
}
out
}
}
#[derive(Debug, Clone)]
struct TableExtent {
lines: Range<usize>,
}
fn table_line_extents(index: &LineIndex) -> Vec<TableExtent> {
block_ranges_by(index.text, |kind| kind == BlockKind::Table)
.into_iter()
.map(|r| TableExtent {
lines: index.byte_to_line(r.start)..index.byte_to_line(r.end),
})
.collect()
}
fn split_table_hunk(
old_extent: &TableExtent,
new_extent: &TableExtent,
old_index: &LineIndex,
new_index: &LineIndex,
ids: &mut HunkIdAllocator,
) -> SplitOutcome {
let old_rows = old_index.slice(old_extent.lines.clone());
let new_rows = new_index.slice(new_extent.lines.clone());
if !table_rows_uniform(&old_rows, &new_rows) {
return SplitOutcome::Uneven;
}
let old_joined: String = old_rows.iter().map(|s| format!("{s}\n")).collect();
let new_joined: String = new_rows.iter().map(|s| format!("{s}\n")).collect();
let row_diff = TextDiff::from_lines(&old_joined, &new_joined);
let mut hunks: Vec<Hunk> = Vec::new();
let mut run_old_start: Option<usize> = None;
let mut run_new_start: Option<usize> = None;
let mut run_old_end: usize = 0;
let mut run_new_end: usize = 0;
let mut last_old: usize = 0;
let mut last_new: usize = 0;
let emit = |run_old_s: &mut Option<usize>,
run_new_s: &mut Option<usize>,
run_old_e: &mut usize,
run_new_e: &mut usize,
hunks: &mut Vec<Hunk>,
ids: &mut HunkIdAllocator| {
if run_old_s.is_none() && run_new_s.is_none() {
return;
}
let o = run_old_s.unwrap_or(*run_old_e)..*run_old_e;
let n = run_new_s.unwrap_or(*run_new_e)..*run_new_e;
if o.start == o.end && n.start == n.end {
*run_old_s = None;
*run_new_s = None;
return;
}
let kind = Hunk::classify(&o, &n);
hunks.push(Hunk {
id: ids.allocate(),
old_lines: (old_extent.lines.start + o.start)..(old_extent.lines.start + o.end),
new_lines: (new_extent.lines.start + n.start)..(new_extent.lines.start + n.end),
inline: Vec::new(),
kind,
});
*run_old_s = None;
*run_new_s = None;
};
for change in row_diff.iter_all_changes() {
match change.tag() {
ChangeTag::Equal => {
emit(
&mut run_old_start,
&mut run_new_start,
&mut run_old_end,
&mut run_new_end,
&mut hunks,
ids,
);
if let Some(i) = change.old_index() {
last_old = i + 1;
}
if let Some(i) = change.new_index() {
last_new = i + 1;
}
}
ChangeTag::Delete => {
if run_old_start.is_none() {
run_old_start = Some(change.old_index().unwrap_or(last_old));
run_old_end = run_old_start.unwrap();
}
if run_new_start.is_none() {
run_new_end = last_new;
}
run_old_end = change.old_index().map(|i| i + 1).unwrap_or(run_old_end + 1);
last_old = run_old_end;
}
ChangeTag::Insert => {
if run_new_start.is_none() {
run_new_start = Some(change.new_index().unwrap_or(last_new));
run_new_end = run_new_start.unwrap();
}
if run_old_start.is_none() {
run_old_end = last_old;
}
run_new_end = change.new_index().map(|i| i + 1).unwrap_or(run_new_end + 1);
last_new = run_new_end;
}
}
}
emit(
&mut run_old_start,
&mut run_new_start,
&mut run_old_end,
&mut run_new_end,
&mut hunks,
ids,
);
for h in &mut hunks {
if h.kind == HunkKind::Replace {
populate_inline_spans(h, old_index, new_index);
}
}
if hunks.is_empty() {
return SplitOutcome::Degenerate;
}
SplitOutcome::Rows(hunks)
}
fn find_extent_idx(extents: &[TableExtent], lines: &Range<usize>) -> Option<usize> {
extents
.iter()
.position(|e| lines.start >= e.lines.start && lines.end <= e.lines.end)
}
fn table_rows_uniform(old_rows: &[&str], new_rows: &[&str]) -> bool {
if old_rows.is_empty() || new_rows.is_empty() {
return false;
}
fn cell_count(row: &str) -> Option<usize> {
let trimmed = row.trim();
if !trimmed.starts_with('|') {
return None;
}
let mut pipes = 0usize;
let mut chars = trimmed.chars().peekable();
while let Some(c) = chars.next() {
if c == '\\' {
let _ = chars.next();
continue;
}
if c == '|' {
pipes += 1;
}
}
let cells = if trimmed.ends_with('|') {
pipes.saturating_sub(1)
} else {
pipes
};
Some(cells)
}
let mut max_old = 0usize;
for row in old_rows {
let Some(c) = cell_count(row) else {
return false;
};
if max_old == 0 {
max_old = c;
} else if c != max_old {
return false;
}
}
let mut max_new = 0usize;
for row in new_rows {
let Some(c) = cell_count(row) else {
return false;
};
if max_new == 0 {
max_new = c;
} else if c != max_new {
return false;
}
}
max_old == max_new
}
pub fn pending_decisions(hunks: &[Hunk]) -> Vec<Decision> {
vec![Decision::Pending; hunks.len()]
}
fn old_range_overlap(a: &Range<usize>, b: &Range<usize>) -> usize {
let start = a.start.max(b.start);
let end = a.end.min(b.end);
end.saturating_sub(start)
}
pub fn match_by_old_overlap(hunk: &Hunk, priors: &[Hunk]) -> Option<usize> {
let cand = &hunk.old_lines;
let cand_empty = cand.start == cand.end;
let mut best: Option<(usize, usize, usize)> = None; for (i, p) in priors.iter().enumerate() {
let po = &p.old_lines;
let overlap = old_range_overlap(cand, po);
let score = if overlap > 0 {
overlap
} else if cand_empty && po.start == po.end && po.start == cand.start {
0
} else {
continue;
};
let better = match best {
None => true,
Some((best_score, best_start, _)) => {
score > best_score || (score == best_score && po.start < best_start)
}
};
if better {
best = Some((score, po.start, i));
}
}
best.map(|(_, _, idx)| idx)
}
pub fn hunk_new_side_text(hunk: &Hunk, rope: &Rope) -> String {
let mut out = String::new();
let total = rope.len_lines();
for line_idx in hunk.new_lines.clone() {
if line_idx < total {
out.push_str(&rope.line(line_idx).to_string());
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn ids() -> HunkIdAllocator {
HunkIdAllocator::new()
}
fn compute_hunks(old: &str, new: &str, ids: &mut HunkIdAllocator) -> Vec<Hunk> {
compute(old, new, ids).hunks
}
#[test]
fn identical_inputs_produce_no_hunks() {
let s = "a\nb\nc\n";
let mut a = ids();
assert!(compute_hunks(s, s, &mut a).is_empty());
}
#[test]
fn pure_insert_produces_one_insert_hunk() {
let old = "a\nb\n";
let new = "a\nb\nc\n";
let mut a = ids();
let hunks = compute_hunks(old, new, &mut a);
assert_eq!(hunks.len(), 1);
assert_eq!(hunks[0].kind, HunkKind::Insert);
assert_eq!(hunks[0].old_lines, 2..2);
assert_eq!(hunks[0].new_lines, 2..3);
}
#[test]
fn pure_delete_produces_one_delete_hunk() {
let old = "a\nb\nc\n";
let new = "a\nc\n";
let mut a = ids();
let hunks = compute_hunks(old, new, &mut a);
assert_eq!(hunks.len(), 1);
assert_eq!(hunks[0].kind, HunkKind::Delete);
assert_eq!(hunks[0].old_lines, 1..2);
}
#[test]
fn replace_emits_inline_spans_on_paired_lines() {
let old = "alpha bravo\n";
let new = "alpha gamma\n";
let mut a = ids();
let hunks = compute_hunks(old, new, &mut a);
assert_eq!(hunks.len(), 1);
assert_eq!(hunks[0].kind, HunkKind::Replace);
assert!(!hunks[0].inline.is_empty());
}
fn hunk_with_old(old_lines: Range<usize>) -> Hunk {
Hunk {
id: HunkId(0),
old_lines: old_lines.clone(),
new_lines: 0..0,
inline: Vec::new(),
kind: Hunk::classify(&old_lines, &(0..0)),
}
}
#[test]
fn match_by_old_overlap_picks_largest_overlap_and_breaks_ties() {
let priors = vec![hunk_with_old(0..3), hunk_with_old(5..7)];
let cand = hunk_with_old(1..2);
assert_eq!(match_by_old_overlap(&cand, &priors), Some(0));
let priors = vec![hunk_with_old(0..4), hunk_with_old(2..6)];
let cand = hunk_with_old(2..4); assert_eq!(match_by_old_overlap(&cand, &priors), Some(0));
let priors = vec![hunk_with_old(0..2)];
assert_eq!(match_by_old_overlap(&hunk_with_old(10..12), &priors), None);
assert_eq!(match_by_old_overlap(&hunk_with_old(1..1), &priors), None);
}
#[test]
fn match_by_old_overlap_anchors_inserts_by_position() {
let priors = vec![hunk_with_old(1..1), hunk_with_old(3..3)];
assert_eq!(match_by_old_overlap(&hunk_with_old(3..3), &priors), Some(1));
assert_eq!(match_by_old_overlap(&hunk_with_old(1..1), &priors), Some(0));
assert_eq!(match_by_old_overlap(&hunk_with_old(5..5), &priors), None);
let priors = vec![hunk_with_old(2..2), hunk_with_old(1..4)];
assert_eq!(match_by_old_overlap(&hunk_with_old(2..3), &priors), Some(1));
}
#[test]
fn ids_are_unique_and_monotonic() {
let old = "a\nb\nc\n";
let new = "x\nb\ny\n";
let mut a = ids();
let hunks = compute_hunks(old, new, &mut a);
let mut seen = std::collections::HashSet::new();
for h in &hunks {
assert!(seen.insert(h.id), "duplicate id");
}
}
}