Add cursor moving (#19)

This commit is contained in:
Andras Schmelczer 2025-04-02 22:06:38 +01:00 • committed by GitHub
commit 1f9728d893
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
49 changed files with 1104 additions and 140 deletions

11
.editorconfig Normal file
View file

@ -0,0 +1,11 @@
# https://editorconfig.org
root = true
[*]
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
charset = utf-8
indent_style = space
indent_size = 4

1
backend/Cargo.lock generated
View file

@ -1897,6 +1897,7 @@ dependencies = [
"insta", "insta",
"pretty_assertions", "pretty_assertions",
"serde", "serde",
"serde_yaml",
"test-case", "test-case",
] ]

View file

@ -7,7 +7,7 @@ license.workspace = true
repository.workspace = true repository.workspace = true
[dependencies] [dependencies]
serde = { version = "1.0.219", optional = true } serde = { version = "1.0.219", optional = true, features = ["derive"] }
[features] [features]
serde = [ "dep:serde" ] serde = [ "dep:serde" ]
@ -15,6 +15,8 @@ serde = [ "dep:serde" ]
[dev-dependencies] [dev-dependencies]
insta = "1.42.2" insta = "1.42.2"
pretty_assertions = "1.4.1" pretty_assertions = "1.4.1"
serde = { version = "1.0.219", features = ["derive"] }
serde_yaml ="0.9.34"
test-case = "3.3.1" test-case = "3.3.1"
[lints] [lints]

View file

@ -3,5 +3,8 @@ mod operation_transformation;
mod tokenizer; mod tokenizer;
mod utils; mod utils;
pub use operation_transformation::{EditedText, reconcile, reconcile_with_tokenizer}; pub use operation_transformation::{
pub use tokenizer::token::Token; CursorPosition, EditedText, TextWithCursors, reconcile, reconcile_with_cursors,
reconcile_with_tokenizer,
};
pub use tokenizer::{Tokenizer, token::Token};

View file

