use core::{ fmt::{Debug, Display}, ops::Range, }; #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; use super::merge_context::MergeContext; use crate::{ utils::{find_common_overlap::find_common_overlap, string_builder::StringBuilder}, Token, }; /// Represents a change that can be applied to a text document. /// Operation is tied to a `ropey::Rope` and is mainly expected to be /// created by `EditedText`. #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[derive(Clone, PartialEq)] pub enum Operation where T: PartialEq + Clone, { Insert { index: usize, text: Vec>, }, Delete { index: usize, deleted_character_count: usize, #[cfg(debug_assertions)] deleted_text: Option, }, } impl Operation where T: PartialEq + Clone, { /// Creates an insert operation with the given index and text. /// If the text is empty (meaning that the operation would be a no-op), /// returns None. pub fn create_insert(index: usize, text: Vec>) -> Option { if text.is_empty() { return None; } Some(Operation::Insert { index, text }) } /// Creates a delete operation with the given index and number of /// to-be-deleted characters. If the operation would delete 0 (meaning /// that the operation would be a no-op), returns None. pub fn create_delete(index: usize, deleted_character_count: usize) -> Option { if deleted_character_count == 0 { return None; } Some(Operation::Delete { index, deleted_character_count, #[cfg(debug_assertions)] deleted_text: None, }) } pub fn create_delete_with_text(index: usize, text: String) -> Option { if text.is_empty() { return None; } Some(Operation::Delete { index, deleted_character_count: text.chars().count(), #[cfg(debug_assertions)] deleted_text: Some(text), }) } /// Tries to apply the operation to the given `ropey::Rope` text, returning /// the modified text. /// /// # Errors /// /// Returns a `SyncLibError::OperationApplicationError` if the operation /// cannot be applied. /// /// # Panics /// /// When compiled in debug mode, panics if a delete operation is attempted /// on a range of text that does not match the text to be deleted. pub fn apply<'a>(&self, mut builder: StringBuilder<'a>) -> StringBuilder<'a> { match self { Operation::Insert { text, .. } => builder.insert( self.start_index(), &text.iter().map(Token::original).collect::(), ), Operation::Delete { #[cfg(debug_assertions)] deleted_text, .. } => { #[cfg(debug_assertions)] debug_assert!( deleted_text .as_ref() .is_none_or(|text| builder.get_slice(self.range()) == *text), "Text to delete does not match the text in the range" ); builder.delete(self.range()); } }; builder } /// Returns the index of the first character that the operation affects. pub fn start_index(&self) -> usize { match self { Operation::Insert { index, .. } => *index, Operation::Delete { index, .. } => *index, } } /// Returns the index of the last character that the operation affects. pub fn end_index(&self) -> usize { debug_assert!( self.len() > 0, " len() must be greater than 0 because operations must be non-empty" ); self.start_index() + self.len() - 1 } /// Returns the range of indices of characters that the operation affects. pub fn range(&self) -> Range { self.start_index()..self.end_index() + 1 } /// Returns the number of affected characters. It is always greater than 0 /// because empty operations cannot be created. pub fn len(&self) -> usize { match self { Operation::Insert { text, .. } => text.iter().map(Token::get_original_length).sum(), Operation::Delete { deleted_character_count, .. } => *deleted_character_count, } } /// Creates a new operation with the same type and text but with the given /// index. pub fn with_index(self, index: usize) -> Self { match self { Operation::Insert { text, .. } => Operation::Insert { index, text }, Operation::Delete { deleted_character_count, #[cfg(debug_assertions)] deleted_text, .. } => Operation::Delete { index, deleted_character_count, #[cfg(debug_assertions)] deleted_text, }, } } /// Creates a new operation with the same type and text but with the index /// shifted by the given offset. The offset can be negative but the /// resulting index must be non-negative. /// /// # Panics /// /// In debug mode, panics if the resulting index is negative. pub fn with_shifted_index(self, offset: i64) -> Self { let index = self.start_index() as i64 + offset; debug_assert!(index >= 0, "Shifted index must be non-negative"); self.with_index(index as usize) } /// Merges the operation with the given context, producing a new operation /// and updating the context. This implements a comples FSM that handles /// the merging of operations in a way that is consistent with the text. /// The contexts are updated in-place. pub fn merge_operations_with_context( self, affecting_context: &mut MergeContext, produced_context: &mut MergeContext, ) -> Option> { affecting_context.consume_last_operation_if_it_is_too_behind(&self); let operation = self.with_shifted_index(affecting_context.shift); match (operation, affecting_context.last_operation()) { (operation @ Operation::Insert { .. }, None) => { produced_context.shift += operation.len() as i64; produced_context.consume_and_replace_last_operation(Some(operation.clone())); Some(operation) } ( Operation::Insert { text, index }, Some(Operation::Insert { text: previous_inserted_text, .. }), ) => { let offset_in_tokens = find_common_overlap(previous_inserted_text, &text); let trimmed_length_in_tokens = previous_inserted_text.len() - offset_in_tokens; let trimmed_length = previous_inserted_text .iter() .skip(offset_in_tokens) .map(Token::get_original_length) .sum::(); let trimmed_operation = Operation::create_insert(index, text[trimmed_length_in_tokens..].to_vec()); affecting_context.shift -= trimmed_length as i64; produced_context.shift += trimmed_operation .as_ref() .map(Operation::len) .unwrap_or_default() as i64; produced_context.consume_and_replace_last_operation(trimmed_operation.clone()); trimmed_operation } (operation @ Operation::Delete { .. }, None | Some(Operation::Insert { .. })) => { produced_context.consume_and_replace_last_operation(Some(operation.clone())); Some(operation) } ( operation @ Operation::Insert { .. }, Some(last_delete @ Operation::Delete { .. }), ) => { produced_context.shift += operation.len() as i64; debug_assert!( last_delete.range().contains(&operation.start_index()), "There is a last delete ({last_delete}) but the operation ({operation}) is \ not contained in it" ); let difference = operation.start_index() as i64 - last_delete.start_index() as i64; let moved_operation = operation.with_index(last_delete.start_index()); affecting_context.replace_last_operation(Operation::create_delete( moved_operation.end_index() + 1, (last_delete.len() as i64 - difference) as usize, )); affecting_context.shift -= difference; produced_context.consume_and_replace_last_operation(Some(moved_operation.clone())); Some(moved_operation) } ( operation @ Operation::Delete { .. }, Some(last_delete @ Operation::Delete { .. }), ) => { debug_assert!( last_delete.range().contains(&operation.start_index()), "There is a last delete ({last_delete}) but the operation ({operation}) is \ not contained in it" ); let difference = operation.start_index() as i64 - last_delete.start_index() as i64; let updated_delete = Operation::create_delete( last_delete.start_index(), 0.max(operation.end_index() as i64 - last_delete.end_index() as i64) as usize, ); affecting_context.replace_last_operation(Operation::create_delete( last_delete.start_index(), 0.max(last_delete.end_index() as i64 - operation.end_index() as i64) as usize, )); affecting_context.shift -= difference; produced_context.consume_and_replace_last_operation(updated_delete.clone()); updated_delete } } } } impl Display for Operation where T: PartialEq + Clone, { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { Operation::Insert { index, text } => { write!( f, "", text.iter().map(Token::original).collect::(), index ) } Operation::Delete { index, deleted_character_count, #[cfg(debug_assertions)] deleted_text, } => { #[cfg(debug_assertions)] write!( f, "", deleted_text .as_ref() .map(|text| format!("'{text}'")) .unwrap_or(format!("{deleted_character_count} characters")), index )?; #[cfg(not(debug_assertions))] write!( f, "", )?; Ok(()) } } } } impl Debug for Operation where T: PartialEq + Clone, { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { write!(f, "{self}") } } #[cfg(test)] mod tests { use pretty_assertions::assert_eq; use super::*; #[test] #[should_panic] fn test_shifting_error() { insta::assert_debug_snapshot!(Operation::create_insert(1, vec!["hi".into()]) .unwrap() .with_shifted_index(-2)); } #[test] fn test_apply_delete_with_create() { let builder = StringBuilder::new("hello world"); let operation = Operation::<()>::create_delete_with_text(5, " world".to_owned()).unwrap(); assert_eq!(operation.apply(builder).build(), "hello"); } #[test] fn test_apply_insert() { let builder = StringBuilder::new("hello"); let operation = Operation::create_insert(5, vec![" my friend".into()]).unwrap(); assert_eq!(operation.apply(builder).build(), "hello my friend"); } }