Move Side to EditedText from Operation

This commit is contained in:
Andras Schmelczer 2025-10-26 20:23:22 +00:00
parent 8a52034426
commit de89532880
6 changed files with 119 additions and 55 deletions

View file

@ -3,13 +3,10 @@ mod operation;
mod utils; mod utils;
use std::fmt::Debug; use std::fmt::Debug;
pub use edited_text::EditedText; pub use edited_text::{ChangeSet, EditedText};
pub use operation::Operation; pub use operation::Operation;
use crate::{ use crate::{Tokenizer, types::text_with_cursors::TextWithCursors};
Tokenizer,
types::{side::Side, text_with_cursors::TextWithCursors},
};
/// Given an `original` document and two concurrent edits to it, /// Given an `original` document and two concurrent edits to it,
/// return a document containing all changes from both `left` /// return a document containing all changes from both `left`
@ -48,10 +45,8 @@ pub fn reconcile<'a, T>(
where where
T: PartialEq + Clone + Debug, T: PartialEq + Clone + Debug,
{ {
let left_operations = let left_operations = EditedText::from_strings_with_tokenizer(original, left, tokenizer);
EditedText::from_strings_with_tokenizer(original, left, tokenizer, Side::Left); let right_operations = EditedText::from_strings_with_tokenizer(original, right, tokenizer);
let right_operations =
EditedText::from_strings_with_tokenizer(original, right, tokenizer, Side::Right);
left_operations.merge(right_operations) left_operations.merge(right_operations)
} }

View file

