.
All checks were successful
Check / build (pull_request) Successful in 9m54s

This commit is contained in:
Andras Schmelczer 2026-10-27 15:31:31 +00:00
commit b4259b767c
4 changed files with 608 additions and 24 deletions

View file

@ -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.

View file

@ -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)]

View file

@ -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 {

View 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)
}