use crate::myers::{DiffChunk, DiffTag};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum GutterHit {
CopyLeftToRight,
CopyRightToLeft,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ChunkMapRect {
pub y_start: f64,
pub height: f64,
pub tag: DiffTag,
pub conflict: bool,
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Side {
A,
B,
}
#[must_use]
pub fn find_next_chunk(
chunks: &[DiffChunk],
cursor_line: usize,
direction: i32,
side: Side,
wrap: bool,
) -> Option<usize> {
let start_line = |i: usize| -> usize {
if side == Side::A {
chunks[i].start_a
} else {
chunks[i].start_b
}
};
let non_equal_iter = || {
chunks
.iter()
.enumerate()
.filter(|(_, c)| c.tag != DiffTag::Equal)
.map(|(i, _)| i)
};
if direction > 0 {
let mut first = None;
let mut found = None;
for i in non_equal_iter() {
if first.is_none() {
first = Some(i);
}
if start_line(i) > cursor_line {
found = Some(i);
break;
}
}
found.or(if wrap { first } else { None })
} else {
let mut last = None;
let mut found = None;
for i in non_equal_iter() {
if start_line(i) < cursor_line {
found = Some(i);
}
last = Some(i);
}
found.or(if wrap { last } else { None })
}
}
#[must_use]
pub fn format_chunk_label(chunks: &[DiffChunk], current: Option<usize>) -> String {
let mut total = 0usize;
let mut position = None;
for (i, c) in chunks.iter().enumerate() {
if c.tag != DiffTag::Equal {
if current == Some(i) {
position = Some(total);
}
total += 1;
}
}
if total == 0 {
return "No changes".to_string();
}
match position {
Some(pos) => format!("Change {} of {}", pos + 1, total),
None => format!("{total} {}", if total == 1 { "change" } else { "changes" }),
}
}
#[must_use]
pub(super) fn chunk_nav_sensitivity(
chunks: &[DiffChunk],
cursor_line: usize,
side: Side,
wrap: bool,
) -> (bool, bool) {
let has_non_equal = chunks.iter().any(|c| c.tag != DiffTag::Equal);
if !has_non_equal {
return (false, false);
}
if wrap {
return (true, true);
}
let prev = find_next_chunk(chunks, cursor_line, -1, side, false).is_some();
let next = find_next_chunk(chunks, cursor_line, 1, side, false).is_some();
(prev, next)
}
#[must_use]
pub(super) fn chunk_at_cursor(
chunks: &[DiffChunk],
cursor_line: usize,
side: Side,
) -> Option<usize> {
chunks.iter().enumerate().find_map(|(i, c)| {
if c.tag == DiffTag::Equal {
return None;
}
let (start, end) = if side == Side::A {
(c.start_a, c.end_a)
} else {
(c.start_b, c.end_b)
};
if cursor_line >= start && cursor_line < end {
Some(i)
} else {
None
}
})
}
#[must_use]
#[allow(dead_code)]
pub(super) fn find_all_matches(text: &str, needle: &str) -> Vec<(usize, usize)> {
if needle.is_empty() {
return Vec::new();
}
let lower_text = text.to_lowercase();
let lower_needle = needle.to_lowercase();
let mut results = Vec::new();
let mut start = 0;
while let Some(pos) = lower_text[start..].find(&lower_needle) {
let byte_start = start + pos;
let byte_end = byte_start + lower_needle.len();
results.push((byte_start, byte_end));
start = byte_start + 1;
}
results
}
#[must_use]
pub(super) fn hit_test_gutter_arrow(
x: f64,
y: f64,
width: f64,
left_mid: f64,
right_mid: f64,
) -> Option<GutterHit> {
const HIT_RADIUS_SQ: f64 = 144.0;
let left_dx = x - 2.0;
let left_dy = y - left_mid;
if left_dx * left_dx + left_dy * left_dy < HIT_RADIUS_SQ {
return Some(GutterHit::CopyLeftToRight);
}
let right_dx = x - (width - 2.0);
let right_dy = y - right_mid;
if right_dx * right_dx + right_dy * right_dy < HIT_RADIUS_SQ {
return Some(GutterHit::CopyRightToLeft);
}
None
}
#[must_use]
pub fn compute_chunk_map_rects(
chunks: &[DiffChunk],
total_lines: usize,
map_height: f64,
side: Side,
conflict_flags: &[bool],
) -> Vec<ChunkMapRect> {
if total_lines == 0 || map_height <= 0.0 {
return Vec::new();
}
let total = total_lines as f64;
chunks
.iter()
.enumerate()
.filter(|(_, c)| c.tag != DiffTag::Equal)
.map(|(i, c)| {
let (start, end) = if side == Side::A {
(c.start_a, c.end_a)
} else {
(c.start_b, c.end_b)
};
let y_start = (start as f64 / total) * map_height;
let raw_height = ((end - start) as f64 / total) * map_height;
ChunkMapRect {
y_start,
height: raw_height.max(2.0),
tag: c.tag,
conflict: conflict_flags.get(i).copied().unwrap_or(false),
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::myers::{DiffChunk, DiffTag};
fn eq(start_a: usize, end_a: usize, start_b: usize, end_b: usize) -> DiffChunk {
DiffChunk {
tag: DiffTag::Equal,
start_a,
end_a,
start_b,
end_b,
}
}
fn rep(start_a: usize, end_a: usize, start_b: usize, end_b: usize) -> DiffChunk {
DiffChunk {
tag: DiffTag::Replace,
start_a,
end_a,
start_b,
end_b,
}
}
fn del(start_a: usize, end_a: usize, start_b: usize, end_b: usize) -> DiffChunk {
DiffChunk {
tag: DiffTag::Delete,
start_a,
end_a,
start_b,
end_b,
}
}
fn ins(start_a: usize, end_a: usize, start_b: usize, end_b: usize) -> DiffChunk {
DiffChunk {
tag: DiffTag::Insert,
start_a,
end_a,
start_b,
end_b,
}
}
#[test]
fn find_next_empty_chunks() {
assert_eq!(find_next_chunk(&[], 0, 1, Side::A, true), None);
assert_eq!(find_next_chunk(&[], 0, -1, Side::A, true), None);
}
#[test]
fn find_next_all_equal() {
let chunks = [eq(0, 5, 0, 5), eq(5, 10, 5, 10)];
assert_eq!(find_next_chunk(&chunks, 0, 1, Side::A, true), None);
assert_eq!(find_next_chunk(&chunks, 0, -1, Side::A, true), None);
}
#[test]
fn find_next_forward() {
let chunks = [eq(0, 3, 0, 3), rep(3, 5, 3, 6), eq(5, 10, 6, 11)];
assert_eq!(find_next_chunk(&chunks, 0, 1, Side::A, true), Some(1));
}
#[test]
fn find_next_backward() {
let chunks = [eq(0, 3, 0, 3), rep(3, 5, 3, 6), eq(5, 10, 6, 11)];
assert_eq!(find_next_chunk(&chunks, 7, -1, Side::A, true), Some(1));
}
#[test]
fn find_next_forward_wraps() {
let chunks = [eq(0, 3, 0, 3), rep(3, 5, 3, 6), eq(5, 10, 6, 11)];
assert_eq!(find_next_chunk(&chunks, 5, 1, Side::A, true), Some(1));
}
#[test]
fn find_next_backward_wraps() {
let chunks = [eq(0, 3, 0, 3), rep(3, 5, 3, 6), eq(5, 10, 6, 11)];
assert_eq!(find_next_chunk(&chunks, 2, -1, Side::A, true), Some(1));
}
#[test]
fn find_next_forward_no_wrap() {
let chunks = [eq(0, 3, 0, 3), rep(3, 5, 3, 6), eq(5, 10, 6, 11)];
assert_eq!(find_next_chunk(&chunks, 5, 1, Side::A, false), None);
}
#[test]
fn find_next_backward_no_wrap() {
let chunks = [eq(0, 3, 0, 3), rep(3, 5, 3, 6), eq(5, 10, 6, 11)];
assert_eq!(find_next_chunk(&chunks, 2, -1, Side::A, false), None);
}
#[test]
fn find_next_side_b() {
let chunks = [eq(0, 3, 0, 5), rep(3, 5, 5, 8), eq(5, 10, 8, 13)];
assert_eq!(find_next_chunk(&chunks, 4, 1, Side::B, true), Some(1));
assert_eq!(find_next_chunk(&chunks, 6, -1, Side::B, true), Some(1));
}
#[test]
fn find_next_multiple_changes_forward() {
let chunks = [
eq(0, 2, 0, 2),
del(2, 4, 2, 2),
eq(4, 6, 2, 4),
ins(6, 6, 4, 7),
eq(6, 10, 7, 11),
];
assert_eq!(find_next_chunk(&chunks, 3, 1, Side::A, true), Some(3));
assert_eq!(find_next_chunk(&chunks, 0, 1, Side::A, true), Some(1));
}
#[test]
fn find_next_multiple_changes_backward() {
let chunks = [
eq(0, 2, 0, 2),
del(2, 4, 2, 2),
eq(4, 6, 2, 4),
ins(6, 6, 4, 7),
eq(6, 10, 7, 11),
];
assert_eq!(find_next_chunk(&chunks, 8, -1, Side::A, true), Some(3));
assert_eq!(find_next_chunk(&chunks, 5, -1, Side::A, true), Some(1));
}
#[test]
fn find_next_cursor_on_change_start() {
let chunks = [eq(0, 3, 0, 3), rep(3, 5, 3, 6), eq(5, 10, 6, 11)];
assert_eq!(find_next_chunk(&chunks, 3, 1, Side::A, true), Some(1));
assert_eq!(find_next_chunk(&chunks, 3, -1, Side::A, true), Some(1));
}
#[test]
fn sensitivity_no_chunks() {
assert_eq!(
chunk_nav_sensitivity(&[], 0, Side::A, false),
(false, false)
);
assert_eq!(chunk_nav_sensitivity(&[], 0, Side::A, true), (false, false));
}
#[test]
fn sensitivity_all_equal() {
let chunks = [eq(0, 5, 0, 5), eq(5, 10, 5, 10)];
assert_eq!(
chunk_nav_sensitivity(&chunks, 0, Side::A, false),
(false, false)
);
}
#[test]
fn sensitivity_wrap_always_true() {
let chunks = [eq(0, 3, 0, 3), rep(3, 5, 3, 6), eq(5, 10, 6, 11)];
assert_eq!(
chunk_nav_sensitivity(&chunks, 0, Side::A, true),
(true, true)
);
assert_eq!(
chunk_nav_sensitivity(&chunks, 99, Side::A, true),
(true, true)
);
}
#[test]
fn sensitivity_at_first_chunk() {
let chunks = [eq(0, 3, 0, 3), rep(3, 5, 3, 6), eq(5, 10, 6, 11)];
assert_eq!(
chunk_nav_sensitivity(&chunks, 3, Side::A, false),
(false, false)
);
}
#[test]
fn sensitivity_before_change() {
let chunks = [eq(0, 3, 0, 3), rep(3, 5, 3, 6), eq(5, 10, 6, 11)];
assert_eq!(
chunk_nav_sensitivity(&chunks, 0, Side::A, false),
(false, true)
);
}
#[test]
fn sensitivity_after_change() {
let chunks = [eq(0, 3, 0, 3), rep(3, 5, 3, 6), eq(5, 10, 6, 11)];
assert_eq!(
chunk_nav_sensitivity(&chunks, 7, Side::A, false),
(true, false)
);
}
#[test]
fn sensitivity_between_changes() {
let chunks = [
eq(0, 2, 0, 2),
del(2, 4, 2, 2),
eq(4, 6, 2, 4),
ins(6, 6, 4, 7),
eq(6, 10, 7, 11),
];
assert_eq!(
chunk_nav_sensitivity(&chunks, 5, Side::A, false),
(true, true)
);
}
#[test]
fn chunk_at_cursor_empty() {
assert_eq!(chunk_at_cursor(&[], 0, Side::A), None);
}
#[test]
fn chunk_at_cursor_in_equal() {
let chunks = [eq(0, 5, 0, 5), rep(5, 8, 5, 7), eq(8, 10, 7, 9)];
assert_eq!(chunk_at_cursor(&chunks, 2, Side::A), None);
}
#[test]
fn chunk_at_cursor_in_change_side_a() {
let chunks = [eq(0, 5, 0, 5), rep(5, 8, 5, 7), eq(8, 10, 7, 9)];
assert_eq!(chunk_at_cursor(&chunks, 5, Side::A), Some(1));
assert_eq!(chunk_at_cursor(&chunks, 7, Side::A), Some(1));
assert_eq!(chunk_at_cursor(&chunks, 8, Side::A), None);
}
#[test]
fn chunk_at_cursor_in_change_side_b() {
let chunks = [eq(0, 5, 0, 5), rep(5, 8, 5, 7), eq(8, 10, 7, 9)];
assert_eq!(chunk_at_cursor(&chunks, 5, Side::B), Some(1));
assert_eq!(chunk_at_cursor(&chunks, 6, Side::B), Some(1));
assert_eq!(chunk_at_cursor(&chunks, 7, Side::B), None);
}
#[test]
fn chunk_at_cursor_insert_zero_range() {
let chunks = [ins(5, 5, 5, 8)];
assert_eq!(chunk_at_cursor(&chunks, 5, Side::A), None);
assert_eq!(chunk_at_cursor(&chunks, 5, Side::B), Some(0));
}
#[test]
fn label_no_changes() {
let chunks = [eq(0, 5, 0, 5)];
assert_eq!(format_chunk_label(&chunks, None), "No changes");
}
#[test]
fn label_no_changes_empty() {
assert_eq!(format_chunk_label(&[], None), "No changes");
}
#[test]
fn label_n_changes_no_current() {
let chunks = [eq(0, 3, 0, 3), rep(3, 5, 3, 6), eq(5, 10, 6, 11)];
assert_eq!(format_chunk_label(&chunks, None), "1 change");
}
#[test]
fn label_n_changes_current_is_equal() {
let chunks = [eq(0, 3, 0, 3), rep(3, 5, 3, 6), eq(5, 10, 6, 11)];
assert_eq!(format_chunk_label(&chunks, Some(0)), "1 change");
}
#[test]
fn label_change_x_of_y() {
let chunks = [
eq(0, 2, 0, 2),
rep(2, 4, 2, 5),
eq(4, 6, 5, 7),
del(6, 8, 7, 7),
eq(8, 10, 7, 9),
];
assert_eq!(format_chunk_label(&chunks, Some(1)), "Change 1 of 2");
assert_eq!(format_chunk_label(&chunks, Some(3)), "Change 2 of 2");
}
#[test]
fn label_invalid_current_index() {
let chunks = [eq(0, 3, 0, 3), rep(3, 5, 3, 6), eq(5, 10, 6, 11)];
assert_eq!(format_chunk_label(&chunks, Some(99)), "1 change");
}
#[test]
fn find_matches_empty_needle() {
assert_eq!(
find_all_matches("hello world", ""),
Vec::<(usize, usize)>::new()
);
}
#[test]
fn find_matches_no_match() {
assert_eq!(
find_all_matches("hello world", "xyz"),
Vec::<(usize, usize)>::new()
);
}
#[test]
fn find_matches_single() {
assert_eq!(find_all_matches("hello world", "world"), vec![(6, 11)]);
}
#[test]
fn find_matches_multiple() {
assert_eq!(find_all_matches("abcabc", "abc"), vec![(0, 3), (3, 6)]);
}
#[test]
fn find_matches_case_insensitive() {
assert_eq!(
find_all_matches("Hello HELLO hElLo", "hello"),
vec![(0, 5), (6, 11), (12, 17)]
);
}
#[test]
fn find_matches_overlapping() {
assert_eq!(find_all_matches("aaa", "aa"), vec![(0, 2), (1, 3)]);
}
#[test]
fn find_matches_empty_text() {
assert_eq!(find_all_matches("", "abc"), Vec::<(usize, usize)>::new());
}
#[test]
fn find_matches_needle_longer_than_text() {
assert_eq!(find_all_matches("ab", "abcd"), Vec::<(usize, usize)>::new());
}
#[test]
fn gutter_hit_left_arrow() {
assert_eq!(
hit_test_gutter_arrow(3.0, 50.0, 100.0, 50.0, 80.0),
Some(GutterHit::CopyLeftToRight)
);
}
#[test]
fn gutter_hit_right_arrow() {
assert_eq!(
hit_test_gutter_arrow(97.0, 80.0, 100.0, 50.0, 80.0),
Some(GutterHit::CopyRightToLeft)
);
}
#[test]
fn gutter_miss_center() {
assert_eq!(hit_test_gutter_arrow(50.0, 50.0, 100.0, 50.0, 50.0), None);
}
#[test]
fn gutter_miss_outside_radius() {
assert_eq!(hit_test_gutter_arrow(15.0, 50.0, 100.0, 50.0, 80.0), None);
}
#[test]
fn gutter_hit_exactly_at_boundary() {
assert_eq!(hit_test_gutter_arrow(14.0, 50.0, 100.0, 50.0, 80.0), None);
}
#[test]
fn gutter_hit_just_inside_radius() {
assert_eq!(
hit_test_gutter_arrow(13.9, 50.0, 100.0, 50.0, 80.0),
Some(GutterHit::CopyLeftToRight)
);
}
#[test]
fn chunk_map_empty_chunks() {
assert_eq!(
compute_chunk_map_rects(&[], 100, 500.0, Side::A, &[]),
Vec::<ChunkMapRect>::new()
);
}
#[test]
fn chunk_map_zero_total_lines() {
let chunks = [rep(0, 5, 0, 3)];
assert_eq!(
compute_chunk_map_rects(&chunks, 0, 500.0, Side::A, &[]),
Vec::<ChunkMapRect>::new()
);
}
#[test]
fn chunk_map_zero_map_height() {
let chunks = [rep(0, 5, 0, 3)];
assert_eq!(
compute_chunk_map_rects(&chunks, 100, 0.0, Side::A, &[]),
Vec::<ChunkMapRect>::new()
);
}
#[test]
fn chunk_map_negative_map_height() {
let chunks = [rep(0, 5, 0, 3)];
assert_eq!(
compute_chunk_map_rects(&chunks, 100, -10.0, Side::A, &[]),
Vec::<ChunkMapRect>::new()
);
}
#[test]
fn chunk_map_skips_equal() {
let chunks = [eq(0, 10, 0, 10)];
assert_eq!(
compute_chunk_map_rects(&chunks, 10, 100.0, Side::A, &[]),
Vec::<ChunkMapRect>::new()
);
}
#[test]
fn chunk_map_basic_left() {
let chunks = [eq(0, 10, 0, 10), rep(10, 20, 10, 15), eq(20, 100, 15, 85)];
let rects = compute_chunk_map_rects(&chunks, 100, 1000.0, Side::A, &[]);
assert_eq!(rects.len(), 1);
assert!((rects[0].y_start - 100.0).abs() < f64::EPSILON);
assert!((rects[0].height - 100.0).abs() < f64::EPSILON);
assert_eq!(rects[0].tag, DiffTag::Replace);
}
#[test]
fn chunk_map_basic_right() {
let chunks = [eq(0, 10, 0, 10), rep(10, 20, 10, 15), eq(20, 100, 15, 85)];
let rects = compute_chunk_map_rects(&chunks, 85, 850.0, Side::B, &[]);
assert_eq!(rects.len(), 1);
assert!((rects[0].y_start - 100.0).abs() < f64::EPSILON);
assert!((rects[0].height - 50.0).abs() < f64::EPSILON);
assert_eq!(rects[0].tag, DiffTag::Replace);
}
#[test]
fn chunk_map_minimum_height_clamp() {
let chunks = [rep(500, 501, 500, 501)];
let rects = compute_chunk_map_rects(&chunks, 10000, 100.0, Side::A, &[]);
assert_eq!(rects.len(), 1);
assert!((rects[0].height - 2.0).abs() < f64::EPSILON);
}
#[test]
fn chunk_map_multiple_non_equal() {
let chunks = [
eq(0, 5, 0, 5),
del(5, 8, 5, 5),
eq(8, 15, 5, 12),
ins(15, 15, 12, 14),
eq(15, 20, 14, 19),
];
let rects = compute_chunk_map_rects(&chunks, 20, 200.0, Side::A, &[]);
assert_eq!(rects.len(), 2);
assert_eq!(rects[0].tag, DiffTag::Delete);
assert_eq!(rects[1].tag, DiffTag::Insert);
assert!((rects[0].y_start - 50.0).abs() < f64::EPSILON);
assert!((rects[0].height - 30.0).abs() < f64::EPSILON);
assert!((rects[1].y_start - 150.0).abs() < f64::EPSILON);
assert!((rects[1].height - 2.0).abs() < f64::EPSILON);
}
mod proptests {
use super::*;
use proptest::prelude::*;
fn arb_tag() -> impl Strategy<Value = DiffTag> {
prop_oneof![
Just(DiffTag::Equal),
Just(DiffTag::Replace),
Just(DiffTag::Insert),
Just(DiffTag::Delete),
]
}
fn arb_chunks() -> impl Strategy<Value = Vec<DiffChunk>> {
prop::collection::vec(
(
arb_tag(),
0..1000_usize,
0..1000_usize,
0..1000_usize,
0..1000_usize,
)
.prop_map(|(tag, a, b, c, d)| DiffChunk {
tag,
start_a: a.min(b),
end_a: a.max(b),
start_b: c.min(d),
end_b: c.max(d),
}),
1..20,
)
}
proptest! {
#[test]
fn nav_forward_returns_valid_non_equal(
chunks in arb_chunks(),
cursor in 0..2000_usize,
) {
if let Some(idx) = find_next_chunk(&chunks, cursor, 1, Side::A, true) {
prop_assert!(idx < chunks.len());
prop_assert_ne!(chunks[idx].tag, DiffTag::Equal);
}
}
#[test]
fn nav_backward_returns_valid_non_equal(
chunks in arb_chunks(),
cursor in 0..2000_usize,
) {
if let Some(idx) = find_next_chunk(&chunks, cursor, -1, Side::A, true) {
prop_assert!(idx < chunks.len());
prop_assert_ne!(chunks[idx].tag, DiffTag::Equal);
}
}
#[test]
fn nav_returns_none_iff_all_equal(
chunks in arb_chunks(),
cursor in 0..2000_usize,
) {
let has_non_equal = chunks.iter().any(|c| c.tag != DiffTag::Equal);
let result = find_next_chunk(&chunks, cursor, 1, Side::A, true);
prop_assert_eq!(result.is_some(), has_non_equal);
}
}
}
}