@ -1,4 +1,4 @@
use std::fmt::Debug; use std::{fmt::Debug, vec};
#[cfg(feature = "serde")] #[cfg(feature = "serde")]
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -35,9 +35,35 @@ where
{ {
text: &'a str, text: &'a str,
operations: Vec<Operation<T>>, operations: Vec<Operation<T>>,
operation_sides: Vec<Side>,
cursors: Vec<CursorPosition>, cursors: Vec<CursorPosition>,
} }
/// A serializable representation of the changes made to a text document
/// without the original text.
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct ChangeSet<T>
where
T: PartialEq + Clone + Debug,
{
operations: Vec<Operation<T>>,
cursors: Vec<CursorPosition>,
}
impl<'a, T> ChangeSet<T>
where
T: PartialEq + Clone + Debug,
{
#[must_use]
pub fn new(operations: Vec<Operation<T>>, cursors: Vec<CursorPosition>) -> Self {
Self {
operations,
cursors,
}
}
}
impl<'a> EditedText<'a, String> { impl<'a> EditedText<'a, String> {
/// Create an `EditedText` from the given original (old) and updated (new) /// Create an `EditedText` from the given original (old) and updated (new)
/// strings. The returned `EditedText` represents the changes from the /// strings. The returned `EditedText` represents the changes from the
@ -46,8 +72,8 @@ impl<'a> EditedText<'a, String> {
/// word tokenizer is used to tokenize the text which splits the text on /// word tokenizer is used to tokenize the text which splits the text on
/// whitespaces. /// whitespaces.
#[must_use] #[must_use]
pub fn from_strings(original: &'a str, updated: &TextWithCursors, side: Side) -> Self { pub fn from_strings(original: &'a str, updated: &TextWithCursors) -> Self {
Self::from_strings_with_tokenizer(original, updated, &*BuiltinTokenizer::Word, side) Self::from_strings_with_tokenizer(original, updated, &*BuiltinTokenizer::Word)
} }
} }
@ -64,16 +90,18 @@ where
original: &'a str, original: &'a str,
updated: &TextWithCursors, updated: &TextWithCursors,
tokenizer: &Tokenizer<T>, tokenizer: &Tokenizer<T>,
side: Side,
) -> Self { ) -> Self {
let original_tokens = (tokenizer)(original); let original_tokens = (tokenizer)(original);
let updated_tokens = (tokenizer)(&updated.text()); let updated_tokens = (tokenizer)(&updated.text());
let diff: Vec<RawOperation<T>> = RawOperation::vec_from(&original_tokens, &updated_tokens); let diff: Vec<RawOperation<T>> = RawOperation::vec_from(&original_tokens, &updated_tokens);
let operations: Vec<Operation<T>> = cook_operations(elongate_operations(diff)).collect();
let operation_count = operations.len();
Self::new( Self::new(
original, original,
cook_operations(elongate_operations(diff), side).collect(), operations,
vec![Side::Left; operation_count],
updated.cursors(), updated.cursors(),
) )
} }
@ -81,12 +109,18 @@ where
/// Create a new `EditedText` with the given operations. /// Create a new `EditedText` with the given operations.
/// The operations must be in the order in which they are meant to be /// The operations must be in the order in which they are meant to be
/// applied. The operations must not overlap. /// applied. The operations must not overlap.
fn new(text: &'a str, operations: Vec<Operation<T>>, mut cursors: Vec<CursorPosition>) -> Self { fn new(
text: &'a str,
operations: Vec<Operation<T>>,
operation_sides: Vec<Side>,
mut cursors: Vec<CursorPosition>,
) -> Self {
cursors.sort_by_key(|cursor| cursor.char_index); cursors.sort_by_key(|cursor| cursor.char_index);
Self { Self {
text, text,
operations, operations,
operation_sides,
cursors, cursors,
} }
} }
@ -109,6 +143,8 @@ where
let mut merged_operations: Vec<Operation<T>> = let mut merged_operations: Vec<Operation<T>> =
Vec::with_capacity(self.operations.len() + other.operations.len()); Vec::with_capacity(self.operations.len() + other.operations.len());
let mut merged_operation_sides: Vec<Side> =
Vec::with_capacity(self.operations.len() + other.operations.len());
let mut left_iter = self.operations.into_iter(); let mut left_iter = self.operations.into_iter();
let mut right_iter = other.operations.into_iter(); let mut right_iter = other.operations.into_iter();
@ -149,7 +185,7 @@ where
); );
let original_length = operation.len(); let original_length = operation.len();
let result = match side { let (side, result) = match side {
Side::Left => { Side::Left => {
let result = operation.merge_operations(&mut last_other_op); let result = operation.merge_operations(&mut last_other_op);
@ -181,7 +217,7 @@ where
maybe_left_op = left_iter.next(); maybe_left_op = left_iter.next();
last_left_op = Some(result.clone()); last_left_op = Some(result.clone());
result (Side::Left, result)
} }
Side::Right => { Side::Right => {
let result = operation.merge_operations(&mut last_other_op); let result = operation.merge_operations(&mut last_other_op);
@ -214,7 +250,7 @@ where
maybe_right_op = right_iter.next(); maybe_right_op = right_iter.next();
last_right_op = Some(result.clone()); last_right_op = Some(result.clone());
result (Side::Right, result)
} }
}; };
@ -227,13 +263,21 @@ where
} }
merged_operations.push(result); merged_operations.push(result);
merged_operation_sides.push(side);
} }
for cursor in left_cursors.chain(right_cursors) { for cursor in left_cursors.chain(right_cursors) {
merged_cursors.push(cursor.with_index(merged_length)); merged_cursors.push(cursor.with_index(merged_length));
} }
Self::new(self.text, merged_operations, merged_cursors) debug_assert_eq!(merged_operations.len(), merged_operation_sides.len());
Self::new(
self.text,
merged_operations,
merged_operation_sides,
merged_cursors,
)
} }
/// Apply the operations to the text and return the resulting text. /// Apply the operations to the text and return the resulting text.
@ -288,14 +332,14 @@ where
let mut history = Vec::with_capacity(self.operations.len()); let mut history = Vec::with_capacity(self.operations.len());
for operation in &self.operations { for (operation, side) in self.operations.iter().zip(self.operation_sides.iter()) {
builder = operation.apply(builder); builder = operation.apply(builder);
match operation { match operation {
Operation::Equal { .. } => { Operation::Equal { .. } => {
history.push(SpanWithHistory::new(builder.take(), History::Unchanged)); history.push(SpanWithHistory::new(builder.take(), History::Unchanged));
} }
Operation::Insert { side, .. } => match side { Operation::Insert { .. } => match side {
Side::Left => { Side::Left => {
history.push(SpanWithHistory::new(builder.take(), History::AddedFromLeft)); history.push(SpanWithHistory::new(builder.take(), History::AddedFromLeft));
} }
@ -307,7 +351,6 @@ where
Operation::Delete { Operation::Delete {
deleted_character_count, deleted_character_count,
order, order,
side,
.. ..
} => { } => {
let deleted = self.text[*order..*order + *deleted_character_count].to_string(); let deleted = self.text[*order..*order + *deleted_character_count].to_string();
@ -325,6 +368,29 @@ where
history history
} }
/// Serialize the `EditedText` as a `ChangeSet`, which contains only
/// the operations and cursor positions, without the original text.
/// This is useful for sending changes over the network if there's
/// a clear consensus on the original text.
#[must_use]
pub fn serialise_as_change_set(&self) -> ChangeSet<T> {
ChangeSet::new(self.operations.clone(), self.cursors.clone())
}
/// Deserialize an `EditedText` from a `ChangeSet` and the original text.
/// This is useful for reconstructing the `EditedText` on the receiving
/// end after sending only the `ChangeSet` over the network.
#[must_use]
pub fn from_change_set(text: &'a str, change_set: ChangeSet<T>) -> EditedText<'a, T> {
let operation_count = change_set.operations.len();
EditedText::new(
text,
change_set.operations,
vec![Side::Left; operation_count],
change_set.cursors,
)
}
} }
#[cfg(test)] #[cfg(test)]
@ -339,7 +405,7 @@ mod tests {
let left = "hello world! How are you? Adam"; let left = "hello world! How are you? Adam";
let right = "Hello, my friend! How are you doing? Albert"; let right = "Hello, my friend! How are you doing? Albert";
let operations = EditedText::from_strings(left, &right.into(), Side::Right); let operations = EditedText::from_strings(left, &right.into());
insta::assert_debug_snapshot!(operations); insta::assert_debug_snapshot!(operations);
@ -351,7 +417,7 @@ mod tests {
fn test_calculate_operations_with_no_diff() { fn test_calculate_operations_with_no_diff() {
let text = "hello world!"; let text = "hello world!";
let operations = EditedText::from_strings(text, &text.into(), Side::Right); let operations = EditedText::from_strings(text, &text.into());
assert_debug_snapshot!(operations); assert_debug_snapshot!(operations);
@ -366,8 +432,8 @@ mod tests {
let right = "Hello world! How are you?"; let right = "Hello world! How are you?";
let expected = "Hello world! How are you? I'm Andras."; let expected = "Hello world! How are you? I'm Andras.";
let operations_1 = EditedText::from_strings(original, &left.into(), Side::Left); let operations_1 = EditedText::from_strings(original, &left.into());
let operations_2 = EditedText::from_strings(original, &right.into(), Side::Right); let operations_2 = EditedText::from_strings(original, &right.into());
let operations = operations_1.merge(operations_2); let operations = operations_1.merge(operations_2);
assert_eq!(operations.apply().text(), expected); assert_eq!(operations.apply().text(), expected);

View file

@ -4,7 +4,7 @@ use core::fmt::{Debug, Display};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{ use crate::{
Side, Token, Token,
utils::{ utils::{
find_longest_prefix_contained_within::find_longest_prefix_contained_within, find_longest_prefix_contained_within::find_longest_prefix_contained_within,
string_builder::StringBuilder, string_builder::StringBuilder,
@ -23,23 +23,21 @@ where
length: usize, length: usize,
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
#[cfg_attr(feature = "serde", serde(skip_serializing))]
text: Option<String>, text: Option<String>,
}, },
Insert { Insert {
side: Side,
order: usize, order: usize,
text: Vec<Token<T>>, text: Vec<Token<T>>,
}, },
Delete { Delete {
side: Side,
order: usize, order: usize,
deleted_character_count: usize, deleted_character_count: usize,
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
#[cfg_attr(feature = "serde", serde(skip_serializing))]
deleted_text: Option<String>, deleted_text: Option<String>,
}, },
} }
@ -72,15 +70,14 @@ where
} }
/// Creates an insert operation with the given index and text. /// Creates an insert operation with the given index and text.
pub fn create_insert(order: usize, text: Vec<Token<T>>, side: Side) -> Self { pub fn create_insert(order: usize, text: Vec<Token<T>>) -> Self {
Operation::Insert { side, order, text } Operation::Insert { order, text }
} }
/// Creates a delete operation with the given index and number of /// Creates a delete operation with the given index and number of
/// to-be-deleted characters. /// to-be-deleted characters.
pub fn create_delete(order: usize, deleted_character_count: usize, side: Side) -> Self { pub fn create_delete(order: usize, deleted_character_count: usize) -> Self {
Operation::Delete { Operation::Delete {
side,
order, order,
deleted_character_count, deleted_character_count,
@ -89,9 +86,8 @@ where
} }
} }
pub fn create_delete_with_text(order: usize, text: String, side: Side) -> Self { pub fn create_delete_with_text(order: usize, text: String) -> Self {
Operation::Delete { Operation::Delete {
side,
order, order,
deleted_character_count: text.chars().count(), deleted_character_count: text.chars().count(),
@ -206,7 +202,7 @@ where
match (operation, previous_operation) { match (operation, previous_operation) {
( (
Operation::Insert { side, order, text }, Operation::Insert { order, text },
Some(Operation::Insert { Some(Operation::Insert {
text: previous_inserted_text, text: previous_inserted_text,
.. ..
@ -218,12 +214,11 @@ where
let offset_in_tokens = let offset_in_tokens =
find_longest_prefix_contained_within(previous_inserted_text, &text); find_longest_prefix_contained_within(previous_inserted_text, &text);
Operation::create_insert(order, text[offset_in_tokens..].to_vec(), side) Operation::create_insert(order, text[offset_in_tokens..].to_vec())
} }
( (
Operation::Delete { Operation::Delete {
side,
order, order,
deleted_character_count, deleted_character_count,
@ -247,20 +242,19 @@ where
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
let updated_delete = deleted_text.as_ref().map_or_else( let updated_delete = deleted_text.as_ref().map_or_else(
|| Operation::create_delete(order + overlap, new_length, side), || Operation::create_delete(order + overlap, new_length),
|text| { |text| {
Operation::create_delete_with_text( Operation::create_delete_with_text(
order + overlap, order + overlap,
text.chars() text.chars()
.skip(deleted_character_count - new_length) .skip(deleted_character_count - new_length)
.collect::<String>(), .collect::<String>(),
side,
) )
}, },
); );
#[cfg(not(debug_assertions))] #[cfg(not(debug_assertions))]
let updated_delete = Operation::create_delete(order + overlap, new_length, side); let updated_delete = Operation::create_delete(order + overlap, new_length);
updated_delete updated_delete
} }
@ -405,8 +399,7 @@ mod tests {
#[test] #[test]
fn test_apply_delete_with_create() { fn test_apply_delete_with_create() {
let builder = StringBuilder::new("hello world"); let builder = StringBuilder::new("hello world");
let delete_operation = let delete_operation = Operation::<()>::create_delete_with_text(0, "hello ".to_owned());
Operation::<()>::create_delete_with_text(0, "hello ".to_owned(), Side::Left);
let retain_operation = Operation::<()>::create_equal(6, 5); let retain_operation = Operation::<()>::create_equal(6, 5);
let mut builder = delete_operation.apply(builder); let mut builder = delete_operation.apply(builder);
@ -420,7 +413,7 @@ mod tests {
let builder = StringBuilder::new("hello"); let builder = StringBuilder::new("hello");
let retain_operation = Operation::<()>::create_equal(0, 5); let retain_operation = Operation::<()>::create_equal(0, 5);
let insert_operation = Operation::create_insert(5, vec![" my friend".into()], Side::Right); let insert_operation = Operation::create_insert(5, vec![" my friend".into()]);
let mut builder = retain_operation.apply(builder); let mut builder = retain_operation.apply(builder);
builder = insert_operation.apply(builder); builder = insert_operation.apply(builder);

View file

@ -1,7 +1,6 @@
--- ---
source: src/operation_transformation/edited_text.rs source: src/operation_transformation/edited_text.rs
expression: operations expression: operations
snapshot_kind: text
--- ---
EditedText { EditedText {
text: "hello world! How are you? Adam", text: "hello world! How are you? Adam",
@ -15,5 +14,15 @@ EditedText {
<delete ' you? Adam' from 20>, <delete ' you? Adam' from 20>,
<insert ' you doing? Albert' at 31>, <insert ' you doing? Albert' at 31>,
], ],
operation_sides: [
Left,
Left,
Left,
Left,
Left,
Left,
Left,
Left,
],
cursors: [], cursors: [],
} }

View file

@ -1,7 +1,6 @@
--- ---
source: src/operation_transformation/edited_text.rs source: src/operation_transformation/edited_text.rs
expression: operations expression: operations
snapshot_kind: text
--- ---
EditedText { EditedText {
text: "hello world!", text: "hello world!",
@ -10,5 +9,10 @@ EditedText {
<equal ' ' from 5>, <equal ' ' from 5>,
<equal 'world!' from 6>, <equal 'world!' from 6>,
], ],
operation_sides: [
Left,
Left,
Left,
],
cursors: [], cursors: [],
} }

View file

@ -1,10 +1,10 @@
use std::fmt::Debug; use std::fmt::Debug;
use crate::{operation_transformation::Operation, raw_operation::RawOperation, types::side::Side}; use crate::{operation_transformation::Operation, raw_operation::RawOperation};
/// Turn raw operations into ordered operations while keeping track of the /// Turn raw operations into ordered operations while keeping track of the
/// original token's indexes. /// original token's indexes.
pub fn cook_operations<I, T>(raw_operations: I, side: Side) -> impl Iterator<Item = Operation<T>> pub fn cook_operations<I, T>(raw_operations: I) -> impl Iterator<Item = Operation<T>>
where where
I: IntoIterator<Item = RawOperation<T>>, I: IntoIterator<Item = RawOperation<T>>,
T: PartialEq + Clone + Debug, T: PartialEq + Clone + Debug,
@ -29,18 +29,15 @@ where
op op
} }
RawOperation::Insert(tokens) => { RawOperation::Insert(tokens) => Operation::create_insert(original_text_index, tokens),
Operation::create_insert(original_text_index, tokens, side)
}
RawOperation::Delete(..) => { RawOperation::Delete(..) => {
let op = if cfg!(debug_assertions) { let op = if cfg!(debug_assertions) {
Operation::create_delete_with_text( Operation::create_delete_with_text(
original_text_index, original_text_index,
raw_operation.get_original_text(), raw_operation.get_original_text(),
side,
) )
} else { } else {
Operation::create_delete(original_text_index, length, side) Operation::create_delete(original_text_index, length)
}; };
original_text_index += length; original_text_index += length;