diff --git a/crates/examples/benches/bench_text.rs b/crates/examples/benches/bench_text.rs index cd626874..a2434f22 100644 --- a/crates/examples/benches/bench_text.rs +++ b/crates/examples/benches/bench_text.rs @@ -8,6 +8,7 @@ use bench_utils::TextAction; use criterion::{black_box, criterion_group, criterion_main, Criterion}; use dev_utils::ByteSize; use loro::LoroDoc; +use rand::Rng; fn bench_text(c: &mut Criterion) { use bench_utils::TextAction; @@ -125,6 +126,77 @@ fn bench_text(c: &mut Criterion) { }); } +fn bench_update_text(c: &mut Criterion) { + let mut g = c.benchmark_group("update_text"); + g.bench_function("Update 1024x1024 total different text", |b| { + b.iter_batched( + || { + let from = "a".repeat(1024); + let to = "b".repeat(1024); + (from, to) + }, + |(from, to)| { + let doc = LoroDoc::new(); + let text = doc.get_text("text"); + text.update(&from, Default::default()).unwrap(); + text.update(&to, Default::default()).unwrap(); + assert_eq!(&text.to_string(), &to); + }, + criterion::BatchSize::SmallInput, + ); + }); + + g.bench_function("Update 1024x1024 random diff", |b| { + b.iter_batched( + || { + fn rand_str(len: usize, seed: u64) -> String { + let mut rng: rand::rngs::StdRng = rand::SeedableRng::seed_from_u64(seed); + let mut s = String::new(); + for _ in 0..len { + s.push(rng.gen_range(b'a'..=b'z') as char); + } + s + } + + let from = rand_str(1024, 42); + let mut to = from.clone(); + to.insert_str(0, &rand_str(100, 43)); + to.replace_range(100..200, &rand_str(100, 44)); // Make some differences + to.replace_range(500..504, &rand_str(4, 45)); + to.replace_range(600..700, &rand_str(5, 46)); + (from, to) + }, + |(from, to)| { + let doc = LoroDoc::new(); + let text = doc.get_text("text"); + text.update(&from, Default::default()).unwrap(); + text.update(&to, Default::default()).unwrap(); + assert_eq!(&text.to_string(), &to); + }, + criterion::BatchSize::SmallInput, + ); + }); + + g.bench_function("Update 1024x1024 text with 16 inserted chars", |b| { + b.iter_batched( + || { + let from = "a".repeat(1024); + let mut to = from.clone(); + to.insert_str(504, "b".repeat(16).as_str()); + (from, to) + }, + |(from, to)| { + let doc = LoroDoc::new(); + let text = doc.get_text("text"); + text.update(&from, Default::default()).unwrap(); + text.update(&to, Default::default()).unwrap(); + assert_eq!(&text.to_string(), &to); + }, + criterion::BatchSize::SmallInput, + ); + }); +} + fn apply_text_actions(actions: &[bench_utils::TextAction], n: usize) -> LoroDoc { let loro = LoroDoc::new(); let text = loro.get_text("text"); @@ -137,5 +209,5 @@ fn apply_text_actions(actions: &[bench_utils::TextAction], n: usize) -> LoroDoc loro } -criterion_group!(benches, bench_text); +criterion_group!(benches, bench_text, bench_update_text); criterion_main!(benches); diff --git a/crates/fuzz/fuzz/Cargo.lock b/crates/fuzz/fuzz/Cargo.lock index 66269165..6837e92a 100644 --- a/crates/fuzz/fuzz/Cargo.lock +++ b/crates/fuzz/fuzz/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "ahash" @@ -275,13 +275,14 @@ dependencies = [ "arbitrary", "bytes", "ensure-cov", - "enum-as-inner 0.5.1", + "enum-as-inner 0.6.0", "enum_dispatch", "fxhash", "itertools 0.12.1", "loro 0.16.12", - "loro 0.16.2 (git+https://github.com/loro-dev/loro.git?tag=loro-crdt@0.16.7)", + "loro 0.16.2 (git+https://github.com/loro-dev/loro.git?tag=loro-crdt%400.16.7)", "loro 0.16.2 (git+https://github.com/loro-dev/loro.git?rev=90470658435ec4c62b5af59ebb82fe9e1f5aa761)", + "loro 1.0.0-beta.5", "num_cpus", "pretty_assertions", "rand", @@ -297,7 +298,7 @@ version = "0.0.0" dependencies = [ "fuzz", "libfuzzer-sys", - "loro 0.16.12", + "loro 1.0.0-beta.5", ] [[package]] @@ -506,13 +507,13 @@ dependencies = [ [[package]] name = "loro" version = "0.16.2" -source = "git+https://github.com/loro-dev/loro.git?tag=loro-crdt@0.16.7#d2b0520f8633f96146a49ec205bd5e7056880f1a" +source = "git+https://github.com/loro-dev/loro.git?tag=loro-crdt%400.16.7#d2b0520f8633f96146a49ec205bd5e7056880f1a" dependencies = [ "either", "enum-as-inner 0.6.0", "generic-btree", - "loro-delta 0.16.2 (git+https://github.com/loro-dev/loro.git?tag=loro-crdt@0.16.7)", - "loro-internal 0.16.2 (git+https://github.com/loro-dev/loro.git?tag=loro-crdt@0.16.7)", + "loro-delta 0.16.2 (git+https://github.com/loro-dev/loro.git?tag=loro-crdt%400.16.7)", + "loro-internal 0.16.2 (git+https://github.com/loro-dev/loro.git?tag=loro-crdt%400.16.7)", "tracing", ] @@ -532,26 +533,39 @@ dependencies = [ [[package]] name = "loro" version = "0.16.12" +source = "git+https://github.com/loro-dev/loro.git?tag=loro-crdt%401.0.0-alpha.4#9bfe97bce4912c6dc8439817497d18423a0e8cb7" dependencies = [ - "either", "enum-as-inner 0.6.0", "generic-btree", "loro-common 0.16.12", "loro-delta 0.16.12", "loro-internal 0.16.12", - "loro-kv-store", + "loro-kv-store 0.16.2", + "tracing", +] + +[[package]] +name = "loro" +version = "1.0.0-beta.5" +dependencies = [ + "enum-as-inner 0.6.0", + "generic-btree", + "loro-common 1.0.0-beta.5", + "loro-delta 1.0.0-beta.5", + "loro-internal 1.0.0-beta.5", + "loro-kv-store 1.0.0-beta.5", "tracing", ] [[package]] name = "loro-common" version = "0.16.2" -source = "git+https://github.com/loro-dev/loro.git?tag=loro-crdt@0.16.7#d2b0520f8633f96146a49ec205bd5e7056880f1a" +source = "git+https://github.com/loro-dev/loro.git?tag=loro-crdt%400.16.7#d2b0520f8633f96146a49ec205bd5e7056880f1a" dependencies = [ "arbitrary", "enum-as-inner 0.6.0", "fxhash", - "loro-rle 0.16.2 (git+https://github.com/loro-dev/loro.git?tag=loro-crdt@0.16.7)", + "loro-rle 0.16.2 (git+https://github.com/loro-dev/loro.git?tag=loro-crdt%400.16.7)", "nonmax", "serde", "serde_columnar", @@ -578,6 +592,7 @@ dependencies = [ [[package]] name = "loro-common" version = "0.16.12" +source = "git+https://github.com/loro-dev/loro.git?tag=loro-crdt%401.0.0-alpha.4#9bfe97bce4912c6dc8439817497d18423a0e8cb7" dependencies = [ "arbitrary", "enum-as-inner 0.6.0", @@ -592,10 +607,27 @@ dependencies = [ "thiserror", ] +[[package]] +name = "loro-common" +version = "1.0.0-beta.5" +dependencies = [ + "arbitrary", + "enum-as-inner 0.6.0", + "fxhash", + "leb128", + "loro-rle 1.0.0-beta.5", + "nonmax", + "serde", + "serde_columnar", + "serde_json", + "string_cache", + "thiserror", +] + [[package]] name = "loro-delta" version = "0.16.2" -source = "git+https://github.com/loro-dev/loro.git?tag=loro-crdt@0.16.7#d2b0520f8633f96146a49ec205bd5e7056880f1a" +source = "git+https://github.com/loro-dev/loro.git?tag=loro-crdt%400.16.7#d2b0520f8633f96146a49ec205bd5e7056880f1a" dependencies = [ "arrayvec", "enum-as-inner 0.5.1", @@ -619,6 +651,7 @@ dependencies = [ [[package]] name = "loro-delta" version = "0.16.12" +source = "git+https://github.com/loro-dev/loro.git?tag=loro-crdt%401.0.0-alpha.4#9bfe97bce4912c6dc8439817497d18423a0e8cb7" dependencies = [ "arrayvec", "enum-as-inner 0.5.1", @@ -627,10 +660,20 @@ dependencies = [ "tracing", ] +[[package]] +name = "loro-delta" +version = "1.0.0-beta.5" +dependencies = [ + "arrayvec", + "enum-as-inner 0.5.1", + "generic-btree", + "heapless 0.8.0", +] + [[package]] name = "loro-internal" version = "0.16.2" -source = "git+https://github.com/loro-dev/loro.git?tag=loro-crdt@0.16.7#d2b0520f8633f96146a49ec205bd5e7056880f1a" +source = "git+https://github.com/loro-dev/loro.git?tag=loro-crdt%400.16.7#d2b0520f8633f96146a49ec205bd5e7056880f1a" dependencies = [ "append-only-bytes", "arref", @@ -643,10 +686,10 @@ dependencies = [ "im", "itertools 0.12.1", "leb128", - "loro-common 0.16.2 (git+https://github.com/loro-dev/loro.git?tag=loro-crdt@0.16.7)", - "loro-delta 0.16.2 (git+https://github.com/loro-dev/loro.git?tag=loro-crdt@0.16.7)", - "loro-rle 0.16.2 (git+https://github.com/loro-dev/loro.git?tag=loro-crdt@0.16.7)", - "loro_fractional_index 0.16.2 (git+https://github.com/loro-dev/loro.git?tag=loro-crdt@0.16.7)", + "loro-common 0.16.2 (git+https://github.com/loro-dev/loro.git?tag=loro-crdt%400.16.7)", + "loro-delta 0.16.2 (git+https://github.com/loro-dev/loro.git?tag=loro-crdt%400.16.7)", + "loro-rle 0.16.2 (git+https://github.com/loro-dev/loro.git?tag=loro-crdt%400.16.7)", + "loro_fractional_index 0.16.2 (git+https://github.com/loro-dev/loro.git?tag=loro-crdt%400.16.7)", "md5", "num", "num-derive", @@ -700,13 +743,14 @@ dependencies = [ [[package]] name = "loro-internal" version = "0.16.12" +source = "git+https://github.com/loro-dev/loro.git?tag=loro-crdt%401.0.0-alpha.4#9bfe97bce4912c6dc8439817497d18423a0e8cb7" dependencies = [ "append-only-bytes", "arref", "bytes", "either", "ensure-cov", - "enum-as-inner 0.5.1", + "enum-as-inner 0.6.0", "enum_dispatch", "fxhash", "generic-btree", @@ -716,7 +760,7 @@ dependencies = [ "leb128", "loro-common 0.16.12", "loro-delta 0.16.12", - "loro-kv-store", + "loro-kv-store 0.16.2", "loro-rle 0.16.12", "loro_fractional_index 0.16.12", "md5", @@ -737,9 +781,49 @@ dependencies = [ "xxhash-rust", ] +[[package]] +name = "loro-internal" +version = "1.0.0-beta.5" +dependencies = [ + "append-only-bytes", + "arref", + "bytes", + "either", + "ensure-cov", + "enum-as-inner 0.6.0", + "enum_dispatch", + "fxhash", + "generic-btree", + "getrandom", + "im", + "itertools 0.12.1", + "leb128", + "loro-common 1.0.0-beta.5", + "loro-delta 1.0.0-beta.5", + "loro-kv-store 1.0.0-beta.5", + "loro-rle 1.0.0-beta.5", + "loro_fractional_index 1.0.0-beta.5", + "md5", + "nonmax", + "num", + "num-traits", + "once_cell", + "postcard", + "pretty_assertions", + "rand", + "serde", + "serde_columnar", + "serde_json", + "smallvec", + "thiserror", + "tracing", + "xxhash-rust", +] + [[package]] name = "loro-kv-store" version = "0.16.2" +source = "git+https://github.com/loro-dev/loro.git?tag=loro-crdt%401.0.0-alpha.4#9bfe97bce4912c6dc8439817497d18423a0e8cb7" dependencies = [ "bytes", "ensure-cov", @@ -748,13 +832,29 @@ dependencies = [ "lz4_flex", "once_cell", "quick_cache", + "tracing", + "xxhash-rust", +] + +[[package]] +name = "loro-kv-store" +version = "1.0.0-beta.5" +dependencies = [ + "bytes", + "ensure-cov", + "fxhash", + "loro-common 1.0.0-beta.5", + "lz4_flex", + "once_cell", + "quick_cache", + "tracing", "xxhash-rust", ] [[package]] name = "loro-rle" version = "0.16.2" -source = "git+https://github.com/loro-dev/loro.git?tag=loro-crdt@0.16.7#d2b0520f8633f96146a49ec205bd5e7056880f1a" +source = "git+https://github.com/loro-dev/loro.git?tag=loro-crdt%400.16.7#d2b0520f8633f96146a49ec205bd5e7056880f1a" dependencies = [ "append-only-bytes", "arref", @@ -780,6 +880,7 @@ dependencies = [ [[package]] name = "loro-rle" version = "0.16.12" +source = "git+https://github.com/loro-dev/loro.git?tag=loro-crdt%401.0.0-alpha.4#9bfe97bce4912c6dc8439817497d18423a0e8cb7" dependencies = [ "append-only-bytes", "arref", @@ -789,6 +890,15 @@ dependencies = [ "smallvec", ] +[[package]] +name = "loro-rle" +version = "1.0.0-beta.5" +dependencies = [ + "append-only-bytes", + "num", + "smallvec", +] + [[package]] name = "loro-thunderdome" version = "0.6.2" @@ -798,7 +908,7 @@ checksum = "3f3d053a135388e6b1df14e8af1212af5064746e9b87a06a345a7a779ee9695a" [[package]] name = "loro_fractional_index" version = "0.16.2" -source = "git+https://github.com/loro-dev/loro.git?tag=loro-crdt@0.16.7#d2b0520f8633f96146a49ec205bd5e7056880f1a" +source = "git+https://github.com/loro-dev/loro.git?tag=loro-crdt%400.16.7#d2b0520f8633f96146a49ec205bd5e7056880f1a" dependencies = [ "imbl", "rand", @@ -820,6 +930,7 @@ dependencies = [ [[package]] name = "loro_fractional_index" version = "0.16.12" +source = "git+https://github.com/loro-dev/loro.git?tag=loro-crdt%401.0.0-alpha.4#9bfe97bce4912c6dc8439817497d18423a0e8cb7" dependencies = [ "imbl", "once_cell", @@ -828,6 +939,15 @@ dependencies = [ "smallvec", ] +[[package]] +name = "loro_fractional_index" +version = "1.0.0-beta.5" +dependencies = [ + "once_cell", + "rand", + "serde", +] + [[package]] name = "lz4_flex" version = "0.11.3" diff --git a/crates/fuzz/fuzz/fuzz_targets/text-update.rs b/crates/fuzz/fuzz/fuzz_targets/text-update.rs index 3a5770c2..90a3529c 100644 --- a/crates/fuzz/fuzz/fuzz_targets/text-update.rs +++ b/crates/fuzz/fuzz/fuzz_targets/text-update.rs @@ -7,9 +7,9 @@ fuzz_target!(|data: [&str; 3]| { let (old, new, new1) = (data[0], data[1], data[2]); let doc = LoroDoc::new(); let text = doc.get_text("text"); - text.update(old); - text.update(new); + text.update(old, Default::default()).unwrap(); + text.update(new, Default::default()).unwrap(); assert_eq!(&text.to_string(), new); - text.update_by_line(new1); + text.update_by_line(new1, Default::default()).unwrap(); assert_eq!(&text.to_string(), new1); }); diff --git a/crates/fuzz/tests/update_text.rs b/crates/fuzz/tests/update_text.rs index aa6db792..1ab7fe68 100644 --- a/crates/fuzz/tests/update_text.rs +++ b/crates/fuzz/tests/update_text.rs @@ -4,7 +4,7 @@ use loro::LoroDoc; fn update_text() { let doc = LoroDoc::new(); let text = doc.get_text("text"); - text.update("ϼCCC"); - text.update("2"); + text.update("ϼCCC", Default::default()).unwrap(); + text.update("2", Default::default()).unwrap(); assert_eq!(&text.to_string(), "2"); } diff --git a/crates/loro-ffi/src/container/text.rs b/crates/loro-ffi/src/container/text.rs index ac87ab66..82703843 100644 --- a/crates/loro-ffi/src/container/text.rs +++ b/crates/loro-ffi/src/container/text.rs @@ -1,6 +1,6 @@ use std::{fmt::Display, sync::Arc}; -use loro::{cursor::Side, LoroResult, TextDelta}; +use loro::{cursor::Side, LoroResult, TextDelta, UpdateOptions, UpdateTimeoutError}; use crate::{ContainerID, LoroValue, LoroValueLike}; @@ -101,8 +101,8 @@ impl LoroText { } /// Update the current text based on the provided text. - pub fn update(&self, text: &str) { - self.text.update(text); + pub fn update(&self, text: &str, options: UpdateOptions) -> Result<(), UpdateTimeoutError> { + self.text.update(text, options) } /// Apply a [delta](https://quilljs.com/docs/delta/) to the text container. diff --git a/crates/loro-internal/src/awareness.rs b/crates/loro-internal/src/awareness.rs index 7af5e67e..84a93496 100644 --- a/crates/loro-internal/src/awareness.rs +++ b/crates/loro-internal/src/awareness.rs @@ -2,7 +2,7 @@ use fxhash::FxHashMap; use loro_common::{LoroValue, PeerID}; use serde::{Deserialize, Serialize}; -use crate::change::get_sys_timestamp; +use crate::change::{get_sys_timestamp, Timestamp}; /// `Awareness` is a structure that tracks the ephemeral state of peers. /// @@ -43,7 +43,7 @@ impl Awareness { pub fn encode(&self, peers: &[PeerID]) -> Vec { let mut peers_info = Vec::new(); - let now = get_sys_timestamp(); + let now = get_sys_timestamp() as Timestamp; for peer in peers { if let Some(peer_info) = self.peers.get(peer) { if now - peer_info.timestamp > self.timeout { @@ -64,7 +64,7 @@ impl Awareness { pub fn encode_all(&self) -> Vec { let mut peers_info = Vec::new(); - let now = get_sys_timestamp(); + let now = get_sys_timestamp() as Timestamp; for (peer, peer_info) in self.peers.iter() { if now - peer_info.timestamp > self.timeout { continue; @@ -86,7 +86,7 @@ impl Awareness { let peers_info: Vec = postcard::from_bytes(encoded_peers_info).unwrap(); let mut changed_peers = Vec::new(); let mut added_peers = Vec::new(); - let now = get_sys_timestamp(); + let now = get_sys_timestamp() as Timestamp; for peer_info in peers_info { match self.peers.get(&peer_info.peer) { Some(x) if x.counter >= peer_info.counter || peer_info.peer == self.peer => { @@ -126,7 +126,7 @@ impl Awareness { peer.state = value; peer.counter += 1; - peer.timestamp = get_sys_timestamp(); + peer.timestamp = get_sys_timestamp() as Timestamp; } pub fn get_local_state(&self) -> Option { @@ -134,7 +134,7 @@ impl Awareness { } pub fn remove_outdated(&mut self) -> Vec { - let now = get_sys_timestamp(); + let now = get_sys_timestamp() as Timestamp; let mut removed = Vec::new(); self.peers.retain(|id, v| { if now - v.timestamp > self.timeout { diff --git a/crates/loro-internal/src/change.rs b/crates/loro-internal/src/change.rs index ae505736..486fe319 100644 --- a/crates/loro-internal/src/change.rs +++ b/crates/loro-internal/src/change.rs @@ -263,7 +263,7 @@ impl Change { /// [Unix time](https://en.wikipedia.org/wiki/Unix_time) /// It is the number of milliseconds that have elapsed since 00:00:00 UTC on 1 January 1970. #[cfg(not(all(feature = "wasm", target_arch = "wasm32")))] -pub(crate) fn get_sys_timestamp() -> Timestamp { +pub(crate) fn get_sys_timestamp() -> f64 { use std::time::{SystemTime, UNIX_EPOCH}; SystemTime::now() .duration_since(UNIX_EPOCH) @@ -275,7 +275,7 @@ pub(crate) fn get_sys_timestamp() -> Timestamp { /// [Unix time](https://en.wikipedia.org/wiki/Unix_time) /// It is the number of seconds that have elapsed since 00:00:00 UTC on 1 January 1970. #[cfg(all(feature = "wasm", target_arch = "wasm32"))] -pub fn get_sys_timestamp() -> Timestamp { +pub fn get_sys_timestamp() -> f64 { use wasm_bindgen::prelude::wasm_bindgen; #[wasm_bindgen] extern "C" { @@ -285,7 +285,7 @@ pub fn get_sys_timestamp() -> Timestamp { pub fn now() -> f64; } - now() as Timestamp + now() } #[cfg(test)] diff --git a/crates/loro-internal/src/diff/diff_impl.rs b/crates/loro-internal/src/diff/diff_impl.rs index 591d089c..8d39f600 100644 --- a/crates/loro-internal/src/diff/diff_impl.rs +++ b/crates/loro-internal/src/diff/diff_impl.rs @@ -14,9 +14,38 @@ //! //! The implementation of this algorithm is based on the implementation by //! Brandon Williams. +use crate::change::get_sys_timestamp; +use fxhash::FxHashMap; +use std::cmp::Ordering; +use std::collections::BinaryHeap; use std::iter::zip; use std::ops::{Index, IndexMut}; +/// Options for controlling the text update behavior. +/// +/// - `timeout_ms`: Optional timeout in milliseconds for the diff computation +/// - `use_refined_diff`: Whether to use a more refined but slower diff algorithm. Defaults to true. +#[derive(Clone, Debug)] +pub struct UpdateOptions { + pub timeout_ms: Option, + pub use_refined_diff: bool, +} + +impl Default for UpdateOptions { + fn default() -> Self { + Self { + timeout_ms: None, + use_refined_diff: true, + } + } +} + +#[derive(thiserror::Error, Debug, PartialEq)] +pub enum UpdateTimeoutError { + #[error("Timeout")] + Timeout, +} + /// Utility function to check if a range is empty that works on older rust versions #[inline(always)] fn is_empty_range(start: usize, end: usize) -> bool { @@ -79,14 +108,33 @@ impl OperateProxy { pub fn insert(&mut self, old_index: usize, new_index: usize, new_len: usize) { self.handler.insert(old_index, new_index, new_len); } + + #[allow(unused)] + fn unwrap(self) -> D { + self.handler + } } -pub(crate) fn myers_diff(proxy: &mut OperateProxy, old: &[u32], new: &[u32]) { +pub(crate) fn diff( + proxy: &mut OperateProxy, + options: UpdateOptions, + old: &[u32], + new: &[u32], +) -> Result<(), UpdateTimeoutError> { let max_d = (old.len() + new.len() + 1) / 2 + 1; let mut vb = OffsetVec::new(max_d); let mut vf = OffsetVec::new(max_d); + let start_time = if options.timeout_ms.is_some() { + get_sys_timestamp() + } else { + 0. + }; + conquer( proxy, + options.use_refined_diff, + options.timeout_ms, + start_time, old, 0, old.len(), @@ -95,7 +143,7 @@ pub(crate) fn myers_diff(proxy: &mut OperateProxy, old: &[u32 new.len(), &mut vf, &mut vb, - ); + ) } struct OffsetVec(isize, Vec); @@ -123,8 +171,17 @@ impl IndexMut for OffsetVec { } } +struct MiddleSnakeResult { + #[allow(unused)] + d: usize, + x_start: usize, + y_start: usize, +} + #[allow(clippy::too_many_arguments)] fn find_middle_snake( + timeout_ms: Option, + start_time: f64, old: &[u32], old_start: usize, old_end: usize, @@ -133,7 +190,7 @@ fn find_middle_snake( new_end: usize, vf: &mut OffsetVec, vb: &mut OffsetVec, -) -> Option<(usize, usize)> { +) -> Result, UpdateTimeoutError> { let n = old_end - old_start; let m = new_end - new_start; let delta = n as isize - m as isize; @@ -160,9 +217,20 @@ fn find_middle_snake( } vf[k] = x; if odd && (k - delta).abs() <= (d - 1) && vf[k] + vb[delta - k] >= n { - return Some((x0 + old_start, y0 + new_start)); + return Ok(Some(MiddleSnakeResult { + d: d as usize, + x_start: x0 + old_start, + y_start: y0 + new_start, + })); + } + + if let Some(timeout_ms) = timeout_ms { + if get_sys_timestamp() - start_time > timeout_ms { + return Err(UpdateTimeoutError::Timeout); + } } } + for k in (-d..=d).rev().step_by(2) { let mut x = if k == -d || (k != d && vb[k - 1] < vb[k + 1]) { vb[k + 1] @@ -180,16 +248,30 @@ fn find_middle_snake( } vb[k] = x; if !odd && (k - delta).abs() <= d && vb[k] + vf[delta - k] >= n { - return Some((n - x + old_start, m - y + new_start)); + return Ok(Some(MiddleSnakeResult { + d: d as usize, + x_start: n - x + old_start, + y_start: m - y + new_start, + })); + } + + if let Some(timeout_ms) = timeout_ms { + if get_sys_timestamp() - start_time > timeout_ms { + return Err(UpdateTimeoutError::Timeout); + } } } } - None + + Ok(None) } #[allow(clippy::too_many_arguments)] fn conquer( proxy: &mut OperateProxy, + should_use_dj: bool, + timeout_ms: Option, + start_time: f64, old: &[u32], mut old_start: usize, mut old_end: usize, @@ -198,7 +280,7 @@ fn conquer( mut new_end: usize, vf: &mut OffsetVec, vb: &mut OffsetVec, -) { +) -> Result<(), UpdateTimeoutError> { let common_prefix_len = common_prefix(&old[old_start..old_end], &new[new_start..new_end]); if common_prefix_len > 0 { old_start += common_prefix_len; @@ -208,21 +290,566 @@ fn conquer( let common_suffix_len = common_suffix_len(&old[old_start..old_end], &new[new_start..new_end]); old_end -= common_suffix_len; new_end -= common_suffix_len; + if is_not_empty_range(old_start, old_end) || is_not_empty_range(new_start, new_end) { + let len_old = old_end - old_start; + let len_new = new_end - new_start; + if should_use_dj && (len_old * len_new < 128 * 128) { + let ok = dj_diff( + proxy, + &old[old_start..old_end], + &new[new_start..new_end], + old_start, + new_start, + 10_000, + ); + if ok { + return Ok(()); + } + } + if is_empty_range(new_start, new_end) { proxy.delete(old_start, old_end - old_start); } else if is_empty_range(old_start, old_end) { proxy.insert(old_start, new_start, new_end - new_start); - } else if let Some((x_start, y_start)) = - find_middle_snake(old, old_start, old_end, new, new_start, new_end, vf, vb) - { + } else if let Some(MiddleSnakeResult { + d: _, + x_start, + y_start, + }) = find_middle_snake( + timeout_ms, start_time, old, old_start, old_end, new, new_start, new_end, vf, vb, + )? { conquer( - proxy, old, old_start, x_start, new, new_start, y_start, vf, vb, - ); - conquer(proxy, old, x_start, old_end, new, y_start, new_end, vf, vb); + proxy, + should_use_dj, + timeout_ms, + start_time, + old, + old_start, + x_start, + new, + new_start, + y_start, + vf, + vb, + )?; + conquer( + proxy, + should_use_dj, + timeout_ms, + start_time, + old, + x_start, + old_end, + new, + y_start, + new_end, + vf, + vb, + )?; } else { proxy.delete(old_start, old_end - old_start); proxy.insert(old_start, new_start, new_end - new_start); } } + + Ok(()) +} + +/// Return false if this method gives up early +#[must_use] +pub(crate) fn dj_diff( + proxy: &mut OperateProxy, + old: &[u32], + new: &[u32], + old_offset: usize, + new_offset: usize, + max_try_count: usize, +) -> bool { + let common_prefix_len = common_prefix(old, new); + let common_suffix_len = common_suffix_len(&old[common_prefix_len..], &new[common_prefix_len..]); + let old = &old[common_prefix_len..old.len() - common_suffix_len]; + let new = &new[common_prefix_len..new.len() - common_suffix_len]; + assert!(old.len() <= u16::MAX as usize); + assert!(new.len() <= u16::MAX as usize); + if old.is_empty() { + if new.is_empty() { + return true; + } + + proxy.insert( + old_offset + common_prefix_len, + new_offset + common_prefix_len, + new.len(), + ); + return true; + } + + if new.is_empty() { + proxy.delete(old_offset + common_prefix_len, old.len()); + return true; + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + struct Point { + x: u16, + y: u16, + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + enum Direction { + Up, + Left, + UpLeft, + } + + struct QueueItem { + point: Point, + cost: u32, + from: Direction, + } + + impl PartialEq for QueueItem { + fn eq(&self, other: &Self) -> bool { + self.cost == other.cost + } + } + + impl PartialOrd for QueueItem { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } + } + + impl Ord for QueueItem { + fn cmp(&self, other: &Self) -> Ordering { + other.cost.cmp(&self.cost) + } + } + + impl Eq for QueueItem {} + + let mut visited: FxHashMap = FxHashMap::default(); + let mut q: BinaryHeap = BinaryHeap::new(); + q.push(QueueItem { + point: Point { x: 0, y: 0 }, + cost: 0, + from: Direction::UpLeft, + }); + + while let Some(QueueItem { point, cost, from }) = q.pop() { + if visited.contains_key(&point) { + continue; + } + + visited.insert(point, from); + if point.x == old.len() as u16 && point.y == new.len() as u16 { + break; + } + + if visited.len() + q.len() > max_try_count { + // println!("give up on: visited len: {}", visited.len()); + // println!("queue len: {}", q.len()); + return false; + } + + if point.x < old.len() as u16 { + let next_point = Point { + x: point.x + 1, + y: point.y, + }; + + if !visited.contains_key(&next_point) { + let mut next_cost = cost + 1; + if from != Direction::Left { + next_cost += 8; + } + + q.push(QueueItem { + point: next_point, + cost: next_cost, + from: Direction::Left, + }); + } + } + + if point.y < new.len() as u16 { + let next_point = Point { + x: point.x, + y: point.y + 1, + }; + + if !visited.contains_key(&next_point) { + let direction = Direction::Up; + let mut next_cost = cost + 1; + if from != direction { + next_cost += 8; + } + + q.push(QueueItem { + point: next_point, + cost: next_cost, + from: Direction::Up, + }); + } + } + + if point.x < old.len() as u16 + && point.y < new.len() as u16 + && old[point.x as usize] == new[point.y as usize] + { + let next_point = Point { + x: point.x + 1, + y: point.y + 1, + }; + + if !visited.contains_key(&next_point) { + let next_cost = if from == Direction::UpLeft { + cost + } else { + cost + 1 + }; + + q.push(QueueItem { + point: next_point, + cost: next_cost, + from: Direction::UpLeft, + }); + } + } + } + + // println!("visited len: {}", visited.len()); + // println!("queue len: {}", q.len()); + + // Backtrack from end point to construct diff operations + let mut current = Point { + x: old.len() as u16, + y: new.len() as u16, + }; + + let mut path: Vec<(Direction, usize)> = Vec::new(); + while current.x > 0 || current.y > 0 { + let direction = visited.get(¤t).unwrap(); + if let Some((last_dir, count)) = path.last_mut() { + if last_dir == direction { + *count += 1; + } else { + path.push((*direction, 1)); + } + } else { + path.push((*direction, 1)); + } + match direction { + Direction::Left => { + current.x -= 1; + } + Direction::Up => { + current.y -= 1; + } + Direction::UpLeft => { + current.x -= 1; + current.y -= 1; + } + } + } + + path.reverse(); + + let mut old_index = common_prefix_len; + let mut new_index = common_prefix_len; + + for (direction, count) in path { + match direction { + Direction::Left => { + proxy.delete(old_offset + old_index, count); + old_index += count; + } + Direction::Up => { + proxy.insert(old_offset + old_index, new_index + new_offset, count); + new_index += count; + } + Direction::UpLeft => { + old_index += count; + new_index += count; + } + } + } + + true +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Debug, Default)] + struct RecordingDiffHandler { + ops: Vec, + } + + #[derive(Debug, PartialEq)] + enum DiffOperation { + Insert { + old_index: usize, + new_index: usize, + length: usize, + }, + Delete { + old_index: usize, + length: usize, + }, + } + + impl DiffHandler for RecordingDiffHandler { + fn insert(&mut self, pos: usize, start: usize, len: usize) { + self.ops.push(DiffOperation::Insert { + old_index: pos, + new_index: start, + length: len, + }); + } + + fn delete(&mut self, pos: usize, len: usize) { + self.ops.push(DiffOperation::Delete { + old_index: pos, + length: len, + }); + } + } + + #[test] + fn test_recording_diff_handler() { + let handler = RecordingDiffHandler::default(); + let mut proxy = OperateProxy::new(handler); + let old = vec![1, 2, 3]; + let new = vec![1, 4, 3]; + + let _ = dj_diff(&mut proxy, &old, &new, 0, 0, 100_000); + let handler = proxy.unwrap(); + assert_eq!( + handler.ops, + vec![ + DiffOperation::Delete { + old_index: 1, + length: 1 + }, + DiffOperation::Insert { + old_index: 2, + new_index: 1, + length: 1 + }, + ] + ); + } + + #[test] + fn test_dj_diff_same() { + let handler = RecordingDiffHandler::default(); + let mut proxy = OperateProxy::new(handler); + let old = vec![1, 2, 3]; + let new = vec![1, 2, 3]; + let _ = dj_diff(&mut proxy, &old, &new, 0, 0, 100_000); + let handler = proxy.unwrap(); + assert_eq!(handler.ops, vec![]); + } + + #[test] + fn test_dj_diff_1() { + let handler = RecordingDiffHandler::default(); + let mut proxy = OperateProxy::new(handler); + let old = vec![1]; + let new = vec![0, 1, 2]; + let _ = dj_diff(&mut proxy, &old, &new, 0, 0, 100_000); + let handler = proxy.unwrap(); + assert_eq!( + handler.ops, + vec![ + DiffOperation::Insert { + old_index: 0, + new_index: 0, + length: 1 + }, + DiffOperation::Insert { + old_index: 1, + new_index: 2, + length: 1 + }, + ] + ); + } + + #[test] + fn test_diff_may_scatter() { + let handler = RecordingDiffHandler::default(); + let mut proxy = OperateProxy::new(handler); + let old = vec![1, 2, 3, 4, 5]; + let new = vec![99, 1, 2, 3, 4, 5, 98, 97, 96, 3, 95, 4, 93, 92, 5, 91]; + diff(&mut proxy, UpdateOptions::default(), &old, &new).unwrap(); + let handler = proxy.unwrap(); + assert_eq!( + handler.ops, + vec![ + DiffOperation::Insert { + old_index: 0, + new_index: 0, + length: 1 + }, + DiffOperation::Insert { + old_index: 5, + new_index: 6, + length: 10 + }, + ] + ); + } + + #[test] + fn test_diff_may_scatter_1() { + let handler = RecordingDiffHandler::default(); + let mut proxy = OperateProxy::new(handler); + let old = vec![1, 2, 3, 4, 5]; + let new = vec![99, 1, 2, 98, 97, 96, 3, 95, 4, 93, 92, 5, 1, 2, 3, 4, 5, 91]; + diff(&mut proxy, UpdateOptions::default(), &old, &new).unwrap(); + let handler = proxy.unwrap(); + assert_eq!( + handler.ops, + vec![ + DiffOperation::Insert { + old_index: 0, + new_index: 0, + length: 12 + }, + DiffOperation::Insert { + old_index: 5, + new_index: 17, + length: 1 + }, + ] + ); + } + + #[test] + fn test_dj_diff_may_scatter() { + let handler = RecordingDiffHandler::default(); + let mut proxy = OperateProxy::new(handler); + let old = vec![1, 2, 3, 4, 5]; + let new = vec![99, 1, 2, 3, 4, 5, 98, 97, 96, 3, 95, 4, 93, 92, 5, 91]; + let _ = dj_diff(&mut proxy, &old, &new, 0, 0, 100_000); + let handler = proxy.unwrap(); + assert_eq!( + handler.ops, + vec![ + DiffOperation::Insert { + old_index: 0, + new_index: 0, + length: 1 + }, + DiffOperation::Insert { + old_index: 5, + new_index: 6, + length: 10 + }, + ] + ); + } + + #[test] + fn test_dj_diff_may_scatter_1() { + let handler = RecordingDiffHandler::default(); + let mut proxy = OperateProxy::new(handler); + let old = vec![1, 2, 3, 4, 5]; + let new = vec![99, 1, 2, 98, 97, 96, 3, 95, 4, 93, 92, 5, 1, 2, 3, 4, 5, 91]; + let _ = dj_diff(&mut proxy, &old, &new, 0, 0, 100_000); + let handler = proxy.unwrap(); + assert_eq!( + handler.ops, + vec![ + DiffOperation::Insert { + old_index: 0, + new_index: 0, + length: 12 + }, + DiffOperation::Insert { + old_index: 5, + new_index: 17, + length: 1 + }, + ] + ); + } + + #[test] + fn test_dj_diff_100_differences() { + let handler = RecordingDiffHandler::default(); + let mut proxy = OperateProxy::new(handler); + let old = vec![1; 100]; + let new = vec![2; 100]; + let _ = dj_diff(&mut proxy, &old, &new, 0, 0, 100_000); + let handler = proxy.unwrap(); + assert_eq!( + handler.ops, + vec![ + DiffOperation::Delete { + old_index: 0, + length: 100 + }, + DiffOperation::Insert { + old_index: 100, + new_index: 0, + length: 100 + }, + ] + ); + } + + #[test] + fn test_dj_diff_insert() { + let handler = RecordingDiffHandler::default(); + let mut proxy = OperateProxy::new(handler); + let old = vec![1; 100]; + let mut new = old.clone(); + new.splice(50..50, [1, 2, 3, 4, 1, 2, 3, 4]); + new.splice(0..0, [0, 1, 2, 3, 4, 5, 6, 7]); + let _ = dj_diff(&mut proxy, &old, &new, 0, 0, 100_000); + let handler = proxy.unwrap(); + assert_eq!( + handler.ops, + vec![ + DiffOperation::Insert { + old_index: 0, + new_index: 0, + length: 9 + }, + DiffOperation::Insert { + old_index: 50, + new_index: 59, + length: 7 + } + ] + ); + } + + #[test] + fn test_diff() { + let handler = RecordingDiffHandler::default(); + let mut proxy = OperateProxy::new(handler); + let old = vec![1; 100]; + let new = vec![2; 100]; + diff(&mut proxy, Default::default(), &old, &new).unwrap(); + } + + #[test] + fn test_timeout() { + let handler = RecordingDiffHandler::default(); + let mut proxy = OperateProxy::new(handler); + let old = vec![1; 10000]; + let new = vec![2; 10000]; + let options = UpdateOptions { + timeout_ms: Some(0.1), + ..Default::default() + }; + let result = diff(&mut proxy, options, &old, &new); + assert!(result.is_err()); + } } diff --git a/crates/loro-internal/src/diff/mod.rs b/crates/loro-internal/src/diff/mod.rs index beef71c9..5d9d5cb7 100644 --- a/crates/loro-internal/src/diff/mod.rs +++ b/crates/loro-internal/src/diff/mod.rs @@ -1,4 +1,4 @@ pub mod diff_impl; -pub(crate) use diff_impl::myers_diff; +pub(crate) use diff_impl::diff; pub(crate) use diff_impl::DiffHandler; pub(crate) use diff_impl::OperateProxy; diff --git a/crates/loro-internal/src/handler.rs b/crates/loro-internal/src/handler.rs index 172e2fa7..b9087631 100644 --- a/crates/loro-internal/src/handler.rs +++ b/crates/loro-internal/src/handler.rs @@ -8,7 +8,7 @@ use crate::{ }, cursor::{Cursor, Side}, delta::{DeltaItem, Meta, StyleMeta, TreeExternalDiff}, - diff::{myers_diff, OperateProxy}, + diff::{diff, diff_impl::UpdateTimeoutError, OperateProxy}, event::{Diff, TextDiffItem}, op::ListSlice, state::{IndexType, State, TreeParentId}, @@ -34,6 +34,7 @@ use std::{ }; use tracing::{error, info, instrument, trace}; +pub use crate::diff::diff_impl::UpdateOptions; pub use tree::TreeHandler; mod movable_list_apply_delta; mod tree; @@ -2145,25 +2146,33 @@ impl TextHandler { Ok(()) } - #[instrument(level = "trace", skip(self))] - pub fn update(&self, text: &str) { + pub fn update(&self, text: &str, options: UpdateOptions) -> Result<(), UpdateTimeoutError> { let old_str = self.to_string(); let new = text.chars().map(|x| x as u32).collect::>(); - myers_diff( + let old = old_str.chars().map(|x| x as u32).collect::>(); + diff( &mut OperateProxy::new(text_update::DiffHook::new(self, &new)), - &old_str.chars().map(|x| x as u32).collect::>(), + options, + &old, &new, - ); + )?; + Ok(()) } - #[instrument(level = "trace", skip(self))] - pub fn update_by_line(&self, text: &str) { + pub fn update_by_line( + &self, + text: &str, + options: UpdateOptions, + ) -> Result<(), UpdateTimeoutError> { let hook = text_update::DiffHookForLine::new(self, text); let old_lines = hook.get_old_arr().to_vec(); let new_lines = hook.get_new_arr().to_vec(); - trace!("old_lines: {:?}", old_lines); - trace!("new_lines: {:?}", new_lines); - myers_diff(&mut OperateProxy::new(hook), &old_lines, &new_lines); + diff( + &mut OperateProxy::new(hook), + options, + &old_lines, + &new_lines, + ) } #[allow(clippy::inherent_to_string)] diff --git a/crates/loro-internal/src/oplog.rs b/crates/loro-internal/src/oplog.rs index 6b5f4a29..20e427c7 100644 --- a/crates/loro-internal/src/oplog.rs +++ b/crates/loro-internal/src/oplog.rs @@ -499,7 +499,7 @@ impl OpLog { pub fn get_timestamp_for_next_txn(&self) -> Timestamp { if self.configure.record_timestamp() { - (get_sys_timestamp() + 500) / 1000 + (get_sys_timestamp() as Timestamp + 500) / 1000 } else { 0 } diff --git a/crates/loro-internal/src/undo.rs b/crates/loro-internal/src/undo.rs index a43d804c..3b418c7e 100644 --- a/crates/loro-internal/src/undo.rs +++ b/crates/loro-internal/src/undo.rs @@ -11,7 +11,7 @@ use loro_common::{ use tracing::{debug_span, info_span, instrument}; use crate::{ - change::get_sys_timestamp, + change::{get_sys_timestamp, Timestamp}, cursor::{AbsolutePosition, Cursor}, delta::TreeExternalDiff, event::{Diff, EventTriggerKind}, @@ -397,7 +397,7 @@ impl UndoManagerInner { } assert!(self.next_counter.unwrap() < latest_counter); - let now = get_sys_timestamp(); + let now = get_sys_timestamp() as Timestamp; let span = CounterSpan::new(self.next_counter.unwrap(), latest_counter); let meta = self .on_push diff --git a/crates/loro-internal/tests/test.rs b/crates/loro-internal/tests/test.rs index e259b9f9..b8762967 100644 --- a/crates/loro-internal/tests/test.rs +++ b/crates/loro-internal/tests/test.rs @@ -1238,7 +1238,8 @@ fn test_text_update() { let doc = LoroDoc::new_auto_commit(); let text = doc.get_text("text"); text.insert(0, "Hello 😊Bro").unwrap(); - text.update("Hello World Bro😊"); + text.update("Hello World Bro😊", Default::default()) + .unwrap(); assert_eq!(text.to_string(), "Hello World Bro😊"); } diff --git a/crates/loro-wasm/src/lib.rs b/crates/loro-wasm/src/lib.rs index 7e3c8227..f6cf97ec 100644 --- a/crates/loro-wasm/src/lib.rs +++ b/crates/loro-wasm/src/lib.rs @@ -13,6 +13,7 @@ use loro_internal::{ cursor::{self, Side}, encoding::ImportBlobMetadata, event::Index, + handler::UpdateOptions, handler::{ Handler, ListHandler, MapHandler, TextDelta, TextHandler, TreeHandler, ValueOrHandler, }, @@ -1824,16 +1825,62 @@ impl LoroText { /// text.update("Hello World"); /// console.log(text.toString()); // "Hello World" /// ``` - pub fn update(&self, text: &str) { - self.handler.update(text); + /// + #[wasm_bindgen(skip_typescript)] + pub fn update(&self, text: &str, options: JsValue) -> JsResult<()> { + let options = if options.is_null() || options.is_undefined() { + UpdateOptions { + timeout_ms: None, + use_refined_diff: true, + } + } else { + let opts = match js_sys::Object::try_from(&options) { + Some(o) => o, + None => return Err(JsError::new("Invalid options").into()), + }; + UpdateOptions { + timeout_ms: js_sys::Reflect::get(opts, &"timeoutMs".into()) + .ok() + .and_then(|v| v.as_f64()), + use_refined_diff: js_sys::Reflect::get(opts, &"useRefinedDiff".into()) + .ok() + .and_then(|v| v.as_bool()) + .unwrap_or(true), + } + }; + self.handler + .update(text, options) + .map_err(|_| JsError::new("Update timeout").into()) } /// Update the current text to the target text, the difference is calculated line by line. /// /// It uses Myers' diff algorithm to compute the optimal difference. - #[wasm_bindgen(js_name = "updateByLine")] - pub fn update_by_line(&self, text: &str) { - self.handler.update_by_line(text); + #[wasm_bindgen(js_name = "updateByLine", skip_typescript)] + pub fn update_by_line(&self, text: &str, options: JsValue) -> JsResult<()> { + let options = if options.is_null() || options.is_undefined() { + UpdateOptions { + timeout_ms: None, + use_refined_diff: true, + } + } else { + let opts = match js_sys::Object::try_from(&options) { + Some(o) => o, + None => return Err(JsError::new("Invalid options").into()), + }; + UpdateOptions { + timeout_ms: js_sys::Reflect::get(opts, &"timeoutMs".into()) + .ok() + .and_then(|v| v.as_f64()), + use_refined_diff: js_sys::Reflect::get(opts, &"useRefinedDiff".into()) + .ok() + .and_then(|v| v.as_bool()) + .unwrap_or(true), + } + }; + self.handler + .update_by_line(text, options) + .map_err(|_| JsError::new("Update timeout").into()) } /// Insert the string at the given index (utf-16 index). @@ -4849,6 +4896,11 @@ export type JsonChange = { ops: JsonOp[] } +export interface TextUpdateOptions { + timeoutMs?: number, + useRefinedDiff?: boolean, +} + export type ExportMode = { mode: "update", from?: VersionVector, @@ -5429,6 +5481,32 @@ interface LoroText { insert(pos: number, text: string): void; delete(pos: number, len: number): void; subscribe(listener: Listener): Subscription; + /** + * Update the current text to the target text. + * + * It will calculate the minimal difference and apply it to the current text. + * It uses Myers' diff algorithm to compute the optimal difference. + * + * This could take a long time for large texts (e.g. > 50_000 characters). + * In that case, you should use `updateByLine` instead. + * + * @example + * ```ts + * import { LoroDoc } from "loro-crdt"; + * + * const doc = new LoroDoc(); + * const text = doc.getText("text"); + * text.insert(0, "Hello"); + * text.update("Hello World"); + * console.log(text.toString()); // "Hello World" + * ``` + */ + update(text: string, options?: TextUpdateOptions): void; + /** + * Update the current text based on the provided text. + * This update calculation is line-based, which will be more efficient but less precise. + */ + updateByLine(text: string, options?: TextUpdateOptions): void; } interface LoroTree = Record> { new(): LoroTree; diff --git a/crates/loro/src/lib.rs b/crates/loro/src/lib.rs index 4b1371a9..03e83b04 100644 --- a/crates/loro/src/lib.rs +++ b/crates/loro/src/lib.rs @@ -29,6 +29,8 @@ use std::ops::Range; use std::sync::Arc; use tracing::info; +pub use loro_internal::diff::diff_impl::UpdateOptions; +pub use loro_internal::diff::diff_impl::UpdateTimeoutError; pub use loro_internal::subscription::LocalUpdateCallback; pub use loro_internal::subscription::PeerIdUpdateCallback; pub use loro_internal::ChangeMeta; @@ -1445,13 +1447,37 @@ impl LoroText { } /// Update the current text based on the provided text. - pub fn update(&self, text: &str) { - self.handler.update(text); + /// + /// It will calculate the minimal difference and apply it to the current text. + /// It uses Myers' diff algorithm to compute the optimal difference. + /// + /// This could take a long time for large texts (e.g. > 50_000 characters). + /// In that case, you should use `updateByLine` instead. + /// + /// # Example + /// ```rust + /// use loro::LoroDoc; + /// + /// let doc = LoroDoc::new(); + /// let text = doc.get_text("text"); + /// text.insert(0, "Hello").unwrap(); + /// text.update("Hello World", Default::default()).unwrap(); + /// assert_eq!(text.to_string(), "Hello World"); + /// ``` + /// + pub fn update(&self, text: &str, options: UpdateOptions) -> Result<(), UpdateTimeoutError> { + self.handler.update(text, options) } - /// Update the current text based on the provided text by line. - pub fn update_by_line(&self, text: &str) { - self.handler.update_by_line(text); + /// Update the current text based on the provided text. + /// + /// This update calculation is line-based, which will be more efficient but less precise. + pub fn update_by_line( + &self, + text: &str, + options: UpdateOptions, + ) -> Result<(), UpdateTimeoutError> { + self.handler.update_by_line(text, options) } /// Apply a [delta](https://quilljs.com/docs/delta/) to the text container. diff --git a/crates/loro/tests/integration_test/text_update_test.rs b/crates/loro/tests/integration_test/text_update_test.rs index cd3fb8f2..3fbb0db2 100644 --- a/crates/loro/tests/integration_test/text_update_test.rs +++ b/crates/loro/tests/integration_test/text_update_test.rs @@ -5,11 +5,11 @@ fn test_text_update() -> anyhow::Result<()> { let (old, new, new1) = (")+++", "%", ""); let doc = LoroDoc::new(); let text = doc.get_text("text"); - text.update(old); + text.update(old, Default::default()).unwrap(); assert_eq!(&text.to_string(), old); - text.update(new); + text.update(new, Default::default()).unwrap(); assert_eq!(&text.to_string(), new); - text.update(new1); + text.update(new1, Default::default()).unwrap(); assert_eq!(&text.to_string(), new1); Ok(()) } @@ -23,11 +23,11 @@ fn test_text_update_by_line() -> anyhow::Result<()> { ); let doc = LoroDoc::new(); let text = doc.get_text("text"); - text.update_by_line(old); + text.update_by_line(old, Default::default()).unwrap(); assert_eq!(&text.to_string(), old); - text.update_by_line(new); + text.update_by_line(new, Default::default()).unwrap(); assert_eq!(&text.to_string(), new); - text.update_by_line(new1); + text.update_by_line(new1, Default::default()).unwrap(); assert_eq!(&text.to_string(), new1); Ok(()) } @@ -36,9 +36,9 @@ fn test_text_update_by_line() -> anyhow::Result<()> { fn test_text_update_empty_to_nonempty() -> anyhow::Result<()> { let doc = LoroDoc::new(); let text = doc.get_text("text"); - text.update(""); + text.update("", Default::default()).unwrap(); assert_eq!(&text.to_string(), ""); - text.update("Hello, Loro!"); + text.update("Hello, Loro!", Default::default()).unwrap(); assert_eq!(&text.to_string(), "Hello, Loro!"); Ok(()) } @@ -47,9 +47,9 @@ fn test_text_update_empty_to_nonempty() -> anyhow::Result<()> { fn test_text_update_nonempty_to_empty() -> anyhow::Result<()> { let doc = LoroDoc::new(); let text = doc.get_text("text"); - text.update("Initial content"); + text.update("Initial content", Default::default()).unwrap(); assert_eq!(&text.to_string(), "Initial content"); - text.update(""); + text.update("", Default::default()).unwrap(); assert_eq!(&text.to_string(), ""); Ok(()) } @@ -58,9 +58,11 @@ fn test_text_update_nonempty_to_empty() -> anyhow::Result<()> { fn test_text_update_with_special_characters() -> anyhow::Result<()> { let doc = LoroDoc::new(); let text = doc.get_text("text"); - text.update("Special chars: !@#$%^&*()"); + text.update("Special chars: !@#$%^&*()", Default::default()) + .unwrap(); assert_eq!(&text.to_string(), "Special chars: !@#$%^&*()"); - text.update("New special chars: ñáéíóú"); + text.update("New special chars: ñáéíóú", Default::default()) + .unwrap(); assert_eq!(&text.to_string(), "New special chars: ñáéíóú"); Ok(()) } @@ -69,9 +71,11 @@ fn test_text_update_with_special_characters() -> anyhow::Result<()> { fn test_text_update_by_line_with_empty_lines() -> anyhow::Result<()> { let doc = LoroDoc::new(); let text = doc.get_text("text"); - text.update_by_line("Line 1\n\nLine 3\n"); + text.update_by_line("Line 1\n\nLine 3\n", Default::default()) + .unwrap(); assert_eq!(&text.to_string(), "Line 1\n\nLine 3\n"); - text.update_by_line("Line 1\nLine 2\n\nLine 4\n"); + text.update_by_line("Line 1\nLine 2\n\nLine 4\n", Default::default()) + .unwrap(); assert_eq!(&text.to_string(), "Line 1\nLine 2\n\nLine 4\n"); Ok(()) } @@ -80,9 +84,11 @@ fn test_text_update_by_line_with_empty_lines() -> anyhow::Result<()> { fn test_text_update_by_line_with_different_line_endings() -> anyhow::Result<()> { let doc = LoroDoc::new(); let text = doc.get_text("text"); - text.update_by_line("Line 1\nLine 2\r\nLine 3\n"); + text.update_by_line("Line 1\nLine 2\r\nLine 3\n", Default::default()) + .unwrap(); assert_eq!(&text.to_string(), "Line 1\nLine 2\r\nLine 3\n"); - text.update_by_line("Line 1\r\nLine 2\nLine 3\r\n"); + text.update_by_line("Line 1\r\nLine 2\nLine 3\r\n", Default::default()) + .unwrap(); assert_eq!(&text.to_string(), "Line 1\r\nLine 2\nLine 3\r\n"); Ok(()) } @@ -91,9 +97,11 @@ fn test_text_update_by_line_with_different_line_endings() -> anyhow::Result<()> fn test_text_update_by_line_with_no_trailing_newline() -> anyhow::Result<()> { let doc = LoroDoc::new(); let text = doc.get_text("text"); - text.update_by_line("Line 1\nLine 2\nLine 3"); + text.update_by_line("Line 1\nLine 2\nLine 3", Default::default()) + .unwrap(); assert_eq!(&text.to_string(), "Line 1\nLine 2\nLine 3"); - text.update_by_line("Line 1\nLine 2\nLine 3\nLine 4"); + text.update_by_line("Line 1\nLine 2\nLine 3\nLine 4", Default::default()) + .unwrap(); assert_eq!(&text.to_string(), "Line 1\nLine 2\nLine 3\nLine 4"); Ok(()) } @@ -114,10 +122,10 @@ fn test_failed_case_0() -> anyhow::Result<()> { let (old, new, new1) = (input[0], input[1], input[2]); let doc = LoroDoc::new(); let text = doc.get_text("text"); - text.update(old); - text.update(new); + text.update(old, Default::default()).unwrap(); + text.update(new, Default::default()).unwrap(); assert_eq!(&text.to_string(), new); - text.update_by_line(new1); + text.update_by_line(new1, Default::default()).unwrap(); assert_eq!(&text.to_string(), new1); Ok(()) } @@ -128,10 +136,10 @@ fn test_failed_case_1() -> anyhow::Result<()> { let (old, new, new1) = (input[0], input[1], input[2]); let doc = LoroDoc::new(); let text = doc.get_text("text"); - text.update(old); - text.update(new); + text.update(old, Default::default()).unwrap(); + text.update(new, Default::default()).unwrap(); assert_eq!(&text.to_string(), new); - text.update_by_line(new1); + text.update_by_line(new1, Default::default()).unwrap(); assert_eq!(&text.to_string(), new1); Ok(()) } @@ -142,10 +150,10 @@ fn test_failed_case_2() -> anyhow::Result<()> { let (old, new, new1) = (input[0], input[1], input[2]); let doc = LoroDoc::new(); let text = doc.get_text("text"); - text.update(old); - text.update(new); + text.update(old, Default::default()).unwrap(); + text.update(new, Default::default()).unwrap(); assert_eq!(&text.to_string(), new); - text.update_by_line(new1); + text.update_by_line(new1, Default::default()).unwrap(); assert_eq!(&text.to_string(), new1); Ok(()) }