Rename TextWithHistory to SpanWithHistory

This commit is contained in:
Andras Schmelczer 2025-07-04 03:14:18 +01:00
commit 8bd803c9b2
7 changed files with 100 additions and 30 deletions

View file

@ -2,7 +2,7 @@ import wasmInit, {
CursorPosition as wasmCursorPosition, CursorPosition as wasmCursorPosition,
reconcile as wasmReconcile, reconcile as wasmReconcile,
TextWithCursors as wasmTextWithCursors, TextWithCursors as wasmTextWithCursors,
TextWithHistory as wasmTextWithHistory, SpanWithHistory as wasmSpanWithHistory,
BuiltinTokenizer, BuiltinTokenizer,
reconcileWithHistory as wasmReconcileWithHistory, reconcileWithHistory as wasmReconcileWithHistory,
History, History,
@ -32,10 +32,10 @@ export interface TextWithCursorsAndHistory {
/** List of cursor positions, can be null or undefined if there are no cursors */ /** List of cursor positions, can be null or undefined if there are no cursors */
cursors: null | undefined | CursorPosition[]; cursors: null | undefined | CursorPosition[];
/** List of operations leading to `text` from the 3 ancestors */ /** List of operations leading to `text` from the 3 ancestors */
history: TextWithHistory[]; history: SpanWithHistory[];
} }
export interface TextWithHistory { export interface SpanWithHistory {
/** Span of text associated with the historical opearion */ /** Span of text associated with the historical opearion */
text: string; text: string;
/** Origin of the `text` span */ /** Origin of the `text` span */
@ -139,7 +139,7 @@ export function reconcileWithHistory(
rightCursor.free(); rightCursor.free();
const jsResult = toTextWithCursors(result); const jsResult = toTextWithCursors(result);
const history = result.history().map(toTextWithHistory); const history = result.history().map(toSpanWithHistory);
result.free(); result.free();
return { return {
@ -184,9 +184,9 @@ function toCursorPosition(cursor: wasmCursorPosition): CursorPosition {
}; };
} }
function toTextWithHistory( function toSpanWithHistory(
textWithHistory: wasmTextWithHistory textWithHistory: wasmSpanWithHistory
): TextWithHistory { ): SpanWithHistory {
return { return {
text: textWithHistory.text(), text: textWithHistory.text(),
history: textWithHistory.history(), history: textWithHistory.history(),

View file

@ -1,16 +1,86 @@
#![feature(stmt_expr_attributes)] //! # Reconcile
//!
//! A library for automatically merging two conflicting versions of a
//! document. `Reconcile` is essentially `git merge` but without any conflict
//! markers (or lost edits) in the output.
//!
//! ```
//! use reconcile::{reconcile, BuiltinTokenizer};
//!
//! let parent = "Merging text is hard!";
//! let left = "Merging text is easy!";
//! let right = "With reconcile, merging documents is hard!";
//!
//! let deconflicted = reconcile(parent, &left.into(), &right.into(), &*BuiltinTokenizer::Word);
//! assert_eq!(deconflicted.apply().text(), "With reconcile, merging documents is easy!");
//! ```
//! > You can also try out an interactive demo at [schmelczer.dev/reconcile](https://schmelczer.dev/reconcile).
//!
//! ## Tokenizing
//!
//! Merging is done on the token level, the granularity of which is
//! configurable. By default, words are the atoms for merging and thus words
//! can't get jumbled up at the end of reconciling. However, to maintain
//! gramatical correctness after merging, we could choose to treat individual
//! sentences as tokens:
//!
//! ```
//! ```
//!
//! > Beware, that if conflicting edits happen within a sentence (therefore each
//! > creating a new token), the sentences will appear duplicated.
//!
//! ```
//! ```
//!
//! If finer grained merging is required, we can make every UTF-8 character
//! become its own token:
//!
//!
//! If something custom is needed, for instance, to better support structured
//! text such as Markdown or HTML, a custom tokenizer can be implemented
//!
//!
//! ## Cursors and selection ranges
//!
//! Additionally, it supports updating cursor &
//! selection ranges during the merging too for interactive workflows.
//!
//!
//! ## The algorithm
//!
//! The algorithm starts similarly to `diff3`. Its inputs are a **Parent**
//! document `P` and two conflicting versions: `left` and `right` which have
//! been created from `P` through any series of concurrent edits. When calling
//! `reconcile(parent, left, right)`, first, the 2-way diff of (`parent` &
//! `left`) and (`parent` & `right`) are taken using Myers' algorithm.
//!
//! The
//!
//! Then, the
//! resulting edits are weaved together using the principles of operational
//! transformations ensuring that no change from either `left` or `right` is
//! lost: if either inserted some text, that string will end up in the result
//! and similarly for deletes.
//!
//! The
//!
//! The `reconcile` library
//!
mod diffs;
mod operation_transformation; mod operation_transformation;
mod raw_operation;
mod tokenizer; mod tokenizer;
mod types;
mod utils; mod utils;
pub use operation_transformation::{ pub use operation_transformation::{EditedText, reconcile};
CursorPosition, EditedText, TextWithCursors, reconcile, reconcile_with_cursors, pub use tokenizer::{BuiltinTokenizer, Tokenizer, token::Token};
reconcile_with_history, reconcile_with_tokenizer, pub use types::{
cursor_position::CursorPosition, history::History, side::Side,
span_with_history::SpanWithHistory, text_with_cursors::TextWithCursors,
}; };
pub use tokenizer::{Tokenizer, token::Token, word_tokenizer::word_tokenizer}; pub use utils::is_binary::is_binary;
pub use utils::{history::History, side::Side};
#[cfg(feature = "wasm")] #[cfg(feature = "wasm")]
pub mod wasm; pub mod wasm;

View file

@ -11,7 +11,7 @@ use crate::{
}, },
raw_operation::RawOperation, raw_operation::RawOperation,
tokenizer::Tokenizer, tokenizer::Tokenizer,
types::{history::History, side::Side, text_with_history::TextWithHistory}, types::{history::History, side::Side, span_with_history::SpanWithHistory},
utils::string_builder::StringBuilder, utils::string_builder::StringBuilder,
}; };
@ -231,7 +231,7 @@ where
} }
#[must_use] #[must_use]
pub fn apply_with_history(&self) -> Vec<TextWithHistory> { pub fn apply_with_history(&self) -> Vec<SpanWithHistory> {
let mut builder: StringBuilder<'_> = StringBuilder::new(self.text); let mut builder: StringBuilder<'_> = StringBuilder::new(self.text);
let mut history = Vec::with_capacity(self.operations.len()); let mut history = Vec::with_capacity(self.operations.len());
@ -241,13 +241,13 @@ where
match operation { match operation {
Operation::Equal { .. } => { Operation::Equal { .. } => {
history.push(TextWithHistory::new(History::Unchanged, builder.take())); history.push(SpanWithHistory::new(History::Unchanged, builder.take()));
} }
Operation::Insert { side, .. } => match side { Operation::Insert { side, .. } => match side {
Side::Left => { Side::Left => {
history.push(TextWithHistory::new(History::AddedFromLeft, builder.take())); history.push(SpanWithHistory::new(History::AddedFromLeft, builder.take()));
} }
Side::Right => history.push(TextWithHistory::new( Side::Right => history.push(SpanWithHistory::new(
History::AddedFromRight, History::AddedFromRight,
builder.take(), builder.take(),
)), )),
@ -261,10 +261,10 @@ where
let deleted = self.text[*order..*order + *deleted_character_count].to_string(); let deleted = self.text[*order..*order + *deleted_character_count].to_string();
match side { match side {
Side::Left => { Side::Left => {
history.push(TextWithHistory::new(History::RemovedFromLeft, deleted)); history.push(SpanWithHistory::new(History::RemovedFromLeft, deleted));
} }
Side::Right => { Side::Right => {
history.push(TextWithHistory::new(History::RemovedFromRight, deleted)); history.push(SpanWithHistory::new(History::RemovedFromRight, deleted));
} }
} }
} }

View file

@ -1,5 +1,5 @@
mod word_tokenizer;
mod character_tokenizer; mod character_tokenizer;
mod word_tokenizer;
use std::ops::Deref; use std::ops::Deref;

View file

@ -1,5 +1,5 @@
pub mod cursor_position; pub mod cursor_position;
pub mod history; pub mod history;
pub mod side; pub mod side;
pub mod span_with_history;
pub mod text_with_cursors; pub mod text_with_cursors;
pub mod text_with_history;

View file

@ -9,15 +9,15 @@ use crate::types::history::History;
#[cfg_attr(feature = "wasm", wasm_bindgen)] #[cfg_attr(feature = "wasm", wasm_bindgen)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct TextWithHistory { pub struct SpanWithHistory {
history: History, history: History,
text: String, text: String,
} }
#[cfg_attr(feature = "wasm", wasm_bindgen)] #[cfg_attr(feature = "wasm", wasm_bindgen)]
impl TextWithHistory { impl SpanWithHistory {
#[must_use] #[must_use]
pub fn new(history: History, text: String) -> Self { TextWithHistory { history, text } } pub fn new(history: History, text: String) -> Self { SpanWithHistory { history, text } }
#[must_use] #[must_use]
pub fn history(&self) -> History { self.history } pub fn history(&self) -> History { self.history }

View file

@ -14,7 +14,7 @@ use core::str;
use cfg_if::cfg_if; use cfg_if::cfg_if;
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
use crate::{BuiltinTokenizer, CursorPosition, TextWithCursors, TextWithHistory}; use crate::{BuiltinTokenizer, CursorPosition, SpanWithHistory, TextWithCursors};
cfg_if! { cfg_if! {
if #[cfg(feature = "wee_alloc")] { if #[cfg(feature = "wee_alloc")] {
#[global_allocator] #[global_allocator]
@ -120,7 +120,7 @@ fn set_panic_hook() {
#[derive(Debug, Clone, PartialEq, Default)] #[derive(Debug, Clone, PartialEq, Default)]
pub struct TextWithCursorsAndHistory { pub struct TextWithCursorsAndHistory {
text_with_cursors: TextWithCursors, text_with_cursors: TextWithCursors,
history: Vec<TextWithHistory>, history: Vec<SpanWithHistory>,
} }
#[wasm_bindgen] #[wasm_bindgen]
@ -132,5 +132,5 @@ impl TextWithCursorsAndHistory {
pub fn cursors(&self) -> Vec<CursorPosition> { self.text_with_cursors.cursors() } pub fn cursors(&self) -> Vec<CursorPosition> { self.text_with_cursors.cursors() }
#[must_use] #[must_use]
pub fn history(&self) -> Vec<TextWithHistory> { self.history.clone() } pub fn history(&self) -> Vec<SpanWithHistory> { self.history.clone() }
} }