use ropey::Rope;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Assoc {
Before,
After,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RangeEffect {
Untouched,
InsertedInside,
PartlyDeleted,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Operation {
Retain(usize),
Delete(usize),
Insert(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChangeSet {
ops: Vec<Operation>,
len_before: usize,
len_after: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ChangedSpan {
pub before_start: usize,
pub before_end: usize,
pub after_start: usize,
pub after_end: usize,
}
impl ChangeSet {
pub fn builder(len_before: usize) -> ChangeSetBuilder {
ChangeSetBuilder { ops: Vec::new(), len_before, consumed: 0 }
}
pub fn identity(len: usize) -> Self {
ChangeSet::builder(len).build()
}
pub fn replace(len_before: usize, from: usize, to: usize, text: impl Into<String>) -> Self {
let mut b = ChangeSet::builder(len_before);
b.retain(from).delete(to - from).insert(text);
b.build()
}
pub fn len_before(&self) -> usize {
self.len_before
}
pub fn len_after(&self) -> usize {
self.len_after
}
pub fn ops(&self) -> &[Operation] {
&self.ops
}
pub fn is_identity(&self) -> bool {
self.ops.iter().all(|op| matches!(op, Operation::Retain(_)))
}
pub fn changed_span(&self) -> Option<ChangedSpan> {
let mut before = 0;
let mut after = 0;
let mut start = None;
let mut end = (0, 0);
for op in &self.ops {
match op {
Operation::Retain(n) => {
before += n;
after += n;
}
Operation::Delete(n) => {
start.get_or_insert((before, after));
before += n;
end = (before, after);
}
Operation::Insert(text) => {
start.get_or_insert((before, after));
after += text.chars().count();
end = (before, after);
}
}
}
start.map(|(before_start, after_start)| ChangedSpan {
before_start,
before_end: end.0,
after_start,
after_end: end.1,
})
}
pub fn apply(&self, text: &Rope) -> Rope {
debug_assert_eq!(
text.len_chars(),
self.len_before,
"changeset applied to a document it was not authored against"
);
let mut out = Rope::new();
let mut pos = 0;
for op in &self.ops {
match op {
Operation::Retain(n) => {
out.append(Rope::from(text.slice(pos..pos + n)));
pos += n;
}
Operation::Delete(n) => pos += n,
Operation::Insert(s) => {
let end = out.len_chars();
out.insert(end, s);
}
}
}
out
}
pub fn invert(&self, original: &Rope) -> ChangeSet {
debug_assert_eq!(original.len_chars(), self.len_before, "invert needs the pre-image");
let mut b = ChangeSet::builder(self.len_after);
let mut pos = 0;
for op in &self.ops {
match op {
Operation::Retain(n) => {
b.retain(*n);
pos += n;
}
Operation::Delete(n) => {
b.insert(original.slice(pos..pos + n).to_string());
pos += n;
}
Operation::Insert(s) => {
b.delete(s.chars().count());
}
}
}
b.build()
}
pub fn map_pos(&self, pos: usize, assoc: Assoc) -> usize {
let mut old = 0;
let mut new = 0;
for op in &self.ops {
match op {
Operation::Retain(n) => {
if pos < old + n {
return new + (pos - old);
}
old += n;
new += n;
}
Operation::Delete(n) => {
if pos < old + n {
return new;
}
old += n;
}
Operation::Insert(s) => {
let len = s.chars().count();
if pos == old {
return match assoc {
Assoc::Before => new,
Assoc::After => new + len,
};
}
new += len;
}
}
}
new + pos.saturating_sub(old)
}
pub fn touches(&self, from: usize, to: usize) -> RangeEffect {
debug_assert!(from <= to, "range bounds reversed");
let mut old = 0;
let mut effect = RangeEffect::Untouched;
for op in &self.ops {
match op {
Operation::Retain(n) => old += n,
Operation::Delete(n) => {
let (start, end) = (old, old + n);
let overlaps = if from == to {
start < from && from < end
} else {
start < to && from < end
};
if overlaps {
return RangeEffect::PartlyDeleted;
}
old = end;
}
Operation::Insert(_) => {
if from < old && old < to {
effect = RangeEffect::InsertedInside;
}
}
}
}
effect
}
pub fn compose(&self, other: &ChangeSet) -> ChangeSet {
assert_eq!(
self.len_after, other.len_before,
"cannot compose: the second changeset was authored against a different document"
);
let mut b = ChangeSet::builder(self.len_before);
let mut a_iter = self.ops.iter().cloned();
let mut b_iter = other.ops.iter().cloned();
let mut a = a_iter.next();
let mut c = b_iter.next();
loop {
match (a.take(), c.take()) {
(None, None) => break,
(Some(Operation::Delete(n)), rest) => {
b.delete(n);
a = a_iter.next();
c = rest;
}
(rest, Some(Operation::Insert(s))) => {
b.insert(s);
a = rest;
c = b_iter.next();
}
(Some(Operation::Retain(i)), Some(Operation::Retain(j))) => {
let n = i.min(j);
b.retain(n);
a = carry(Operation::Retain(i - n), &mut a_iter);
c = carry(Operation::Retain(j - n), &mut b_iter);
}
(Some(Operation::Retain(i)), Some(Operation::Delete(j))) => {
let n = i.min(j);
b.delete(n);
a = carry(Operation::Retain(i - n), &mut a_iter);
c = carry(Operation::Delete(j - n), &mut b_iter);
}
(Some(Operation::Insert(s)), Some(Operation::Retain(j))) => {
let len = s.chars().count();
let n = len.min(j);
b.insert(take_chars(&s, n));
a = carry_insert(&s, n, &mut a_iter);
c = carry(Operation::Retain(j - n), &mut b_iter);
}
(Some(Operation::Insert(s)), Some(Operation::Delete(j))) => {
let len = s.chars().count();
let n = len.min(j);
a = carry_insert(&s, n, &mut a_iter);
c = carry(Operation::Delete(j - n), &mut b_iter);
}
(None, Some(op)) | (Some(op), None) => {
unreachable!("changeset length mismatch, stranded {op:?}")
}
}
}
b.build()
}
}
fn carry(op: Operation, iter: &mut impl Iterator<Item = Operation>) -> Option<Operation> {
match &op {
Operation::Retain(0) | Operation::Delete(0) => iter.next(),
_ => Some(op),
}
}
fn carry_insert(
s: &str,
consumed: usize,
iter: &mut impl Iterator<Item = Operation>,
) -> Option<Operation> {
let rest: String = s.chars().skip(consumed).collect();
if rest.is_empty() {
iter.next()
} else {
Some(Operation::Insert(rest))
}
}
fn take_chars(s: &str, n: usize) -> String {
s.chars().take(n).collect()
}
#[derive(Debug)]
pub struct ChangeSetBuilder {
ops: Vec<Operation>,
len_before: usize,
consumed: usize,
}
impl ChangeSetBuilder {
pub fn retain(&mut self, n: usize) -> &mut Self {
if n == 0 {
return self;
}
self.consumed += n;
if let Some(Operation::Retain(prev)) = self.ops.last_mut() {
*prev += n;
} else {
self.ops.push(Operation::Retain(n));
}
self
}
pub fn delete(&mut self, n: usize) -> &mut Self {
if n == 0 {
return self;
}
self.consumed += n;
let at = match self.ops.last() {
Some(Operation::Insert(_)) => self.ops.len() - 1,
_ => self.ops.len(),
};
if at > 0 {
if let Some(Operation::Delete(prev)) = self.ops.get_mut(at - 1) {
*prev += n;
return self;
}
}
self.ops.insert(at, Operation::Delete(n));
self
}
pub fn insert(&mut self, text: impl Into<String>) -> &mut Self {
let text = text.into();
if text.is_empty() {
return self;
}
if let Some(Operation::Insert(prev)) = self.ops.last_mut() {
prev.push_str(&text);
} else {
self.ops.push(Operation::Insert(text));
}
self
}
pub fn build(mut self) -> ChangeSet {
let tail = self
.len_before
.checked_sub(self.consumed)
.expect("changeset consumed more of the document than it has");
self.retain(tail);
let len_after = self
.ops
.iter()
.map(|op| match op {
Operation::Retain(n) => *n,
Operation::Delete(_) => 0,
Operation::Insert(s) => s.chars().count(),
})
.sum();
ChangeSet { ops: self.ops, len_before: self.len_before, len_after }
}
}
#[cfg(test)]
mod tests {
use super::*;
fn rope(s: &str) -> Rope {
Rope::from_str(s)
}
fn applied(text: &str, cs: &ChangeSet) -> String {
cs.apply(&rope(text)).to_string()
}
#[test]
fn identity_changes_nothing() {
let cs = ChangeSet::identity(5);
assert!(cs.is_identity());
assert_eq!(applied("hello", &cs), "hello");
assert_eq!(cs.len_after(), 5);
}
#[test]
fn replace_swaps_a_range() {
let cs = ChangeSet::replace(11, 6, 11, "there");
assert_eq!(applied("hello world", &cs), "hello there");
}
#[test]
fn insertion_and_deletion_report_their_output_length() {
let mut b = ChangeSet::builder(5);
b.retain(2).insert("XY").delete(3);
let cs = b.build();
assert_eq!(cs.len_before(), 5);
assert_eq!(cs.len_after(), 4); assert_eq!(applied("hello", &cs), "heXY");
}
#[test]
fn the_builder_merges_runs_and_orders_delete_before_insert() {
let mut b = ChangeSet::builder(10);
b.retain(1).retain(1).insert("a").insert("b").delete(2).delete(1);
let cs = b.build();
assert_eq!(
cs.ops(),
[
Operation::Retain(2),
Operation::Delete(3),
Operation::Insert("ab".into()),
Operation::Retain(5),
]
);
}
#[test]
fn zero_length_operations_are_dropped() {
let mut b = ChangeSet::builder(3);
b.retain(0).delete(0).insert("");
assert!(b.build().is_identity());
}
#[test]
fn an_insert_reports_a_zero_width_span_at_the_insert_point() {
let mut builder = ChangeSet::builder(5);
builder.retain(2).insert("xy").retain(3);
let changes = builder.build();
let span = changes.changed_span().expect("not identity");
assert_eq!((span.before_start, span.before_end), (2, 2));
assert_eq!((span.after_start, span.after_end), (2, 4));
}
#[test]
fn a_delete_reports_the_removed_span_and_an_empty_replacement() {
let mut builder = ChangeSet::builder(5);
builder.retain(1).delete(2).retain(2);
let changes = builder.build();
let span = changes.changed_span().expect("not identity");
assert_eq!((span.before_start, span.before_end), (1, 3));
assert_eq!((span.after_start, span.after_end), (1, 1));
}
#[test]
fn a_replace_reports_both_spans() {
let changes = ChangeSet::replace(5, 1, 3, "abc");
let span = changes.changed_span().expect("not identity");
assert_eq!((span.before_start, span.before_end), (1, 3));
assert_eq!((span.after_start, span.after_end), (1, 4));
}
#[test]
fn several_edits_collapse_into_one_covering_span() {
let mut builder = ChangeSet::builder(9);
builder.retain(1).delete(1).retain(3).insert("zz").retain(4);
let changes = builder.build();
let span = changes.changed_span().expect("not identity");
assert_eq!(span.before_start, 1);
assert_eq!(span.before_end, 5);
}
#[test]
fn an_identity_changeset_has_no_span() {
assert!(ChangeSet::identity(4).changed_span().is_none());
}
#[test]
fn positions_are_chars_not_bytes() {
let cs = ChangeSet::replace(5, 1, 2, "e");
assert_eq!(applied("héllo", &cs), "hello");
}
#[test]
fn multibyte_text_survives_insertion_and_inversion() {
let original = rope("naïve");
let cs = ChangeSet::replace(5, 0, 0, "très ");
let changed = cs.apply(&original);
assert_eq!(changed.to_string(), "très naïve");
assert_eq!(cs.invert(&original).apply(&changed).to_string(), "naïve");
}
#[test]
fn invert_round_trips_every_edit_shape() {
for (text, cs) in [
("hello world", ChangeSet::replace(11, 6, 11, "there")), ("hello", ChangeSet::replace(5, 5, 5, "!")), ("hello", ChangeSet::replace(5, 0, 2, "")), ("hello", ChangeSet::identity(5)), ] {
let original = rope(text);
let changed = cs.apply(&original);
let undone = cs.invert(&original).apply(&changed);
assert_eq!(undone.to_string(), text, "round-trip failed for {cs:?}");
}
}
#[test]
fn invert_of_invert_is_the_original_change() {
let original = rope("hello world");
let cs = ChangeSet::replace(11, 0, 5, "goodbye");
let changed = cs.apply(&original);
let back = cs.invert(&original);
let forward = back.invert(&changed);
assert_eq!(forward.apply(&original).to_string(), changed.to_string());
}
#[test]
fn compose_matches_applying_both_in_order() {
let original = rope("hello world");
let first = ChangeSet::replace(11, 0, 5, "goodbye"); let mid = first.apply(&original);
let second = ChangeSet::replace(mid.len_chars(), 8, 13, "everyone");
let composed = first.compose(&second);
assert_eq!(composed.apply(&original).to_string(), "goodbye everyone");
assert_eq!(composed.len_before(), 11);
assert_eq!(composed.len_after(), "goodbye everyone".chars().count());
}
#[test]
fn compose_drops_text_that_was_inserted_then_deleted() {
let original = rope("ac");
let first = ChangeSet::replace(2, 1, 1, "b"); let second = ChangeSet::replace(3, 1, 2, "");
let composed = first.compose(&second);
assert_eq!(composed.apply(&original).to_string(), "ac");
assert!(composed.is_identity(), "the round trip should compose away entirely");
}
#[test]
fn compose_is_associative() {
let original = rope("abcdef");
let a = ChangeSet::replace(6, 0, 1, "X"); let b = ChangeSet::replace(6, 2, 3, "Y"); let c = ChangeSet::replace(6, 4, 5, "Z");
let left = a.compose(&b).compose(&c);
let right = a.compose(&b.compose(&c));
assert_eq!(left.apply(&original).to_string(), right.apply(&original).to_string());
assert_eq!(left, right, "composition should be associative on the nose");
}
#[test]
fn compose_with_identity_is_a_no_op() {
let cs = ChangeSet::replace(5, 1, 3, "XY");
assert_eq!(ChangeSet::identity(5).compose(&cs), cs);
assert_eq!(cs.compose(&ChangeSet::identity(cs.len_after())), cs);
}
#[test]
fn typing_one_character_at_a_time_composes_into_one_change() {
let original = rope("() {}");
let mut composed = ChangeSet::identity(original.len_chars());
let mut text = original.clone();
for (i, ch) in "abc".chars().enumerate() {
let cs = ChangeSet::replace(text.len_chars(), 1 + i, 1 + i, ch.to_string());
text = cs.apply(&text);
composed = composed.compose(&cs);
}
assert_eq!(text.to_string(), "(abc) {}");
assert_eq!(composed.apply(&original).to_string(), "(abc) {}");
}
#[test]
#[should_panic(expected = "authored against a different document")]
fn composing_mismatched_changesets_is_a_programming_error() {
let a = ChangeSet::identity(5);
let b = ChangeSet::identity(9);
let _ = a.compose(&b);
}
#[test]
fn positions_before_a_change_are_untouched() {
let cs = ChangeSet::replace(11, 6, 11, "there");
assert_eq!(cs.map_pos(3, Assoc::After), 3);
}
#[test]
fn positions_after_an_insertion_shift_by_its_length() {
let cs = ChangeSet::replace(11, 0, 0, "abc");
assert_eq!(cs.map_pos(5, Assoc::After), 8);
}
#[test]
fn assoc_decides_only_at_the_insertion_point() {
let cs = ChangeSet::replace(10, 4, 4, "XY");
assert_eq!(cs.map_pos(4, Assoc::Before), 4, "Before stays put");
assert_eq!(cs.map_pos(4, Assoc::After), 6, "After moves past the insert");
assert_eq!(cs.map_pos(3, Assoc::Before), cs.map_pos(3, Assoc::After));
assert_eq!(cs.map_pos(5, Assoc::Before), cs.map_pos(5, Assoc::After));
}
#[test]
fn positions_inside_a_deleted_range_collapse_to_its_start() {
let cs = ChangeSet::replace(10, 2, 6, "");
for pos in 2..6 {
assert_eq!(cs.map_pos(pos, Assoc::After), 2, "pos {pos} should collapse");
}
assert_eq!(cs.map_pos(6, Assoc::After), 2, "the end of the range lands there too");
assert_eq!(cs.map_pos(7, Assoc::After), 3, "past it, positions shift back");
}
#[test]
fn the_end_of_the_document_maps_to_the_new_end() {
let cs = ChangeSet::replace(5, 2, 5, "XY");
assert_eq!(cs.map_pos(5, Assoc::After), cs.len_after());
}
#[test]
fn an_edit_outside_the_range_leaves_it_untouched() {
let hunk = (10, 20);
for elsewhere in [
ChangeSet::replace(40, 0, 5, "x"), ChangeSet::replace(40, 25, 30, "x"), ] {
assert_eq!(elsewhere.touches(hunk.0, hunk.1), RangeEffect::Untouched);
}
}
#[test]
fn an_insertion_inside_the_range_is_detected() {
let typed_inside = ChangeSet::replace(40, 15, 15, "hello");
assert_eq!(typed_inside.touches(10, 20), RangeEffect::InsertedInside);
}
#[test]
fn an_insertion_at_either_boundary_is_not_inside() {
for at in [10, 20] {
let cs = ChangeSet::replace(40, at, at, "x");
assert_eq!(
cs.touches(10, 20),
RangeEffect::Untouched,
"insertion at {at} is a boundary case, resolved by Assoc"
);
}
}
#[test]
fn a_deletion_overlapping_the_range_is_detected() {
for (from, to) in [
(5, 15), (15, 25), (12, 18), (5, 25), (10, 20), ] {
let cs = ChangeSet::replace(40, from, to, "");
assert_eq!(
cs.touches(10, 20),
RangeEffect::PartlyDeleted,
"deletion {from}..{to} should be seen"
);
}
}
#[test]
fn a_deletion_ending_or_starting_at_the_boundary_does_not_overlap() {
for (from, to) in [(5, 10), (20, 25)] {
let cs = ChangeSet::replace(40, from, to, "");
assert_eq!(
cs.touches(10, 20),
RangeEffect::Untouched,
"deletion {from}..{to} only touches the boundary"
);
}
}
#[test]
fn a_zero_width_anchor_survives_a_deletion_that_merely_starts_there() {
let cs = ChangeSet::replace(40, 10, 15, "");
assert_eq!(cs.touches(10, 10), RangeEffect::Untouched, "the anchor still stands");
let containing = ChangeSet::replace(40, 8, 15, "");
assert_eq!(containing.touches(10, 10), RangeEffect::PartlyDeleted, "swallowed");
}
#[test]
fn deletion_outranks_insertion_when_a_change_does_both() {
let cs = ChangeSet::replace(40, 12, 18, "replacement");
assert_eq!(cs.touches(10, 20), RangeEffect::PartlyDeleted);
}
#[test]
fn an_untouched_range_is_exactly_what_map_pos_can_be_trusted_for() {
let cs = ChangeSet::replace(40, 0, 5, "xx");
assert_eq!(cs.touches(10, 20), RangeEffect::Untouched);
assert_eq!((cs.map_pos(10, Assoc::After), cs.map_pos(20, Assoc::After)), (7, 17));
}
#[test]
fn a_pending_anchor_rides_forward_over_keystrokes_at_the_same_offset() {
let original = rope("0123456789tail");
let mut text = original.clone();
let mut anchor = 10;
let mut composed = ChangeSet::identity(original.len_chars());
for ch in "abc".chars() {
let cs = ChangeSet::replace(text.len_chars(), anchor, anchor, ch.to_string());
anchor = cs.map_pos(anchor, Assoc::After);
composed = composed.compose(&cs);
text = cs.apply(&text);
}
assert_eq!(text.to_string(), "0123456789abctail");
assert_eq!(anchor, 13, "the hunk sits after the typed text, not inside it");
assert_eq!(
composed.map_pos(10, Assoc::After),
anchor,
"per-keystroke and batched rebasing must agree"
);
}
}