use std::path::PathBuf;
use similar::{DiffTag, TextDiff};
use termesh_core::ProposalId;
use termesh_editor::{ChangeSet, ConflictReason, HunkState, Version};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Hunk {
pub start: usize,
pub end: usize,
pub text: String,
pub state: HunkState,
}
impl Hunk {
pub fn new(start: usize, end: usize, text: impl Into<String>) -> Self {
Self { start, end, text: text.into(), state: HunkState::Clean }
}
pub fn is_insertion(&self) -> bool {
self.start == self.end
}
pub fn is_deletion(&self) -> bool {
self.text.is_empty()
}
}
#[derive(Debug, Clone)]
pub struct EditProposal {
pub id: ProposalId,
pub path: PathBuf,
pub base_version: Option<Version>,
pub base_text: String,
pub proposed_text: String,
pub hunks: Vec<Hunk>,
}
impl EditProposal {
pub fn new(
id: ProposalId,
path: PathBuf,
base_version: Option<Version>,
base_text: String,
proposed_text: String,
current_text: &str,
) -> Self {
let mut proposal =
Self { id, path, base_version, base_text, proposed_text, hunks: Vec::new() };
proposal.refresh(current_text);
proposal
}
pub fn refresh(&mut self, current_text: &str) {
self.hunks = hunks_from_diff(&self.base_text, &self.proposed_text);
rebase_hunks(&mut self.hunks, &self.base_text, current_text);
}
pub fn applicable(&self) -> impl Iterator<Item = &Hunk> {
self.hunks.iter().filter(|h| h.state.is_applicable())
}
pub fn has_conflicts(&self) -> bool {
self.hunks.iter().any(|h| matches!(h.state, HunkState::Conflicted(_)))
}
pub fn is_settled(&self) -> bool {
self.hunks.iter().all(|h| h.state == HunkState::Satisfied)
}
}
fn line_offsets(text: &str) -> Vec<usize> {
let mut offsets = Vec::new();
let mut acc = 0;
for line in text.split_inclusive('\n') {
offsets.push(acc);
acc += line.chars().count();
}
offsets.push(acc);
offsets
}
pub fn hunks_from_diff(old: &str, new: &str) -> Vec<Hunk> {
let diff = TextDiff::from_lines(old, new);
let offsets = line_offsets(old);
let new_lines: Vec<&str> = new.split_inclusive('\n').collect();
diff.ops()
.iter()
.filter_map(|op| {
let (tag, old_range, new_range) = op.as_tag_tuple();
if tag == DiffTag::Equal {
return None;
}
Some(Hunk::new(
offsets[old_range.start],
offsets[old_range.end],
new_lines[new_range].concat(),
))
})
.collect()
}
pub fn changeset_from_hunks(hunks: &[&Hunk], len_before: usize) -> ChangeSet {
let mut ordered: Vec<&&Hunk> = hunks.iter().collect();
ordered.sort_by_key(|h| (h.start, h.end));
let mut builder = ChangeSet::builder(len_before);
let mut at = 0;
for hunk in ordered {
debug_assert!(hunk.start >= at, "hunks overlap: {at} > {}", hunk.start);
builder.retain(hunk.start.saturating_sub(at));
builder.delete(hunk.end - hunk.start);
builder.insert(hunk.text.clone());
at = hunk.end;
}
builder.build()
}
pub fn rebase_hunks(hunks: &mut [Hunk], base_text: &str, current_text: &str) {
if base_text == current_text {
return; }
let human = hunks_from_diff(base_text, current_text);
let catchup =
changeset_from_hunks(&human.iter().collect::<Vec<_>>(), base_text.chars().count());
for hunk in hunks.iter_mut() {
if human.iter().any(|h| h.start == hunk.start && h.end == hunk.end && h.text == hunk.text) {
hunk.state = HunkState::Satisfied;
continue;
}
match ConflictReason::from_effect(catchup.touches(hunk.start, hunk.end)) {
Some(reason) => hunk.state = HunkState::Conflicted(reason),
None => {
hunk.start = catchup.map_pos(hunk.start, termesh_editor::Assoc::After);
hunk.end = catchup.map_pos(hunk.end, termesh_editor::Assoc::After);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn texts(hunks: &[Hunk]) -> Vec<(usize, usize, &str)> {
hunks.iter().map(|h| (h.start, h.end, h.text.as_str())).collect()
}
fn apply(base: &str, hunks: &[Hunk]) -> String {
let clean: Vec<&Hunk> = hunks.iter().filter(|h| h.state.is_applicable()).collect();
let cs = changeset_from_hunks(&clean, base.chars().count());
cs.apply(&ropey::Rope::from_str(base)).to_string()
}
#[test]
fn an_unchanged_file_yields_no_hunks() {
assert!(hunks_from_diff("a\nb\n", "a\nb\n").is_empty());
}
#[test]
fn a_changed_line_becomes_one_hunk_over_its_char_range() {
let old = "one\ntwo\nthree\n";
let hunks = hunks_from_diff(old, "one\nTWO\nthree\n");
assert_eq!(texts(&hunks), [(4, 8, "TWO\n")]);
assert_eq!(apply(old, &hunks), "one\nTWO\nthree\n");
}
#[test]
fn an_inserted_line_is_a_zero_width_hunk() {
let old = "one\nthree\n";
let hunks = hunks_from_diff(old, "one\ntwo\nthree\n");
assert_eq!(hunks.len(), 1);
assert!(hunks[0].is_insertion(), "nothing is replaced, so the range is empty");
assert_eq!(apply(old, &hunks), "one\ntwo\nthree\n");
}
#[test]
fn a_deleted_line_is_a_hunk_with_no_replacement() {
let old = "one\ntwo\nthree\n";
let hunks = hunks_from_diff(old, "one\nthree\n");
assert_eq!(hunks.len(), 1);
assert!(hunks[0].is_deletion());
assert_eq!(apply(old, &hunks), "one\nthree\n");
}
#[test]
fn separate_edits_become_separate_hunks() {
let old = "one\ntwo\nthree\nfour\n";
let hunks = hunks_from_diff(old, "ONE\ntwo\nthree\nFOUR\n");
assert_eq!(hunks.len(), 2);
assert_eq!(apply(old, &hunks), "ONE\ntwo\nthree\nFOUR\n");
}
#[test]
fn accepting_only_one_hunk_leaves_the_other_alone() {
let old = "one\ntwo\nthree\nfour\n";
let hunks = hunks_from_diff(old, "ONE\ntwo\nthree\nFOUR\n");
let cs = changeset_from_hunks(&[&hunks[0]], old.chars().count());
assert_eq!(cs.apply(&ropey::Rope::from_str(old)).to_string(), "ONE\ntwo\nthree\nfour\n");
}
#[test]
fn creating_a_file_from_nothing_is_one_insertion() {
let hunks = hunks_from_diff("", "hello\n");
assert_eq!(texts(&hunks), [(0, 0, "hello\n")]);
assert_eq!(apply("", &hunks), "hello\n");
}
#[test]
fn a_file_without_a_trailing_newline_round_trips() {
let old = "one\ntwo";
let hunks = hunks_from_diff(old, "one\nTWO");
assert_eq!(apply(old, &hunks), "one\nTWO");
}
#[test]
fn multibyte_lines_produce_char_offsets_not_byte_offsets() {
let old = "héllo\nwörld\n";
let hunks = hunks_from_diff(old, "héllo\nWORLD\n");
assert_eq!(hunks[0].start, 6, "6 chars, not 7 bytes");
assert_eq!(apply(old, &hunks), "héllo\nWORLD\n");
}
#[test]
fn an_untouched_proposal_needs_no_rebasing() {
let base = "one\ntwo\n";
let mut hunks = hunks_from_diff(base, "one\nTWO\n");
let before = hunks.clone();
rebase_hunks(&mut hunks, base, base);
assert_eq!(hunks, before);
}
#[test]
fn a_hunk_rides_over_an_edit_made_above_it() {
let base = "one\ntwo\nthree\n";
let current = "zero\none\ntwo\nthree\n"; let mut hunks = hunks_from_diff(base, "one\ntwo\nTHREE\n");
rebase_hunks(&mut hunks, base, current);
assert_eq!(hunks[0].state, HunkState::Clean);
assert_eq!(apply(current, &hunks), "zero\none\ntwo\nTHREE\n");
}
#[test]
fn a_hunk_the_human_edited_inside_conflicts() {
let base = "one\ntwo\nthree\n";
let current = "one\ntwo EDITED\nthree\n";
let mut hunks = hunks_from_diff(base, "one\nTWO\nthree\n");
rebase_hunks(&mut hunks, base, current);
assert!(matches!(hunks[0].state, HunkState::Conflicted(_)), "got {:?}", hunks[0].state);
assert_eq!(apply(current, &hunks), current, "and nothing is applied");
}
#[test]
fn a_change_the_human_already_made_resolves_itself() {
let base = "one\ntwo\nthree\n";
let current = "one\nTWO\nthree\n"; let mut hunks = hunks_from_diff(base, "one\nTWO\nthree\n");
rebase_hunks(&mut hunks, base, current);
assert_eq!(hunks[0].state, HunkState::Satisfied, "not a conflict — already done");
assert_eq!(apply(current, &hunks), current, "and applying it would not duplicate it");
}
#[test]
fn common_replacement_text_elsewhere_does_not_count_as_already_done() {
let base = "fn a() {\n}\nfn b() {\n x\n}\n";
let current = "fn a() {\n}\nfn b() {\n}\n";
let mut hunks = hunks_from_diff(base, "fn a() {\n}\nfn b() {\n x\n y\n}\n");
rebase_hunks(&mut hunks, base, current);
assert!(
hunks.iter().all(|h| h.state != HunkState::Satisfied),
"a coincidental match must not swallow the change: {:?}",
hunks.iter().map(|h| h.state).collect::<Vec<_>>()
);
}
#[test]
fn a_larger_human_edit_covering_the_same_change_conflicts_rather_than_settling() {
let base = "one\ntwo\nthree\n";
let current = "one\nTWO\nTHREE\n";
let mut hunks = hunks_from_diff(base, "one\nTWO\nthree\n");
rebase_hunks(&mut hunks, base, current);
assert!(
matches!(hunks[0].state, HunkState::Conflicted(_)),
"not an identical change, so it needs a human decision"
);
}
#[test]
fn one_conflicted_hunk_does_not_invalidate_its_siblings() {
let base = "one\ntwo\nthree\nfour\n";
let current = "one\ntwo EDITED\nthree\nfour\n";
let mut hunks = hunks_from_diff(base, "one\nTWO\nthree\nFOUR\n");
assert_eq!(hunks.len(), 2);
rebase_hunks(&mut hunks, base, current);
assert!(matches!(hunks[0].state, HunkState::Conflicted(_)));
assert_eq!(hunks[1].state, HunkState::Clean, "the unrelated change still applies");
assert_eq!(apply(current, &hunks), "one\ntwo EDITED\nthree\nFOUR\n");
}
#[test]
fn a_hunk_whose_lines_were_deleted_conflicts() {
let base = "one\ntwo\nthree\n";
let current = "one\nthree\n"; let mut hunks = hunks_from_diff(base, "one\nTWO\nthree\n");
rebase_hunks(&mut hunks, base, current);
assert!(matches!(hunks[0].state, HunkState::Conflicted(_)));
}
#[test]
fn a_proposal_reports_whether_anything_is_left_to_review() {
let base = "one\n";
let mut proposal = EditProposal::new(
ProposalId::new(1),
PathBuf::from("/proj/a.rs"),
Some(Version(3)),
base.into(),
"ONE\n".into(),
base,
);
assert!(!proposal.is_settled());
assert!(!proposal.has_conflicts());
assert_eq!(proposal.applicable().count(), 1);
proposal.hunks[0].state = HunkState::Satisfied;
assert!(proposal.is_settled());
assert_eq!(proposal.applicable().count(), 0);
}
#[test]
fn refreshing_is_idempotent_and_derived_from_the_original() {
let base = "one\ntwo\nthree\n";
let mut proposal = EditProposal::new(
ProposalId::new(1),
PathBuf::from("/a"),
None,
base.into(),
"one\nTWO\nthree\n".into(),
base,
);
assert_eq!(proposal.hunks[0].state, HunkState::Clean);
proposal.refresh("one\ntwo EDITED\nthree\n");
let conflicted = proposal.hunks.clone();
assert!(matches!(conflicted[0].state, HunkState::Conflicted(_)));
proposal.refresh("one\ntwo EDITED\nthree\n");
assert_eq!(proposal.hunks, conflicted, "refresh must be idempotent");
proposal.refresh(base);
assert_eq!(proposal.hunks[0].state, HunkState::Clean, "a conflict is not permanent");
}
}