@ -1,41 +1,42 @@
mod cursor;
mod edited_text; mod edited_text;
mod merge_context; mod merge_context;
mod operation; mod operation;
pub use cursor::{CursorPosition, TextWithCursors};
pub use edited_text::EditedText; pub use edited_text::EditedText;
pub use operation::Operation; pub use operation::Operation;
use crate::tokenizer::Tokenizer; use crate::Tokenizer;
#[must_use] #[must_use]
pub fn reconcile(original: &str, left: &str, right: &str) -> String { pub fn reconcile(original: &str, left: &str, right: &str) -> String {
// Common trivial cases reconcile_with_cursors(original, left.into(), right.into())
if left == right { .text
return left.to_owned(); .to_string()
} }
if original == left { #[must_use]
return right.to_owned(); pub fn reconcile_with_cursors<'a>(
} original: &'a str,
left: TextWithCursors<'a>,
if original == right { right: TextWithCursors<'a>,
return left.to_owned(); ) -> TextWithCursors<'static> {
}
// 3-way merge
let left_operations = EditedText::from_strings(original, left); let left_operations = EditedText::from_strings(original, left);
let right_operations = EditedText::from_strings(original, right); let right_operations = EditedText::from_strings(original, right);
let merged_operations = left_operations.merge(right_operations); let merged_operations = left_operations.merge(right_operations);
merged_operations.apply()
TextWithCursors::new_owned(merged_operations.apply(), merged_operations.cursors)
} }
pub fn reconcile_with_tokenizer<F, T>( #[must_use]
pub fn reconcile_with_tokenizer<'a, F, T>(
original: &str, original: &str,
left: &str, left: TextWithCursors<'a>,
right: &str, right: TextWithCursors<'a>,
tokenizer: &Tokenizer<T>, tokenizer: &Tokenizer<T>,
) -> String ) -> TextWithCursors<'static>
where where
T: PartialEq + Clone + std::fmt::Debug, T: PartialEq + Clone + std::fmt::Debug,
{ {
@ -43,7 +44,8 @@ where
let right_operations = EditedText::from_strings_with_tokenizer(original, right, tokenizer); let right_operations = EditedText::from_strings_with_tokenizer(original, right, tokenizer);
let merged_operations = left_operations.merge(right_operations); let merged_operations = left_operations.merge(right_operations);
merged_operations.apply()
TextWithCursors::new_owned(merged_operations.apply(), merged_operations.cursors)
} }
#[cfg(test)] #[cfg(test)]
@ -54,6 +56,7 @@ mod test {
use test_case::test_matrix; use test_case::test_matrix;
use super::*; use super::*;
use crate::CursorPosition;
#[test] #[test]
fn test_merges() { fn test_merges() {
@ -172,6 +175,188 @@ mod test {
" |7ca2b36d-6ee7-49eb-8eb1-d77e4cc1a001| |cd9195cc-103a-4f13-90c8-4fba0ba421ee| |d39156cc-cfd6-42a8-b70a-75020896069d| |fbad794c-9c47-41f2-a343-490284ecb5a0| |dup| |dup| "); " |7ca2b36d-6ee7-49eb-8eb1-d77e4cc1a001| |cd9195cc-103a-4f13-90c8-4fba0ba421ee| |d39156cc-cfd6-42a8-b70a-75020896069d| |fbad794c-9c47-41f2-a343-490284ecb5a0| |dup| |dup| ");
} }
#[test]
fn test_cursor_position_no_updates() {
let original = "hello world";
let left = TextWithCursors::new(
"hello world",
vec![CursorPosition {
id: 0,
char_index: 0,
}],
);
let right = TextWithCursors::new(
"hello world",
vec![CursorPosition {
id: 1,
char_index: 5,
}],
);
let merged = reconcile_with_cursors(original, left, right);
assert_eq!(
merged,
TextWithCursors::new(
"hello world",
vec![
CursorPosition {
id: 0,
char_index: 0
},
CursorPosition {
id: 1,
char_index: 5
}
]
)
);
}
#[test]
fn test_cursor_position_updates_with_inserts() {
let original = "hi";
let left = TextWithCursors::new(
"hi there",
vec![CursorPosition {
id: 0,
char_index: 7,
}],
);
let right = TextWithCursors::new(
"hi world!",
vec![
CursorPosition {
id: 1,
char_index: 9,
},
CursorPosition {
id: 2,
char_index: 1,
},
],
);
let merged = reconcile_with_cursors(original, left, right);
assert_eq!(
merged,
TextWithCursors::new(
"hi there world!",
vec![
CursorPosition {
id: 2,
char_index: 1,
},
CursorPosition {
id: 0,
char_index: 7
},
CursorPosition {
id: 1,
char_index: 15
},
]
)
);
}
#[test]
fn test_cursor_position_updates_with_deleted() {
let original = "a b c d";
let left = TextWithCursors::new(
"a b d",
vec![CursorPosition {
id: 0,
char_index: 1, // after a
}],
);
let right = TextWithCursors::new(
"c d",
vec![CursorPosition {
id: 1,
char_index: 1, // after c
}],
);
let merged = reconcile_with_cursors(original, left, right);
assert_eq!(
merged,
TextWithCursors::new(
" d",
vec![
CursorPosition {
id: 0,
char_index: 0
},
CursorPosition {
id: 1,
char_index: 1
}
]
)
);
}
#[test]
fn test_cursor_complex() {
let original = "this is some complex text to test cursor positions";
let left = TextWithCursors::new(
"this is really complex text for testing cursor positions",
vec![
CursorPosition {
id: 0,
char_index: 8,
}, // after "this is "
CursorPosition {
id: 1,
char_index: 22,
}, // after "this is really complex text"
],
);
let right = TextWithCursors::new(
"that was some complex sample to test cursor movements",
vec![
CursorPosition {
id: 2,
char_index: 5,
}, // after "that "
CursorPosition {
id: 3,
char_index: 29,
}, // after "some complex sample "
],
);
let merged = reconcile_with_cursors(original, left, right);
assert_eq!(
merged,
TextWithCursors::new(
"that was really complex sample for testing cursor movements",
vec![
CursorPosition {
id: 2,
char_index: 5
}, // unchanged
CursorPosition {
id: 0,
char_index: 9
}, // before "really"
CursorPosition {
id: 1,
char_index: 23
}, // inside of "s|ample" because "text" got replaced by "sample"
CursorPosition {
id: 3,
char_index: 31
}, // before "for"
]
)
);
}
#[test_matrix( [ #[test_matrix( [
"pride_and_prejudice.txt", "pride_and_prejudice.txt",
"romeo_and_juliet.txt", "romeo_and_juliet.txt",
@ -200,7 +385,7 @@ mod test {
let files = [file_name_1, file_name_2, file_name_3]; let files = [file_name_1, file_name_2, file_name_3];
let permutations = [range_1, range_2, range_3]; let permutations = [range_1, range_2, range_3];
let root = Path::new("test/resources/"); let root = Path::new("tests/resources/");
let contents = files let contents = files
.iter() .iter()

View file

@ -0,0 +1,68 @@
use std::borrow::Cow;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use super::merge_context::MergeContext;
use crate::operation_transformation::Operation;
// CursorPosition represents the position of an identifiable cursor in a text
// document based on its (UTF-8) character index.
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct CursorPosition {
pub id: usize,
pub char_index: usize,
}
impl CursorPosition {
#[must_use]
pub fn apply_merge_context<T>(&self, context: &MergeContext<T>) -> Self
where
T: PartialEq + Clone + std::fmt::Debug,
{
let char_index = match context.last_operation() {
Some(Operation::Delete { index, .. }) => (*index) as i64,
_ => self.char_index as i64 + context.shift,
};
CursorPosition {
id: self.id,
char_index: char_index.max(0) as usize,
}
}
}
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct TextWithCursors<'a> {
pub text: Cow<'a, str>,
pub cursors: Vec<CursorPosition>,
}
impl<'a> TextWithCursors<'a> {
#[must_use]
pub fn new(text: &'a str, cursors: Vec<CursorPosition>) -> Self {
Self {
text: text.into(),
cursors,
}
}
#[must_use]
pub fn new_owned(text: String, cursors: Vec<CursorPosition>) -> Self {
Self {
text: text.into(),
cursors,
}
}
}
impl<'a> From<&'a str> for TextWithCursors<'a> {
fn from(text: &'a str) -> Self {
Self {
text: text.into(),
cursors: Vec::new(),
}
}
}

