This commit is contained in:
parent
a2fbba682e
commit
b4259b767c
4 changed files with 608 additions and 24 deletions
|
|
@ -2,8 +2,9 @@
|
|||
//!
|
||||
//! A library for merging conflicting text edits without manual intervention.
|
||||
//! Unlike traditional 3-way merge tools that produce conflict markers,
|
||||
//! reconcile-text automatically resolves conflicts by applying both sets of
|
||||
//! changes (while updating cursor positions) using an algorithm inspired by
|
||||
//! reconcile-text automatically resolves conflicts by combining edits where
|
||||
//! possible, prioritizing stable replay, and updating cursor positions with
|
||||
//! an algorithm inspired by
|
||||
//! Operational Transformation.
|
||||
//!
|
||||
//! ✨ **[Try the interactive demo](https://schmelczer.dev/reconcile)** to see it in action.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
mod diff_error;
|
||||
mod edited_text;
|
||||
mod operation;
|
||||
mod replay;
|
||||
mod utils;
|
||||
use std::fmt::Debug;
|
||||
|
||||
|
|
@ -10,22 +11,22 @@ pub use operation::Operation;
|
|||
|
||||
use crate::{Tokenizer, types::text_with_cursors::TextWithCursors};
|
||||
|
||||
/// Given an `original` document and two concurrent edits to it,
|
||||
/// return a document containing all changes from both `left`
|
||||
/// and `right`.
|
||||
/// Merge two concurrent snapshots, prioritizing stability when either input
|
||||
/// is replayed against the same `original` document.
|
||||
///
|
||||
/// If both updated texts are identical, the resulting text is exactly that
|
||||
/// text, regardless of the original document.
|
||||
///
|
||||
/// If a span has been inserted in either the `left` or `right`
|
||||
/// versions, it will be present in the return value. If both sides
|
||||
/// insert the same span with a common prefix, that prefix will only
|
||||
/// be present once in the output.
|
||||
/// With a deterministic, lossless tokenizer, if `merged = reconcile(original, left,
|
||||
/// right).apply()`, reconciling `merged` with either input again produces the
|
||||
/// same text. This guarantee concerns text, not the combined cursor list.
|
||||
///
|
||||
/// When both sides delete the same span, it will be deleted in the
|
||||
/// return value. If one side deletes a span and the other side inserts
|
||||
/// into that span, the inserted text will be present in the return
|
||||
/// value.
|
||||
/// Already incorporated insertions are recognized as character subsequences
|
||||
/// of the other side's inserted text, including inside joined words. A combined
|
||||
/// result is kept only when it absorbs both inputs on replay. Otherwise one
|
||||
/// snapshot is selected, preferring more inserted characters, then more deleted
|
||||
/// characters, then lexicographically greater text. Some independent concurrent
|
||||
/// edits can therefore be superseded to keep retries stable.
|
||||
///
|
||||
/// Supports UTF-8. Arguments are tokenized using the provided `tokenizer`.
|
||||
///
|
||||
|
|
@ -49,10 +50,7 @@ pub fn reconcile<'a, T>(
|
|||
where
|
||||
T: PartialEq + Clone + Debug,
|
||||
{
|
||||
let left_operations = EditedText::from_strings_with_tokenizer(original, left, tokenizer);
|
||||
let right_operations = EditedText::from_strings_with_tokenizer(original, right, tokenizer);
|
||||
|
||||
left_operations.merge(right_operations)
|
||||
replay::merge_stable(original, left, right, tokenizer)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -95,10 +95,58 @@ where
|
|||
}
|
||||
}
|
||||
|
||||
pub(crate) fn inserted_text(&self) -> String {
|
||||
let mut result = String::new();
|
||||
for operation in &self.operations {
|
||||
if let Operation::Insert { text, .. } = operation {
|
||||
for token in text {
|
||||
result.push_str(token.original());
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub(crate) fn edit_counts(&self) -> (usize, usize) {
|
||||
self.operations
|
||||
.iter()
|
||||
.fold((0, 0), |(inserted, deleted), operation| match operation {
|
||||
Operation::Insert { .. } => (inserted + operation.len(), deleted),
|
||||
Operation::Delete {
|
||||
deleted_character_count,
|
||||
..
|
||||
} => (inserted, deleted + deleted_character_count),
|
||||
Operation::Equal { .. } => (inserted, deleted),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn with_cursors_and_side(
|
||||
mut self,
|
||||
mut cursors: Vec<CursorPosition>,
|
||||
side: Side,
|
||||
) -> Self {
|
||||
cursors.sort_by_key(|cursor| cursor.char_index);
|
||||
self.cursors = cursors;
|
||||
self.operation_sides.fill(side);
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn swap_sides(mut self) -> Self {
|
||||
for side in &mut self.operation_sides {
|
||||
*side = match side {
|
||||
Side::Left => Side::Right,
|
||||
Side::Right => Side::Left,
|
||||
};
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Merge two `EditedText` instances. The two instances must be derived
|
||||
/// from the same original text. The operations are merged using the
|
||||
/// principles of Operational Transformation. The cursors are updated
|
||||
/// accordingly to reflect the changes made by the merged operations.
|
||||
/// This operation-level API does not guarantee snapshot replay stability;
|
||||
/// use [`Self::merge_with_tokenizer`] or [`crate::reconcile`] for that contract.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
|
|
@ -254,6 +302,23 @@ where
|
|||
)
|
||||
}
|
||||
|
||||
/// Merge snapshots with the same replay guarantees as [`crate::reconcile`].
|
||||
/// The tokenizer must be deterministic, lossless, and match the tokenizer used
|
||||
/// to create these edits. Operations are recalculated from their snapshots.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics in debug builds if the original texts differ, or if a cursor index
|
||||
/// overflows an `isize` during transformation.
|
||||
#[must_use]
|
||||
pub fn merge_with_tokenizer(&self, other: &Self, tokenizer: &Tokenizer<T>) -> Self {
|
||||
debug_assert_eq!(
|
||||
self.text, other.text,
|
||||
"Edits must share the same original text"
|
||||
);
|
||||
super::reconcile(self.text, &self.apply(), &other.apply(), tokenizer)
|
||||
}
|
||||
|
||||
/// Apply the operations to the text and return the resulting text
|
||||
#[must_use]
|
||||
pub fn apply(&self) -> TextWithCursors {
|
||||
|
|
|
|||
172
src/operation_transformation/replay.rs
Normal file
172
src/operation_transformation/replay.rs
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
use std::fmt::Debug;
|
||||
|
||||
use super::EditedText;
|
||||
use crate::{BuiltinTokenizer, Side, TextWithCursors, Tokenizer};
|
||||
|
||||
pub(super) fn merge_stable<'a, T>(
|
||||
original: &'a str,
|
||||
left: &TextWithCursors,
|
||||
right: &TextWithCursors,
|
||||
tokenizer: &Tokenizer<T>,
|
||||
) -> EditedText<'a, T>
|
||||
where
|
||||
T: PartialEq + Clone + Debug,
|
||||
{
|
||||
// A combined result is accepted only if the candidate merge absorbs both
|
||||
// inputs. On replay the candidate is then the same result, and its checks
|
||||
// are unchanged or compare identical snapshots. If this fails, selecting
|
||||
// one input is also stable: replay sees the original pair or identical inputs.
|
||||
let candidate = merge_candidate(original, left, right, tokenizer);
|
||||
if left.text_ref() == right.text_ref()
|
||||
|| left.text_ref() == original
|
||||
|| right.text_ref() == original
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
let merged = candidate.apply().text();
|
||||
let without_cursors = merged.clone().into();
|
||||
if [left, right].iter().all(|input| {
|
||||
merge_candidate(
|
||||
original,
|
||||
&without_cursors,
|
||||
&input.text_ref().into(),
|
||||
tokenizer,
|
||||
)
|
||||
.apply()
|
||||
.text()
|
||||
== merged
|
||||
}) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
let left_operations = EditedText::from_strings_with_tokenizer(original, left, tokenizer);
|
||||
let right_operations = EditedText::from_strings_with_tokenizer(original, right, tokenizer);
|
||||
if version_priority(&left_operations, left) >= version_priority(&right_operations, right) {
|
||||
keep_version(left_operations, right_operations, left, right, Side::Left)
|
||||
} else {
|
||||
keep_version(right_operations, left_operations, right, left, Side::Right)
|
||||
}
|
||||
}
|
||||
|
||||
fn is_subsequence(part: &str, whole: &str) -> bool {
|
||||
let mut chars = whole.chars();
|
||||
part.chars()
|
||||
.all(|ch| chars.by_ref().any(|candidate| candidate == ch))
|
||||
}
|
||||
|
||||
fn version_priority<'b, T>(
|
||||
operations: &EditedText<'_, T>,
|
||||
input: &'b TextWithCursors,
|
||||
) -> (usize, usize, &'b str)
|
||||
where
|
||||
T: PartialEq + Clone + Debug,
|
||||
{
|
||||
let (inserted, deleted) = operations.edit_counts();
|
||||
(inserted, deleted, input.text_ref())
|
||||
}
|
||||
|
||||
fn merge_candidate<'a, T>(
|
||||
original: &'a str,
|
||||
left: &TextWithCursors,
|
||||
right: &TextWithCursors,
|
||||
tokenizer: &Tokenizer<T>,
|
||||
) -> EditedText<'a, T>
|
||||
where
|
||||
T: PartialEq + Clone + Debug,
|
||||
{
|
||||
let left_operations = EditedText::from_strings_with_tokenizer(original, left, tokenizer);
|
||||
let right_operations = EditedText::from_strings_with_tokenizer(original, right, tokenizer);
|
||||
let left_text = left.text_ref();
|
||||
let right_text = right.text_ref();
|
||||
if left_text == right_text || left_text == original || right_text == original {
|
||||
return merge_ordered(left_operations, right_operations, left_text, right_text);
|
||||
}
|
||||
|
||||
let left_inserted = left_operations.inserted_text();
|
||||
let right_inserted = right_operations.inserted_text();
|
||||
// Treat insertions already present in the other side's edits as incorporated,
|
||||
// including inside joined words or across separate inserted spans. This
|
||||
// deliberately gives replay precedence over ambiguous independent edits.
|
||||
let left_contains_right =
|
||||
!right_inserted.is_empty() && is_subsequence(&right_inserted, &left_inserted);
|
||||
let right_contains_left =
|
||||
!left_inserted.is_empty() && is_subsequence(&left_inserted, &right_inserted);
|
||||
match (left_contains_right, right_contains_left) {
|
||||
(true, false) => keep_version(left_operations, right_operations, left, right, Side::Left),
|
||||
(false, true) => keep_version(right_operations, left_operations, right, left, Side::Right),
|
||||
(true, true) => {
|
||||
if version_priority(&left_operations, left)
|
||||
>= version_priority(&right_operations, right)
|
||||
{
|
||||
keep_version(left_operations, right_operations, left, right, Side::Left)
|
||||
} else {
|
||||
keep_version(right_operations, left_operations, right, left, Side::Right)
|
||||
}
|
||||
}
|
||||
(false, false) => merge_ordered(left_operations, right_operations, left_text, right_text),
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_ordered<'a, T>(
|
||||
left: EditedText<'a, T>,
|
||||
right: EditedText<'a, T>,
|
||||
left_text: &str,
|
||||
right_text: &str,
|
||||
) -> EditedText<'a, T>
|
||||
where
|
||||
T: PartialEq + Clone + Debug,
|
||||
{
|
||||
// Canonical input order also stabilizes tokenizers with normalized or
|
||||
// non-joinable tokens. Restore provenance to the caller's original sides.
|
||||
if left_text <= right_text {
|
||||
left.merge(right)
|
||||
} else {
|
||||
right.merge(left).swap_sides()
|
||||
}
|
||||
}
|
||||
|
||||
fn keep_version<'a, T>(
|
||||
operations: EditedText<'a, T>,
|
||||
other_operations: EditedText<'a, T>,
|
||||
kept: &TextWithCursors,
|
||||
other: &TextWithCursors,
|
||||
side: Side,
|
||||
) -> EditedText<'a, T>
|
||||
where
|
||||
T: PartialEq + Clone + Debug,
|
||||
{
|
||||
let kept_text = kept.text_ref();
|
||||
let traditional = merge_ordered(
|
||||
operations.clone(),
|
||||
other_operations,
|
||||
kept_text,
|
||||
other.text_ref(),
|
||||
);
|
||||
// Preserve the existing cursor mapping and provenance when the operation
|
||||
// merge already produces the selected text. Character projection is needed
|
||||
// only when replay suppression actually changes the operation result.
|
||||
if traditional.apply().text_ref() == kept_text {
|
||||
return match side {
|
||||
Side::Left => traditional,
|
||||
Side::Right => traditional.swap_sides(),
|
||||
};
|
||||
}
|
||||
let mut cursors = kept.cursors();
|
||||
if !other.cursors().is_empty() {
|
||||
let other_text = other.text_ref();
|
||||
// Character alignment maps cursors inside joined words and uses the
|
||||
// same character offsets as the public cursor API, including Unicode.
|
||||
let original = EditedText::from_strings_with_tokenizer(
|
||||
other_text,
|
||||
other,
|
||||
&*BuiltinTokenizer::Character,
|
||||
);
|
||||
let updated = EditedText::from_strings_with_tokenizer(
|
||||
other_text,
|
||||
&kept_text.into(),
|
||||
&*BuiltinTokenizer::Character,
|
||||
);
|
||||
cursors.extend(original.merge(updated).apply().cursors());
|
||||
}
|
||||
operations.with_cursors_and_side(cursors, side)
|
||||
}
|
||||
Loading…
Reference in a new issue