diff --git a/backend/sync_lib/src/operations/operation.rs b/backend/sync_lib/src/operations/operation.rs new file mode 100644 index 0000000..87dce13 --- /dev/null +++ b/backend/sync_lib/src/operations/operation.rs @@ -0,0 +1,143 @@ +use std::cmp::Ordering; + +use ropey::Rope; +use serde::{Deserialize, Serialize}; +use similar::{Change, ChangeTag}; + +use crate::errors::SyncLibError; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Operation { + Insert { + index: u64, + text: String, + }, + Delete { + index: u64, + deleted_character_count: u64, + }, +} + +impl Operation { + pub fn new(tag: ChangeTag, index: u64, text: &str) -> Self { + match tag { + ChangeTag::Insert => Operation::Insert { + index, + text: text.to_string(), + }, + ChangeTag::Delete => Operation::Delete { + index, + deleted_character_count: text.chars().count() as u64, + }, + _ => panic!("Only insertion and deletions are supported"), + } + } + pub fn apply<'a>(&self, rope_text: &'a mut Rope) -> Result<&'a mut Rope, SyncLibError> { + let index: usize = self.index() as usize; + match self { + Operation::Insert { text, .. } => rope_text.try_insert(index, &text).map_err(|err| { + SyncLibError::OperationApplicationError(format!("Failed to insert text: {}", err)) + }), + Operation::Delete { + deleted_character_count, + .. + } => rope_text + .try_remove(index..index + *deleted_character_count as usize) + .map_err(|err| { + SyncLibError::OperationApplicationError(format!( + "Failed to remove text: {}", + err + )) + }), + }?; + + Ok(rope_text) + } + + pub fn index(&self) -> u64 { + match self { + Operation::Insert { index, .. } => *index, + Operation::Delete { index, .. } => *index, + } + } + + pub fn with_index(&self, index: u64) -> Self { + match self { + Operation::Insert { text, .. } => Operation::Insert { + index, + text: text.clone(), + }, + Operation::Delete { + deleted_character_count, + .. + } => Operation::Delete { + index, + deleted_character_count: *deleted_character_count, + }, + } + } + + pub fn with_shifted_index(&self, offset: i64) -> Result { + let new_index: i64 = self.index() as i64 + offset; + let new_index: u64 = new_index + .try_into() + .map_err(|err| SyncLibError::OperationShiftingError(format!("{}", err)))?; + Ok(self.with_index(new_index)) + } +} + +impl Ord for Operation { + fn cmp(&self, other: &Self) -> Ordering { + let result = self.index().cmp(&other.index()); + if result == Ordering::Equal { + match (self, other) { + (Operation::Insert { .. }, Operation::Delete { .. }) => Ordering::Greater, + (Operation::Delete { .. }, Operation::Insert { .. }) => Ordering::Less, + _ => Ordering::Equal, + } + } else { + result + } + } +} + +impl PartialOrd for Operation { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(&other)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_apply_delete() -> Result<(), SyncLibError> { + let mut rope = Rope::from_str("hello world"); + let operation = Operation::Delete { + index: 5, + deleted_character_count: 6, + }; + + operation.apply(&mut rope)?; + + assert_eq!(rope.to_string(), "hello"); + + Ok(()) + } + + #[test] + fn test_apply_insert() -> Result<(), SyncLibError> { + let mut rope = Rope::from_str("hello"); + let operation = Operation::Insert { + index: 5, + text: " my friend".to_string(), + }; + + operation.apply(&mut rope)?; + + assert_eq!(rope.to_string(), "hello my friend"); + + Ok(()) + } +} diff --git a/backend/sync_lib/src/operations/operation_sequence.rs b/backend/sync_lib/src/operations/operation_sequence.rs new file mode 100644 index 0000000..30d5bcc --- /dev/null +++ b/backend/sync_lib/src/operations/operation_sequence.rs @@ -0,0 +1,126 @@ +use super::{operation, Operation}; +use crate::errors::SyncLibError; +use log::info; +use ropey::Rope; +use similar::utils::diff_graphemes; +use similar::{utils::TextDiffRemapper, ChangeTag, TextDiff}; +use similar::{Algorithm, DiffableStrRef}; + +#[derive(Debug)] +pub struct OperationSequence { + operations: Vec, +} + +impl OperationSequence { + pub fn new(mut operations: Vec) -> Self { + operations.sort(); + + Self { operations } + } + + pub fn try_from_string_diff( + left: &str, + right: &str, + diff_ratio_threshold: f32, + ) -> Result { + let diff = TextDiff::configure() + .algorithm(Algorithm::Myers) + .diff_words(left, right); + + let diff_ratio = 1.0 - diff.ratio(); + if diff_ratio > diff_ratio_threshold { + return Err(SyncLibError::DiffTooLarge { + diff_ratio, + diff_ratio_limit: diff_ratio_threshold as f32, + }); + } + + let remapper = TextDiffRemapper::from_text_diff(&diff, left, right); + + let mut index = 0; + let operations = diff + .ops() + .iter() + .flat_map(move |x| remapper.iter_slices(x)) + .map(|(tag, text)| match tag { + ChangeTag::Equal => { + println!("Equal: {}", text); + index += text.chars().count(); + None + } + ChangeTag::Insert => { + println!("Insert: {}", text); + let result = Some(Operation::new(tag, index as u64, text)); + index += text.chars().count(); + result + } + ChangeTag::Delete => { + println!("Delete: {}", text); + Some(Operation::new(tag, index as u64, text)) + } + }) + .flat_map(Option::into_iter) + .collect::>(); + + Ok(Self::new(operations)) + } + + pub fn apply<'a>(&self, rope_text: &'a mut Rope) -> Result<&'a mut Rope, SyncLibError> { + for operation in &self.operations { + println!("Applying operation: {:?}", operation); + operation.apply(rope_text)?; + println!("Text after operation: {}", rope_text); + } + + Ok(rope_text) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_calculate_operations() -> Result<(), SyncLibError> { + let left = "hello world! How are you? Adam"; + let right = "Hello, my friend! How are you doing? Albert"; + + let operations = OperationSequence::try_from_string_diff(left, right, 0.6)?; + + insta::assert_debug_snapshot!(operations); + + let mut left = Rope::from_str(left); + let new_right = operations.apply(&mut left)?; + + assert_eq!(new_right.to_string(), right); + + Ok(()) + } + + #[test] + fn test_calculate_operations_with_large_diff() { + let left = "hello world! How are you? Adam"; + let right = "Hello, my friend! How are you doing? Albert"; + + let result = OperationSequence::try_from_string_diff(left, right, 0.1); + + insta::assert_debug_snapshot!(result); + } + + #[test] + fn test_calculate_operations_with_no_diff() -> Result<(), SyncLibError> { + let left = "hello world!"; + let right = "hello world!"; + + let operations = OperationSequence::try_from_string_diff(left, right, 0.0)?; + + assert_eq!(operations.operations.len(), 0); + + let mut left = Rope::from_str(left); + let new_right = operations.apply(&mut left)?; + + assert_eq!(new_right.to_string(), right); + + Ok(()) + } +}