View file

@ -3,7 +3,7 @@ use core::iter;
#[cfg(feature = "serde")] #[cfg(feature = "serde")]
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use super::Operation; use super::{CursorPosition, Operation, TextWithCursors};
use crate::{ use crate::{
diffs::{myers::diff, raw_operation::RawOperation}, diffs::{myers::diff, raw_operation::RawOperation},
operation_transformation::merge_context::MergeContext, operation_transformation::merge_context::MergeContext,
@ -29,6 +29,7 @@ where
{ {
text: &'a str, text: &'a str,
operations: Vec<OrderedOperation<T>>, operations: Vec<OrderedOperation<T>>,
pub(crate) cursors: Vec<CursorPosition>,
} }
impl<'a> EditedText<'a, String> { impl<'a> EditedText<'a, String> {
@ -39,7 +40,7 @@ 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: &str) -> Self { pub fn from_strings(original: &'a str, updated: TextWithCursors<'a>) -> Self {
Self::from_strings_with_tokenizer(original, updated, &word_tokenizer) Self::from_strings_with_tokenizer(original, updated, &word_tokenizer)
} }
} }
@ -55,17 +56,18 @@ where
/// function is used to tokenize the text. /// function is used to tokenize the text.
pub fn from_strings_with_tokenizer( pub fn from_strings_with_tokenizer(
original: &'a str, original: &'a str,
updated: &str, updated: TextWithCursors<'a>,
tokenizer: &Tokenizer<T>, tokenizer: &Tokenizer<T>,
) -> Self { ) -> Self {
let original_tokens = (tokenizer)(original); let original_tokens = (tokenizer)(original);
let updated_tokens = (tokenizer)(updated); let updated_tokens = (tokenizer)(&updated.text);
let diff: Vec<RawOperation<T>> = diff(&original_tokens, &updated_tokens); let diff: Vec<RawOperation<T>> = diff(&original_tokens, &updated_tokens);
Self::new( Self::new(
original, original,
Self::cook_operations(Self::elongate_operations(diff)).collect(), Self::cook_operations(Self::elongate_operations(diff)).collect(),
updated.cursors,
) )
} }
@ -170,7 +172,11 @@ 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<OrderedOperation<T>>) -> Self { fn new(
text: &'a str,
operations: Vec<OrderedOperation<T>>,
mut cursors: Vec<CursorPosition>,
) -> Self {
operations operations
.iter() .iter()
.zip(operations.iter().skip(1)) .zip(operations.iter().skip(1))
@ -183,7 +189,13 @@ where
); );
}); });
Self { text, operations } cursors.sort_by_key(|cursor| cursor.char_index);
Self {
text,
operations,
cursors,
}
} }
#[must_use] #[must_use]
@ -196,10 +208,14 @@ where
let mut left_merge_context = MergeContext::default(); let mut left_merge_context = MergeContext::default();
let mut right_merge_context = MergeContext::default(); let mut right_merge_context = MergeContext::default();
Self::new( let mut merged_cursors = Vec::with_capacity(self.cursors.len() + other.cursors.len());
self.text, let mut left_cursors = self.cursors.iter().peekable();
self.operations let mut right_cursors = other.cursors.iter().peekable();
let merged_operations = self
.operations
.into_iter() .into_iter()
// The current text is always the left; the other operation is the right side.
.map(|op| (op, Side::Left)) .map(|op| (op, Side::Left))
.merge_sorted_by_key( .merge_sorted_by_key(
other.operations.into_iter().map(|op| (op, Side::Right)), other.operations.into_iter().map(|op| (op, Side::Right)),
@ -226,20 +242,76 @@ where
) )
.flat_map(|(OrderedOperation { order, operation }, side)| { .flat_map(|(OrderedOperation { order, operation }, side)| {
match side { match side {
Side::Left => operation.merge_operations_with_context( Side::Left => {
while let Some(cursor) = left_cursors
.next_if(|cursor| cursor.char_index <= operation.start_index())
{
right_merge_context.consume_last_operation_if_it_is_too_behind(
cursor.char_index as i64,
);
merged_cursors.push(cursor.apply_merge_context(&right_merge_context));
}
while let Some(cursor) = right_cursors.next_if(|cursor| {
cursor.char_index as i64
<= operation.start_index() as i64 + right_merge_context.shift
- left_merge_context.shift
}) {
left_merge_context.consume_last_operation_if_it_is_too_behind(
cursor.char_index as i64,
);
merged_cursors.push(cursor.apply_merge_context(&left_merge_context));
}
operation.merge_operations_with_context(
&mut right_merge_context, &mut right_merge_context,
&mut left_merge_context, &mut left_merge_context,
), )
Side::Right => operation.merge_operations_with_context( }
Side::Right => {
while let Some(cursor) = right_cursors
.next_if(|cursor| cursor.char_index <= operation.start_index())
{
left_merge_context.consume_last_operation_if_it_is_too_behind(
cursor.char_index as i64,
);
merged_cursors.push(cursor.apply_merge_context(&left_merge_context));
}
while let Some(cursor) = left_cursors.next_if(|cursor| {
cursor.char_index as i64
<= operation.start_index() as i64 + left_merge_context.shift
- right_merge_context.shift
}) {
right_merge_context.consume_last_operation_if_it_is_too_behind(
cursor.char_index as i64,
);
merged_cursors.push(cursor.apply_merge_context(&right_merge_context));
}
operation.merge_operations_with_context(
&mut left_merge_context, &mut left_merge_context,
&mut right_merge_context, &mut right_merge_context,
), )
}
} }
.map(|operation| OrderedOperation { order, operation }) .map(|operation| OrderedOperation { order, operation })
.into_iter() .into_iter()
}) })
.collect(), .collect();
)
for cursor in left_cursors {
right_merge_context
.consume_last_operation_if_it_is_too_behind(cursor.char_index as i64);
merged_cursors.push(cursor.apply_merge_context(&right_merge_context));
}
for cursor in right_cursors {
left_merge_context.consume_last_operation_if_it_is_too_behind(cursor.char_index as i64);
merged_cursors.push(cursor.apply_merge_context(&left_merge_context));
}
Self::new(self.text, merged_operations, merged_cursors)
} }
/// Apply the operations to the text and return the resulting text. /// Apply the operations to the text and return the resulting text.
@ -268,7 +340,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); let operations = EditedText::from_strings(left, right.into());
insta::assert_debug_snapshot!(operations); insta::assert_debug_snapshot!(operations);
@ -280,7 +352,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); let operations = EditedText::from_strings(text, text.into());
assert_eq!(operations.operations.len(), 0); assert_eq!(operations.operations.len(), 0);
@ -296,8 +368,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); let operations_1 = EditedText::from_strings(original, left.into());
let operations_2 = EditedText::from_strings(original, 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(), expected); assert_eq!(operations.apply(), expected);

