use ropey::Rope; use serde::{Deserialize, Serialize}; use std::fmt::Display; use crate::errors::SyncLibError; /// Represents a change that can be applied to a text document. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum Operation { Insert { index: i64, text: String, }, Delete { index: i64, deleted_character_count: i64, }, } impl Operation { pub fn create_insert(index: i64, text: &str) -> Result, SyncLibError> { Self::validate_index(index)?; if text.is_empty() { return Ok(None); } Ok(Some(Operation::Insert { index, text: text.to_string(), })) } pub fn create_delete(index: i64, length: i64) -> Result, SyncLibError> { Self::validate_index(index)?; if length < 0 { return Err(SyncLibError::NegativeOperationIndexError(format!( "Length {} is negative", length ))); } if length == 0 { return Ok(None); } Ok(Some(Operation::Delete { index, deleted_character_count: length, })) } /// Tries to apply the operation to the given text, returning the modified text. pub fn apply<'a>(&self, rope_text: &'a mut Rope) -> Result<&'a mut Rope, SyncLibError> { let index: usize = self.start_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) } /// Returns the index of the first character that the operation affects. pub fn start_index(&self) -> i64 { match self { Operation::Insert { index, .. } => *index, Operation::Delete { index, .. } => *index, } } /// Returns the index based on which concurrent operations must be ordered. pub fn comparison_index(&self) -> i64 { match self { Operation::Insert { .. } => self.start_index(), Operation::Delete { .. } => self.end_index(), } } /// Returns the index of the last character that the operation affects. pub fn end_index(&self) -> i64 { self.start_index() + self.len() - 1 } /// Returns the number of affected characters. pub fn len(&self) -> i64 { match self { Operation::Insert { text, .. } => text.chars().count() as i64, Operation::Delete { deleted_character_count, .. } => *deleted_character_count, } } /// Returns true if applying operation wouldn't change the text. pub fn is_empty(&self) -> bool { self.len() == 0 } /// Returns the range of indices of characters that the operation affects, inclusive. pub fn range(&self) -> std::ops::RangeInclusive { self.start_index()..=self.end_index() } /// Clones the operation while updating the index. pub fn with_index(&self, index: i64) -> Result { Self::validate_index(index)?; Ok(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, }, }) } /// Clones the operation while shifting the index by the given offset. /// The offset can be negative but the resulting index must be non-negative. pub fn with_shifted_index(&self, offset: i64) -> Result { self.with_index(self.start_index() + offset) } fn validate_index(index: i64) -> Result<(), SyncLibError> { if index < 0 { return Err(SyncLibError::NegativeOperationIndexError(format!( "Index {} is negative", index ))); } Ok(()) } } impl Display for Operation { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Operation::Insert { index, text } => { write!(f, "Insert '{}' from index {}", text, index) } Operation::Delete { index, deleted_character_count, } => { write!( f, "Delete {} characters index {}", deleted_character_count, index ) } } } } #[cfg(test)] mod tests { use super::*; use pretty_assertions::assert_eq; #[test] fn test_creation_errors() { insta::assert_debug_snapshot!(Operation::create_insert(-1, "hi")); insta::assert_debug_snapshot!(Operation::create_delete(0, -1)); insta::assert_debug_snapshot!(Operation::create_delete(-1, -1)); insta::assert_debug_snapshot!(Operation::create_insert(0, "hi") .unwrap() .unwrap() .with_index(-1)); insta::assert_debug_snapshot!(Operation::create_insert(1, "hi") .unwrap() .unwrap() .with_shifted_index(-2)); } #[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_delete_with_create() -> Result<(), SyncLibError> { let mut rope = Rope::from_str("hello world"); let operation = Operation::create_delete(5, 6)?.unwrap(); 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(()) } #[test] fn test_apply_insert_with_create() -> Result<(), SyncLibError> { let mut rope = Rope::from_str("hello"); let operation = Operation::create_insert(5, " my friend")?.unwrap(); operation.apply(&mut rope)?; assert_eq!(rope.to_string(), "hello my friend"); Ok(()) } }