View file

@ -2,7 +2,7 @@ use core::fmt::Debug;
use crate::operation_transformation::Operation; use crate::operation_transformation::Operation;
#[derive(Clone)] #[derive(Clone, Debug)]
pub struct MergeContext<T> pub struct MergeContext<T>
where where
T: PartialEq + Clone + std::fmt::Debug, T: PartialEq + Clone + std::fmt::Debug,
@ -23,26 +23,19 @@ where
} }
} }
impl<T> Debug for MergeContext<T>
where
T: PartialEq + Clone + std::fmt::Debug,
{
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("MergeContext")
.field("last_operation", &self.last_operation)
.field("shift", &self.shift)
.finish()
}
}
impl<T> MergeContext<T> impl<T> MergeContext<T>
where where
T: PartialEq + Clone + std::fmt::Debug, T: PartialEq + Clone + std::fmt::Debug,
{ {
pub fn last_operation(&self) -> Option<&Operation<T>> { self.last_operation.as_ref() } pub fn last_operation(&self) -> Option<&Operation<T>> { self.last_operation.as_ref() }
pub fn replace_last_operation(&mut self, operation: Option<Operation<T>>) {
self.last_operation = operation;
}
/// Replace the last delete operation (if there was one) with a new one /// Replace the last delete operation (if there was one) with a new one
/// while applying it to the shift. /// while applying it to the `shift` in case the last operation
/// was a delete.
pub fn consume_and_replace_last_operation(&mut self, operation: Option<Operation<T>>) { pub fn consume_and_replace_last_operation(&mut self, operation: Option<Operation<T>>) {
if let Some(Operation::Delete { if let Some(Operation::Delete {
deleted_character_count, deleted_character_count,
@ -55,32 +48,22 @@ where
self.last_operation = operation; self.last_operation = operation;
} }
pub fn replace_last_operation(&mut self, operation: Option<Operation<T>>) {
self.last_operation = operation;
}
/// Remove the last operation (if there was one) in case it is behind the /// Remove the last operation (if there was one) in case it is behind the
/// threshold operation. This changes the shift in case the last operation /// threshold operation. This updates the `shift` in case the last operation
/// was a delete. /// was a delete.
pub fn consume_last_operation_if_it_is_too_behind( pub fn consume_last_operation_if_it_is_too_behind(&mut self, threshold_index: i64) {
&mut self,
threshold_operation: &Operation<T>,
) {
if let Some(last_operation) = self.last_operation.as_ref() { if let Some(last_operation) = self.last_operation.as_ref() {
if let Operation::Delete { if let Operation::Delete {
deleted_character_count, deleted_character_count,
.. ..
} = last_operation } = last_operation
{ {
if threshold_operation.start_index() as i64 + self.shift if threshold_index + self.shift > last_operation.end_index() as i64 {
> last_operation.end_index() as i64
{
self.shift -= *deleted_character_count as i64; self.shift -= *deleted_character_count as i64;
self.last_operation = None; self.last_operation = None;
} }
} else if let Operation::Insert { .. } = last_operation { } else if let Operation::Insert { .. } = last_operation {
if threshold_operation.start_index() as i64 + self.shift if threshold_index + self.shift - last_operation.len() as i64
- last_operation.len() as i64
> last_operation.end_index() as i64 > last_operation.end_index() as i64
{ {
self.last_operation = None; self.last_operation = None;

View file

@ -189,7 +189,7 @@ where
affecting_context: &mut MergeContext<T>, affecting_context: &mut MergeContext<T>,
produced_context: &mut MergeContext<T>, produced_context: &mut MergeContext<T>,
) -> Option<Operation<T>> { ) -> Option<Operation<T>> {
affecting_context.consume_last_operation_if_it_is_too_behind(&self); affecting_context.consume_last_operation_if_it_is_too_behind(self.start_index() as i64);
let operation = self.with_shifted_index(affecting_context.shift); let operation = self.with_shifted_index(affecting_context.shift);
match (operation, affecting_context.last_operation()) { match (operation, affecting_context.last_operation()) {

View file

@ -23,4 +23,5 @@ EditedText {
operation: <delete ' you? Adam' from index 43>, operation: <delete ' you? Adam' from index 43>,
}, },
], ],
cursors: [],
} }

View file

@ -1,4 +1,16 @@
use std::fmt::Display;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Side { pub enum Side {
Left, Left,
Right, Right,
} }
impl Display for Side {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Side::Left => write!(f, "Left"),
Side::Right => write!(f, "Right"),
}
}
}

View file

@ -0,0 +1,98 @@
use std::{fs, path::Path};
use pretty_assertions::assert_eq;
use reconcile::{CursorPosition, TextWithCursors};
use serde::Deserialize;
/// `ExampleDocument` represents a test case for the reconciliation process.
/// It contains a parent string, left and right strings with cursor positions,
/// and the expected result after reconciliation.
///
/// '|' characters in the left, right, and expected strings are treated as
/// cursor positions and are converted into `CursorPosition` objects.
#[derive(Debug, Deserialize, Clone, PartialEq, Eq)]
pub struct ExampleDocument {
parent: String,
left: String,
right: String,
expected: String,
}
impl ExampleDocument {
/// Creates a new `ExampleDocument` instance from a YAML file.
///
/// # Panics
///
/// If the file cannot be opened or parsed, the program will panic.
#[must_use]
pub fn from_yaml(path: &Path) -> Self {
let file = fs::File::open(path).expect("Failed to open example file");
serde_yaml::from_reader(file).expect("Failed to parse example file")
}
#[must_use]
pub fn parent(&self) -> String { self.parent.clone() }
#[must_use]
pub fn left(&self) -> TextWithCursors<'static> {
ExampleDocument::string_to_text_with_cursors(&self.left)
}
#[must_use]
pub fn right(&self) -> TextWithCursors<'static> {
ExampleDocument::string_to_text_with_cursors(&self.right)
}
/// Asserts that the result string matches the expected string,
/// including cursor positions.
///
/// # Panics
///
/// If the result string does not match the expected string, the program
/// will panic.
pub fn assert_eq(&self, result: &TextWithCursors<'static>) {
let result_str = ExampleDocument::text_with_cursors_to_string(result);
assert_eq!(result_str, self.expected);
}
/// Asserts that the result string matches the expected string,
/// ignoring cursor positions.
///
/// # Panics
///
/// If the result string does not match the expected string, the program
/// will panic.
pub fn assert_eq_without_cursors(&self, result: &str) {
assert_eq!(
result,
ExampleDocument::string_to_text_with_cursors(&self.expected).text,
);
}
fn text_with_cursors_to_string(text: &TextWithCursors<'_>) -> String {
let mut result = text.text.clone().into_owned();
for (i, cursor) in text.cursors.iter().enumerate() {
result.insert(cursor.char_index + i, '|');
}
result
}
fn string_to_text_with_cursors(text: &str) -> TextWithCursors<'static> {
let cursors = Self::parse_cursors(text);
let text = text.replace('|', "");
TextWithCursors::new_owned(text, cursors)
}
fn parse_cursors(text: &str) -> Vec<CursorPosition> {
let mut cursors = Vec::new();
for (i, c) in text.chars().enumerate() {
if c == '|' {
cursors.push(CursorPosition {
id: 0,
char_index: i - cursors.len(),
});
}
}
cursors
}
}

View file

@ -0,0 +1,6 @@
# The `|` characters denote cursor positions which are stripped before the actual reconcile logic is run
---
parent: You're Annual Savings Statement is available in our online portal
left: Your| annual record is available in our online portal|
right: You're Annual Savings information| is available online
expected: Your| annual record information| is available online|

View file

@ -0,0 +1,4 @@
parent: marketplace
left: market| place
right: market|space
expected: market| placemarket|space

View file

@ -0,0 +1,4 @@
parent: Please remember to bring your laptop and charger
left: Please remember to bring your laptop|
right: Please remember to bring your |new |laptop and charger
expected: Please remember to bring your |new |laptop|

View file

@ -0,0 +1,4 @@
parent: Party A shall pay Party B
left: Party C shall pay Party B
right: Party A shall receive from Party B
expected: Party C shall receive from Party B

View file

@ -0,0 +1,4 @@
parent: Please submit your assignment by Friday
left: Please submit your |completed |assignment by Friday
right: Please submit your assignment |online |by Friday
expected: Please submit your |completed |assignment |online |by Friday

View file

@ -0,0 +1,4 @@
parent:
left: hi my friend|
right: hi there|
expected: hi my friend| there|

View file

@ -0,0 +1,4 @@
parent: Buy milk and eggs
left: Buy organic milk| and eggs|
right: Buy milk and eggs| and bread
expected: Buy organic milk| and eggs|| and bread

View file

@ -0,0 +1,4 @@
parent: Meeting at 2pm in 会议室
left: Meeting at |3pm in the 会议室
right: Team meeting at 2pm in conference room|
expected: Team meeting at |3pm in conference room| the

Some files were not shown because too many files have changed in this diff Show more