From 321e0e72a46d4947e4809e0bcee32bf0b4a6bc0a Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Tue, 21 May 2024 06:14:49 +0800 Subject: [PATCH] Undo (#361) https://github.com/loro-dev/loro/pull/361 --------- Co-authored-by: Leon Zhao --- Cargo.lock | 37 +- crates/delta/fuzz/.gitignore | 4 + crates/delta/fuzz/.vscode/settings.json | 6 + crates/delta/fuzz/Cargo.lock | 1002 +++++++++++ crates/delta/fuzz/Cargo.toml | 31 + crates/delta/fuzz/fuzz_targets/ot.rs | 6 + crates/delta/fuzz/src/lib.rs | 159 ++ crates/delta/src/delta_item.rs | 3 +- crates/delta/src/delta_rope.rs | 60 +- crates/delta/src/delta_rope/compose.rs | 2 - crates/delta/src/iter.rs | 29 + crates/delta/src/lib.rs | 30 + crates/examples/src/lib.rs | 2 +- crates/fuzz/Cargo.toml | 9 +- crates/fuzz/fuzz/Cargo.lock | 32 +- crates/fuzz/src/actions.rs | 21 + crates/fuzz/src/actor.rs | 53 +- crates/fuzz/src/container/list.rs | 2 +- crates/fuzz/src/container/tree.rs | 3 +- crates/fuzz/src/crdt_fuzzer.rs | 42 +- crates/fuzz/tests/test.rs | 60 +- crates/fuzz/tests/undo.rs | 122 ++ crates/loro-common/src/error.rs | 10 +- crates/loro-internal/benches/event.rs | 4 +- crates/loro-internal/examples/event.rs | 2 +- crates/loro-internal/src/delta/map_delta.rs | 8 + crates/loro-internal/src/delta/text.rs | 9 +- crates/loro-internal/src/delta/tree.rs | 126 +- crates/loro-internal/src/event.rs | 41 + crates/loro-internal/src/handler.rs | 229 ++- crates/loro-internal/src/handler/tree.rs | 65 +- crates/loro-internal/src/lib.rs | 3 + crates/loro-internal/src/loro.rs | 212 ++- crates/loro-internal/src/op/content.rs | 43 + crates/loro-internal/src/oplog.rs | 38 + crates/loro-internal/src/parent.rs | 46 +- crates/loro-internal/src/state.rs | 11 +- .../src/state/movable_list_state.rs | 6 +- .../loro-internal/src/state/richtext_state.rs | 6 +- crates/loro-internal/src/state/tree_state.rs | 24 +- crates/loro-internal/src/undo.rs | 490 ++++++ crates/loro-internal/src/value.rs | 3 +- crates/loro-internal/tests/autocommit.rs | 8 +- crates/loro-wasm/src/awareness.rs | 2 + crates/loro-wasm/src/convert.rs | 2 + crates/loro-wasm/src/lib.rs | 90 +- crates/loro/Cargo.toml | 1 + crates/loro/src/lib.rs | 51 +- crates/loro/tests/integration_test/mod.rs | 1 + .../loro/tests/integration_test/undo_test.rs | 1554 +++++++++++++++++ crates/loro/tests/loro_rust_test.rs | 2 + loro-js/src/index.ts | 44 +- loro-js/tests/undo.test.ts | 81 + 53 files changed, 4757 insertions(+), 170 deletions(-) create mode 100644 crates/delta/fuzz/.gitignore create mode 100644 crates/delta/fuzz/.vscode/settings.json create mode 100644 crates/delta/fuzz/Cargo.lock create mode 100644 crates/delta/fuzz/Cargo.toml create mode 100644 crates/delta/fuzz/fuzz_targets/ot.rs create mode 100644 crates/delta/fuzz/src/lib.rs create mode 100644 crates/fuzz/tests/undo.rs create mode 100644 crates/loro-internal/src/undo.rs create mode 100644 crates/loro/tests/integration_test/mod.rs create mode 100644 crates/loro/tests/integration_test/undo_test.rs create mode 100644 loro-js/tests/undo.test.ts diff --git a/Cargo.lock b/Cargo.lock index cc5c92c3d..09d28a61d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -44,6 +44,12 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8901269c6307e8d93993578286ac0edf7f195079ffff5ebdeea6a59ffb7e36bc" +[[package]] +name = "anyhow" +version = "1.0.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25bdb32cbbdce2b519a9cd7df3a678443100e265d5e25ca763b7572a5104f5f3" + [[package]] name = "append-only-bytes" version = "0.1.12" @@ -647,7 +653,7 @@ dependencies = [ [[package]] name = "fractional_index" version = "0.1.0" -source = "git+https://github.com/loro-dev/loro.git?branch=leon/feat-encode-forward#c93a80dbff7ead8afb466884642dbef49f9e16b4" +source = "git+https://github.com/loro-dev/loro.git?rev=0dade6bc0fb574a8190db2aa80c83a479f62e125#0dade6bc0fb574a8190db2aa80c83a479f62e125" dependencies = [ "imbl", "rand", @@ -678,8 +684,8 @@ dependencies = [ "fxhash", "itertools 0.12.1", "loro 0.5.1", - "loro 0.5.1 (git+https://github.com/loro-dev/loro.git?branch=leon/feat-encode-forward)", - "loro-internal 0.5.1", + "loro 0.5.1 (git+https://github.com/loro-dev/loro.git?rev=0dade6bc0fb574a8190db2aa80c83a479f62e125)", + "loro-common 0.5.1 (git+https://github.com/loro-dev/loro.git?rev=0dade6bc0fb574a8190db2aa80c83a479f62e125)", "rand", "tabled 0.10.0", "tracing", @@ -1000,6 +1006,7 @@ dependencies = [ name = "loro" version = "0.5.1" dependencies = [ + "anyhow", "ctor 0.2.6", "dev-utils", "either", @@ -1014,13 +1021,13 @@ dependencies = [ [[package]] name = "loro" version = "0.5.1" -source = "git+https://github.com/loro-dev/loro.git?branch=leon/feat-encode-forward#c93a80dbff7ead8afb466884642dbef49f9e16b4" +source = "git+https://github.com/loro-dev/loro.git?rev=0dade6bc0fb574a8190db2aa80c83a479f62e125#0dade6bc0fb574a8190db2aa80c83a479f62e125" dependencies = [ "either", "enum-as-inner 0.6.0", "generic-btree", - "loro-delta 0.5.1 (git+https://github.com/loro-dev/loro.git?branch=leon/feat-encode-forward)", - "loro-internal 0.5.1 (git+https://github.com/loro-dev/loro.git?branch=leon/feat-encode-forward)", + "loro-delta 0.5.1 (git+https://github.com/loro-dev/loro.git?rev=0dade6bc0fb574a8190db2aa80c83a479f62e125)", + "loro-internal 0.5.1 (git+https://github.com/loro-dev/loro.git?rev=0dade6bc0fb574a8190db2aa80c83a479f62e125)", "tracing", ] @@ -1044,12 +1051,12 @@ dependencies = [ [[package]] name = "loro-common" version = "0.5.1" -source = "git+https://github.com/loro-dev/loro.git?branch=leon/feat-encode-forward#c93a80dbff7ead8afb466884642dbef49f9e16b4" +source = "git+https://github.com/loro-dev/loro.git?rev=0dade6bc0fb574a8190db2aa80c83a479f62e125#0dade6bc0fb574a8190db2aa80c83a479f62e125" dependencies = [ "arbitrary", "enum-as-inner 0.6.0", "fxhash", - "loro-rle 0.5.1 (git+https://github.com/loro-dev/loro.git?branch=leon/feat-encode-forward)", + "loro-rle 0.5.1 (git+https://github.com/loro-dev/loro.git?rev=0dade6bc0fb574a8190db2aa80c83a479f62e125)", "nonmax", "serde", "serde_columnar", @@ -1076,7 +1083,7 @@ dependencies = [ [[package]] name = "loro-delta" version = "0.5.1" -source = "git+https://github.com/loro-dev/loro.git?branch=leon/feat-encode-forward#c93a80dbff7ead8afb466884642dbef49f9e16b4" +source = "git+https://github.com/loro-dev/loro.git?rev=0dade6bc0fb574a8190db2aa80c83a479f62e125#0dade6bc0fb574a8190db2aa80c83a479f62e125" dependencies = [ "arrayvec", "enum-as-inner 0.5.1", @@ -1139,23 +1146,23 @@ dependencies = [ [[package]] name = "loro-internal" version = "0.5.1" -source = "git+https://github.com/loro-dev/loro.git?branch=leon/feat-encode-forward#c93a80dbff7ead8afb466884642dbef49f9e16b4" +source = "git+https://github.com/loro-dev/loro.git?rev=0dade6bc0fb574a8190db2aa80c83a479f62e125#0dade6bc0fb574a8190db2aa80c83a479f62e125" dependencies = [ "append-only-bytes", "arref", "either", "enum-as-inner 0.5.1", "enum_dispatch", - "fractional_index 0.1.0 (git+https://github.com/loro-dev/loro.git?branch=leon/feat-encode-forward)", + "fractional_index 0.1.0 (git+https://github.com/loro-dev/loro.git?rev=0dade6bc0fb574a8190db2aa80c83a479f62e125)", "fxhash", "generic-btree", "getrandom", "im", "itertools 0.12.1", "leb128", - "loro-common 0.5.1 (git+https://github.com/loro-dev/loro.git?branch=leon/feat-encode-forward)", - "loro-delta 0.5.1 (git+https://github.com/loro-dev/loro.git?branch=leon/feat-encode-forward)", - "loro-rle 0.5.1 (git+https://github.com/loro-dev/loro.git?branch=leon/feat-encode-forward)", + "loro-common 0.5.1 (git+https://github.com/loro-dev/loro.git?rev=0dade6bc0fb574a8190db2aa80c83a479f62e125)", + "loro-delta 0.5.1 (git+https://github.com/loro-dev/loro.git?rev=0dade6bc0fb574a8190db2aa80c83a479f62e125)", + "loro-rle 0.5.1 (git+https://github.com/loro-dev/loro.git?rev=0dade6bc0fb574a8190db2aa80c83a479f62e125)", "md5", "num", "num-derive", @@ -1191,7 +1198,7 @@ dependencies = [ [[package]] name = "loro-rle" version = "0.5.1" -source = "git+https://github.com/loro-dev/loro.git?branch=leon/feat-encode-forward#c93a80dbff7ead8afb466884642dbef49f9e16b4" +source = "git+https://github.com/loro-dev/loro.git?rev=0dade6bc0fb574a8190db2aa80c83a479f62e125#0dade6bc0fb574a8190db2aa80c83a479f62e125" dependencies = [ "append-only-bytes", "arref", diff --git a/crates/delta/fuzz/.gitignore b/crates/delta/fuzz/.gitignore new file mode 100644 index 000000000..1a45eee77 --- /dev/null +++ b/crates/delta/fuzz/.gitignore @@ -0,0 +1,4 @@ +target +corpus +artifacts +coverage diff --git a/crates/delta/fuzz/.vscode/settings.json b/crates/delta/fuzz/.vscode/settings.json new file mode 100644 index 000000000..bdb8895e5 --- /dev/null +++ b/crates/delta/fuzz/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "rust-analyzer.runnableEnv": { + "RUST_BACKTRACE": "full", + "DEBUG": "*" + }, +} diff --git a/crates/delta/fuzz/Cargo.lock b/crates/delta/fuzz/Cargo.lock new file mode 100644 index 000000000..0e11c3881 --- /dev/null +++ b/crates/delta/fuzz/Cargo.lock @@ -0,0 +1,1002 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "addr2line" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "arbitrary" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5a26814d8dcb93b0e5a0ff3c6d80a8843bafb21b39e8e18a6f05471870e110" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "arrayvec" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711" + +[[package]] +name = "arref" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ccd462b64c3c72f1be8305905a85d85403d768e8690c9b8bd3b9009a5761679" + +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + +[[package]] +name = "autocfg" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" + +[[package]] +name = "backtrace" +version = "0.3.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b05800d2e817c8b3b4b54abd461726265fa9789ae34330622f2db9ee696f9d" +dependencies = [ + "addr2line", + "cc", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", +] + +[[package]] +name = "bumpalo" +version = "3.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cc" +version = "1.0.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41c270e7540d725e65ac7f1b212ac8ce349719624d7bcff99f8e2e488e8cf03f" +dependencies = [ + "jobserver", + "libc", + "once_cell", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-targets 0.52.5", +] + +[[package]] +name = "color-backtrace" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "150fd80a270c0671379f388c8204deb6a746bb4eac8a6c03fe2460b2c0127ea0" +dependencies = [ + "backtrace", + "termcolor", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" + +[[package]] +name = "critical-section" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7059fff8937831a9ae6f0fe4d658ffabf58f2ca96aa9dec1c889f936f705f216" + +[[package]] +name = "ctor" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edb49164822f3ee45b17acd4a208cfc1251410cf0cad9a833234c9890774dd9f" +dependencies = [ + "quote", + "syn 2.0.65", +] + +[[package]] +name = "derive_arbitrary" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67e77553c4162a157adbf834ebae5b415acbecbeafc7a74b0e886657506a7611" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.65", +] + +[[package]] +name = "dev-utils" +version = "0.1.0" +dependencies = [ + "chrono", + "color-backtrace", + "rand", + "tracing", + "tracing-chrome", + "tracing-subscriber", + "tracing-tree", +] + +[[package]] +name = "either" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dca9240753cf90908d7e4aac30f630662b02aebaa1b58a3cadabdb23385b58b" + +[[package]] +name = "enum-as-inner" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9720bba047d567ffc8a3cba48bf19126600e249ab7f128e9233e6376976a116" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "generic-btree" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "210507e6dec78bb1304e52a174bd99efdd83894219bf20d656a066a0ce2fedc5" +dependencies = [ + "arref", + "fxhash", + "heapless 0.7.17", + "itertools", + "loro-thunderdome", + "proc-macro2", +] + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "gimli" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" + +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32 0.2.1", + "rustc_version", + "spin", + "stable_deref_trait", +] + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32 0.3.1", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "iana-time-zone" +version = "0.1.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" + +[[package]] +name = "jobserver" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2b099aaa34a9751c5bf0878add70444e1ed2dd73f347be99003d4577277de6e" +dependencies = [ + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "libc" +version = "0.2.155" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96cfd5557eb82f2b83fed4955246c988d331975a002961b07c81584d107e7f7" +dependencies = [ + "arbitrary", + "cc", + "once_cell", +] + +[[package]] +name = "lock_api" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" + +[[package]] +name = "loro-delta" +version = "0.5.1" +dependencies = [ + "arrayvec", + "enum-as-inner", + "generic-btree", + "heapless 0.8.0", + "tracing", +] + +[[package]] +name = "loro-delta-fuzz" +version = "0.0.0" +dependencies = [ + "arbitrary", + "ctor", + "dev-utils", + "libfuzzer-sys", + "loro-delta", + "tracing", +] + +[[package]] +name = "loro-thunderdome" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3d053a135388e6b1df14e8af1212af5064746e9b87a06a345a7a779ee9695a" + +[[package]] +name = "memchr" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8640c5d730cb13ebd907d8d04b52f55ac9a2eec55b440c8892f40d56c76c1d" + +[[package]] +name = "miniz_oxide" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87dfd01fe195c66b572b37921ad8803d010623c0aca821bea2302239d155cdae" +dependencies = [ + "adler", +] + +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c073d3c1930d0751774acf49e66653acecb416c3a54c6ec095a9b11caddb5a68" +dependencies = [ + "windows-sys 0.48.0", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "object" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + +[[package]] +name = "pin-project-lite" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" + +[[package]] +name = "ppv-lite86" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" + +[[package]] +name = "proc-macro2" +version = "1.0.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b33eb56c327dec362a9e55b3ad14f9d2f0904fb5a5b03b513ab5465399e9f43" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" + +[[package]] +name = "rustc_version" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366" +dependencies = [ + "semver", +] + +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" + +[[package]] +name = "serde" +version = "1.0.202" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "226b61a0d411b2ba5ff6d7f73a476ac4f8bb900373459cd00fab8512828ba395" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.202" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6048858004bcff69094cd972ed40a32500f153bd3be9f716b2eed2e8217c4838" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.65", +] + +[[package]] +name = "serde_json" +version = "1.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "455182ea6142b14f93f4bc5320a2b31c1f266b66a4a5c858b013302a5d8cbfc3" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "smallvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2863d96a84c6439701d7a38f9de935ec562c8832cc55d1dde0f513b52fad106" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thread_local" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +dependencies = [ + "cfg-if", + "once_cell", +] + +[[package]] +name = "tracing" +version = "0.1.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.65", +] + +[[package]] +name = "tracing-chrome" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf0a738ed5d6450a9fb96e86a23ad808de2b727fd1394585da5cdd6788ffe724" +dependencies = [ + "serde_json", + "tracing-core", + "tracing-subscriber", +] + +[[package]] +name = "tracing-core" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" +dependencies = [ + "nu-ansi-term 0.46.0", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tracing-tree" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65139ecd2c3f6484c3b99bc01c77afe21e95473630747c7aca525e78b0666675" +dependencies = [ + "nu-ansi-term 0.49.0", + "tracing-core", + "tracing-log", + "tracing-subscriber", +] + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "valuable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasm-bindgen" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8" +dependencies = [ + "cfg-if", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.65", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.65", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d4cc384e1e73b93bafa6fb4f1df8c41695c8a91cf9c4c64358067d15a7b6c6b" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.5", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.5", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f0713a46559409d202e70e28227288446bf7841d3211583a4b53e3f6d96e7eb" +dependencies = [ + "windows_aarch64_gnullvm 0.52.5", + "windows_aarch64_msvc 0.52.5", + "windows_i686_gnu 0.52.5", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.5", + "windows_x86_64_gnu 0.52.5", + "windows_x86_64_gnullvm 0.52.5", + "windows_x86_64_msvc 0.52.5", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7088eed71e8b8dda258ecc8bac5fb1153c5cffaf2578fc8ff5d61e23578d3263" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9985fd1504e250c615ca5f281c3f7a6da76213ebd5ccc9561496568a2752afb6" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88ba073cf16d5372720ec942a8ccbf61626074c6d4dd2e745299726ce8b89670" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87f4261229030a858f36b459e748ae97545d6f1ec60e5e0d6a3d32e0dc232ee9" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db3c2bf3d13d5b658be73463284eaf12830ac9a26a90c717b7f771dfe97487bf" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e4246f76bdeff09eb48875a0fd3e2af6aada79d409d33011886d3e1581517d9" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "852298e482cd67c356ddd9570386e2862b5673c85bd5f88df9ab6802b334c596" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0" diff --git a/crates/delta/fuzz/Cargo.toml b/crates/delta/fuzz/Cargo.toml new file mode 100644 index 000000000..32dbe20fe --- /dev/null +++ b/crates/delta/fuzz/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "loro-delta-fuzz" +version = "0.0.0" +publish = false +edition = "2021" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +arbitrary = { version = "1.3.2", features = ["derive"] } +libfuzzer-sys = "0.4" +dev-utils = { path = "../../dev-utils" } +tracing = "0.1.40" +ctor = "0.2.8" + +[dependencies.loro-delta] +path = ".." + +# Prevent this from interfering with workspaces +[workspace] +members = ["."] + +[profile.release] +debug = 1 + +[[bin]] +name = "ot" +path = "fuzz_targets/ot.rs" +test = false +doc = false diff --git a/crates/delta/fuzz/fuzz_targets/ot.rs b/crates/delta/fuzz/fuzz_targets/ot.rs new file mode 100644 index 000000000..5764478b9 --- /dev/null +++ b/crates/delta/fuzz/fuzz_targets/ot.rs @@ -0,0 +1,6 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use loro_delta_fuzz::{run, Op}; + +fuzz_target!(|ops: Vec| run(ops, 5)); diff --git a/crates/delta/fuzz/src/lib.rs b/crates/delta/fuzz/src/lib.rs new file mode 100644 index 000000000..b0a8bc8a9 --- /dev/null +++ b/crates/delta/fuzz/src/lib.rs @@ -0,0 +1,159 @@ +use arbitrary::Arbitrary; +use loro_delta::{ + text_delta::{TextChunk, TextDelta}, + DeltaItem, +}; +use tracing::{debug_span, instrument, trace}; + +#[derive(Debug, Arbitrary)] +pub enum Op { + Insert { site: u8, pos: u16, text: u16 }, + Delete { site: u8, pos: u16, len: u16 }, + Sync { site: u8 }, +} + +pub struct Actor { + rope: TextDelta, + last_sync_version: usize, + pending: TextDelta, +} + +pub struct Manager { + server: TextDelta, + versions: Vec, + actors: Vec, +} + +#[instrument(skip(m))] +fn sync(m: &mut Manager, site: usize) { + let actor = &mut m.actors[site]; + let mut server_ops = TextDelta::new(); + for t in &m.versions[actor.last_sync_version..] { + server_ops.compose(t); + } + + let client_to_apply = server_ops.transform(&actor.pending, true); + + let client_ops = std::mem::take(&mut actor.pending); + + let server_to_apply = client_ops.transform(&server_ops, false); + + actor.rope.compose(&client_to_apply); + m.server.compose(&server_to_apply); + m.versions.push(server_to_apply); + actor.last_sync_version = m.versions.len(); +} + +pub fn run(mut ops: Vec, site_num: usize) { + let mut m = Manager { + server: TextDelta::new(), + versions: vec![], + actors: vec![], + }; + for _ in 0..site_num { + m.actors.push(Actor { + rope: TextDelta::new(), + last_sync_version: 0, + pending: TextDelta::new(), + }) + } + + for op in &mut ops { + match op { + Op::Insert { site, pos, text } => { + *site = ((*site as usize) % site_num) as u8; + let actor = &mut m.actors[*site as usize]; + let len = actor.rope.len(); + *pos = ((*pos as usize) % (len + 1)) as u16; + let pos = *pos as usize; + + actor.rope.insert_str(pos, text.to_string().as_str()); + if actor.pending.len() < pos { + actor.pending.push_retain(pos, ()); + } + actor.pending.insert_values( + pos, + TextChunk::from_long_str(text.to_string().as_str()).map(|chunk| { + DeltaItem::Replace { + value: chunk, + attr: Default::default(), + delete: 0, + } + }), + ); + } + Op::Delete { + site, + pos, + len: del_len, + } => { + *site = ((*site as usize) % site_num) as u8; + let actor = &mut m.actors[*site as usize]; + let len = actor.rope.len(); + if len == 0 { + continue; + } + *pos = ((*pos as usize) % len) as u16; + let pos = *pos as usize; + *del_len = ((*del_len as usize) % len) as u16; + let del_len = *del_len as usize; + let mut del = TextDelta::new(); + del.push_retain(pos, ()).push_delete(del_len); + actor.rope.compose(&del); + actor.pending.compose(&del); + } + Op::Sync { site } => { + *site = ((*site as usize) % site_num) as u8; + let site = *site as usize; + sync(&mut m, site); + } + } + } + + debug_span!("Round 1").in_scope(|| { + for i in 0..site_num { + sync(&mut m, i); + } + }); + debug_span!("Round 2").in_scope(|| { + for i in 0..site_num { + sync(&mut m, i); + } + }); + + let server_str = m.server.try_to_string().unwrap(); + for i in 0..site_num { + let actor = &m.actors[i]; + let rope_str = actor.rope.try_to_string().unwrap(); + assert_eq!(rope_str, server_str, "site {} ops={:#?}", i, &ops); + } +} + +#[cfg(test)] +mod tests { + use super::Op::*; + use super::*; + + #[ctor::ctor] + fn init() { + dev_utils::setup_test_log(); + } + + #[test] + fn test_run() { + let ops = vec![ + Insert { + site: 1, + pos: 0, + text: 65535, + }, + Sync { site: 1 }, + Insert { + site: 1, + pos: 5, + text: 0, + }, + ]; + run(ops, 2); + } +} diff --git a/crates/delta/src/delta_item.rs b/crates/delta/src/delta_item.rs index a82e18704..aef86199b 100644 --- a/crates/delta/src/delta_item.rs +++ b/crates/delta/src/delta_item.rs @@ -4,7 +4,7 @@ use super::*; use generic_btree::rle::{CanRemove, TryInsert}; impl DeltaItem { - /// The real length of the item in the delta + /// Including the delete length pub fn delta_len(&self) -> usize { match self { DeltaItem::Retain { len, .. } => *len, @@ -16,6 +16,7 @@ impl DeltaItem { } } + /// The real length of the item in the delta, excluding the delete length pub fn data_len(&self) -> usize { match self { DeltaItem::Retain { len, .. } => *len, diff --git a/crates/delta/src/delta_rope.rs b/crates/delta/src/delta_rope.rs index 710aeb673..1c25e3eed 100644 --- a/crates/delta/src/delta_rope.rs +++ b/crates/delta/src/delta_rope.rs @@ -233,6 +233,64 @@ impl DeltaRope { } } } + + pub fn transform(&self, other: &Self, left_prior: bool) -> Self { + let mut this_iter = self.iter_with_len(); + let mut other_iter = other.iter_with_len(); + let mut transformed_delta = DeltaRope::new(); + + while this_iter.peek().is_some() || other_iter.peek().is_some() { + if this_iter.peek_is_insert() && (left_prior || !other_iter.peek_is_insert()) { + let insert_length; + match this_iter.peek().unwrap() { + DeltaItem::Replace { value, attr, .. } => { + insert_length = value.rle_len(); + transformed_delta.push_insert(value.clone(), attr.clone()); + } + DeltaItem::Retain { .. } => unreachable!(), + } + this_iter.next_with(insert_length).unwrap(); + } else if other_iter.peek_is_insert() { + let insert_length = other_iter.peek_insert_length(); + transformed_delta.push_retain(insert_length, Default::default()); + other_iter.next_with(insert_length).unwrap(); + } else { + // It's now either retains or deletes + let length = this_iter.peek_length().min(other_iter.peek_length()); + let this_op_peek = this_iter.peek().cloned(); + let other_op_peek = other_iter.peek().cloned(); + let _ = this_iter.next_with(length); + let _ = other_iter.next_with(length); + if other_op_peek.map(|x| x.is_delete()).unwrap_or(false) { + // It makes our deletes or retains redundant + continue; + } else if this_op_peek + .as_ref() + .map(|x| x.is_delete()) + .unwrap_or(false) + { + transformed_delta.push_delete(length); + } else { + transformed_delta.push_retain( + length, + this_op_peek + .map(|x| x.into_retain().unwrap().1) + .unwrap_or_default(), + ); + // FIXME: transform the attributes + } + } + } + + transformed_delta.chop(); + transformed_delta + } + + /// Transforms operation `self` against another operation `other` in such a way that the + /// impact of `other` is effectively included in `self`. + pub fn transform_(&mut self, other: &Self, left_prior: bool) { + *self = self.transform(other, left_prior); + } } impl PartialEq for DeltaRope { @@ -308,7 +366,7 @@ impl Default for DeltaRope DeltaRope { - pub(crate) fn insert_values( + pub fn insert_values( &mut self, pos: usize, values: impl IntoIterator>, diff --git a/crates/delta/src/delta_rope/compose.rs b/crates/delta/src/delta_rope/compose.rs index 6514e6611..b934bb095 100644 --- a/crates/delta/src/delta_rope/compose.rs +++ b/crates/delta/src/delta_rope/compose.rs @@ -1,5 +1,3 @@ - - use super::*; struct DeltaReplace<'a, V, Attr> { diff --git a/crates/delta/src/iter.rs b/crates/delta/src/iter.rs index ba99aafce..5b20f4a68 100644 --- a/crates/delta/src/iter.rs +++ b/crates/delta/src/iter.rs @@ -30,6 +30,35 @@ impl<'a, V: DeltaValue, Attr: DeltaAttr> Iter<'a, V, Attr> { self.current.as_ref() } + pub fn peek_is_replace(&self) -> bool { + self.peek().map(|x| x.is_replace()).unwrap_or(false) + } + + pub fn peek_is_insert(&self) -> bool { + self.peek().map(|x| x.is_insert()).unwrap_or(false) + } + + pub fn peek_is_delete(&self) -> bool { + self.peek().map(|x| x.is_delete()).unwrap_or(false) + } + + pub fn peek_is_retain(&self) -> bool { + self.peek().map(|x| x.is_retain()).unwrap_or(false) + } + + pub fn peek_length(&self) -> usize { + self.peek().map(|x| x.delta_len()).unwrap_or(usize::MAX) + } + + pub fn peek_insert_length(&self) -> usize { + self.peek() + .map(|x| match x { + DeltaItem::Retain { .. } => 0, + DeltaItem::Replace { value, .. } => value.rle_len(), + }) + .unwrap_or(0) + } + pub fn next_with(&mut self, mut len: usize) -> Result<(), usize> { while len > 0 { let Some(current) = self.current.as_mut() else { diff --git a/crates/delta/src/lib.rs b/crates/delta/src/lib.rs index 760afe485..eaec2c3ac 100644 --- a/crates/delta/src/lib.rs +++ b/crates/delta/src/lib.rs @@ -46,3 +46,33 @@ pub enum DeltaItem { delete: usize, }, } + +impl DeltaItem { + fn is_insert(&self) -> bool { + match self { + DeltaItem::Retain { .. } => false, + DeltaItem::Replace { value, .. } => value.rle_len() > 0, + } + } + + fn is_delete(&self) -> bool { + match self { + DeltaItem::Retain { .. } => false, + DeltaItem::Replace { value, delete, .. } => value.rle_len() == 0 && *delete > 0, + } + } + + fn is_replace(&self) -> bool { + match self { + DeltaItem::Retain { .. } => false, + DeltaItem::Replace { .. } => true, + } + } + + fn is_retain(&self) -> bool { + match self { + DeltaItem::Retain { .. } => true, + DeltaItem::Replace { .. } => false, + } + } +} diff --git a/crates/examples/src/lib.rs b/crates/examples/src/lib.rs index 7f9690c20..bba754f6b 100644 --- a/crates/examples/src/lib.rs +++ b/crates/examples/src/lib.rs @@ -201,7 +201,7 @@ pub fn minify_failed_tests_in_async_mode( actions.drain(num..); if let Some(min_actions) = min_actions.as_mut() { if actions.len() < min_actions.len() { - *min_actions = actions.clone(); + min_actions.clone_from(&actions); } } else { min_actions = Some(actions.clone()); diff --git a/crates/fuzz/Cargo.toml b/crates/fuzz/Cargo.toml index 56e26cc7c..fb4cdc2b2 100644 --- a/crates/fuzz/Cargo.toml +++ b/crates/fuzz/Cargo.toml @@ -10,8 +10,13 @@ publish = false loro-without-counter = { path = "../loro", package = "loro" } loro = { git = "https://github.com/loro-dev/loro.git", features = [ "counter", -], branch = "leon/feat-encode-forward" } -loro-internal = { path = "../loro-internal", features = ["test_utils"] } +], rev = "0dade6bc0fb574a8190db2aa80c83a479f62e125" } +loro-common = { git = "https://github.com/loro-dev/loro.git", features = [ + "counter", +], rev = "0dade6bc0fb574a8190db2aa80c83a479f62e125" } +# loro = { path = "../loro", package = "loro", features = ["counter"] } +# loro-common = { path = "../loro-common", features = ["counter"] } +# loro-without-counter = { git = "https://github.com/loro-dev/loro.git", branch = "zxch3n/loro-560-undoredo", package = "loro" } fxhash = { workspace = true } enum_dispatch = { workspace = true } enum-as-inner = { workspace = true } diff --git a/crates/fuzz/fuzz/Cargo.lock b/crates/fuzz/fuzz/Cargo.lock index 2912fd763..7b35879fd 100644 --- a/crates/fuzz/fuzz/Cargo.lock +++ b/crates/fuzz/fuzz/Cargo.lock @@ -219,7 +219,7 @@ dependencies = [ [[package]] name = "fractional_index" version = "0.1.0" -source = "git+https://github.com/loro-dev/loro.git?branch=leon/feat-encode-forward#0ad15cd3c3c8e6f59d113bc948a7838bbdb5300a" +source = "git+https://github.com/loro-dev/loro.git?rev=0dade6bc0fb574a8190db2aa80c83a479f62e125#0dade6bc0fb574a8190db2aa80c83a479f62e125" dependencies = [ "imbl", "rand", @@ -237,8 +237,8 @@ dependencies = [ "fxhash", "itertools 0.12.1", "loro 0.5.1", - "loro 0.5.1 (git+https://github.com/loro-dev/loro.git?branch=leon/feat-encode-forward)", - "loro-internal 0.5.1", + "loro 0.5.1 (git+https://github.com/loro-dev/loro.git?rev=0dade6bc0fb574a8190db2aa80c83a479f62e125)", + "loro-common 0.5.1 (git+https://github.com/loro-dev/loro.git?rev=0dade6bc0fb574a8190db2aa80c83a479f62e125)", "rand", "tabled", "tracing", @@ -457,13 +457,13 @@ dependencies = [ [[package]] name = "loro" version = "0.5.1" -source = "git+https://github.com/loro-dev/loro.git?branch=leon/feat-encode-forward#0ad15cd3c3c8e6f59d113bc948a7838bbdb5300a" +source = "git+https://github.com/loro-dev/loro.git?rev=0dade6bc0fb574a8190db2aa80c83a479f62e125#0dade6bc0fb574a8190db2aa80c83a479f62e125" dependencies = [ "either", "enum-as-inner 0.6.0", "generic-btree", - "loro-delta 0.5.1 (git+https://github.com/loro-dev/loro.git?branch=leon/feat-encode-forward)", - "loro-internal 0.5.1 (git+https://github.com/loro-dev/loro.git?branch=leon/feat-encode-forward)", + "loro-delta 0.5.1 (git+https://github.com/loro-dev/loro.git?rev=0dade6bc0fb574a8190db2aa80c83a479f62e125)", + "loro-internal 0.5.1 (git+https://github.com/loro-dev/loro.git?rev=0dade6bc0fb574a8190db2aa80c83a479f62e125)", "tracing", ] @@ -485,12 +485,12 @@ dependencies = [ [[package]] name = "loro-common" version = "0.5.1" -source = "git+https://github.com/loro-dev/loro.git?branch=leon/feat-encode-forward#0ad15cd3c3c8e6f59d113bc948a7838bbdb5300a" +source = "git+https://github.com/loro-dev/loro.git?rev=0dade6bc0fb574a8190db2aa80c83a479f62e125#0dade6bc0fb574a8190db2aa80c83a479f62e125" dependencies = [ "arbitrary", "enum-as-inner 0.6.0", "fxhash", - "loro-rle 0.5.1 (git+https://github.com/loro-dev/loro.git?branch=leon/feat-encode-forward)", + "loro-rle 0.5.1 (git+https://github.com/loro-dev/loro.git?rev=0dade6bc0fb574a8190db2aa80c83a479f62e125)", "nonmax", "serde", "serde_columnar", @@ -512,7 +512,7 @@ dependencies = [ [[package]] name = "loro-delta" version = "0.5.1" -source = "git+https://github.com/loro-dev/loro.git?branch=leon/feat-encode-forward#0ad15cd3c3c8e6f59d113bc948a7838bbdb5300a" +source = "git+https://github.com/loro-dev/loro.git?rev=0dade6bc0fb574a8190db2aa80c83a479f62e125#0dade6bc0fb574a8190db2aa80c83a479f62e125" dependencies = [ "arrayvec", "enum-as-inner 0.5.1", @@ -526,7 +526,6 @@ name = "loro-internal" version = "0.5.1" dependencies = [ "append-only-bytes", - "arbitrary", "arref", "either", "enum-as-inner 0.5.1", @@ -552,7 +551,6 @@ dependencies = [ "serde_columnar", "serde_json", "smallvec", - "tabled", "thiserror", "tracing", ] @@ -560,23 +558,23 @@ dependencies = [ [[package]] name = "loro-internal" version = "0.5.1" -source = "git+https://github.com/loro-dev/loro.git?branch=leon/feat-encode-forward#0ad15cd3c3c8e6f59d113bc948a7838bbdb5300a" +source = "git+https://github.com/loro-dev/loro.git?rev=0dade6bc0fb574a8190db2aa80c83a479f62e125#0dade6bc0fb574a8190db2aa80c83a479f62e125" dependencies = [ "append-only-bytes", "arref", "either", "enum-as-inner 0.5.1", "enum_dispatch", - "fractional_index 0.1.0 (git+https://github.com/loro-dev/loro.git?branch=leon/feat-encode-forward)", + "fractional_index 0.1.0 (git+https://github.com/loro-dev/loro.git?rev=0dade6bc0fb574a8190db2aa80c83a479f62e125)", "fxhash", "generic-btree", "getrandom", "im", "itertools 0.12.1", "leb128", - "loro-common 0.5.1 (git+https://github.com/loro-dev/loro.git?branch=leon/feat-encode-forward)", - "loro-delta 0.5.1 (git+https://github.com/loro-dev/loro.git?branch=leon/feat-encode-forward)", - "loro-rle 0.5.1 (git+https://github.com/loro-dev/loro.git?branch=leon/feat-encode-forward)", + "loro-common 0.5.1 (git+https://github.com/loro-dev/loro.git?rev=0dade6bc0fb574a8190db2aa80c83a479f62e125)", + "loro-delta 0.5.1 (git+https://github.com/loro-dev/loro.git?rev=0dade6bc0fb574a8190db2aa80c83a479f62e125)", + "loro-rle 0.5.1 (git+https://github.com/loro-dev/loro.git?rev=0dade6bc0fb574a8190db2aa80c83a479f62e125)", "md5", "num", "num-derive", @@ -607,7 +605,7 @@ dependencies = [ [[package]] name = "loro-rle" version = "0.5.1" -source = "git+https://github.com/loro-dev/loro.git?branch=leon/feat-encode-forward#0ad15cd3c3c8e6f59d113bc948a7838bbdb5300a" +source = "git+https://github.com/loro-dev/loro.git?rev=0dade6bc0fb574a8190db2aa80c83a479f62e125#0dade6bc0fb574a8190db2aa80c83a479f62e125" dependencies = [ "append-only-bytes", "arref", diff --git a/crates/fuzz/src/actions.rs b/crates/fuzz/src/actions.rs index 428af4942..b6a7b77b0 100644 --- a/crates/fuzz/src/actions.rs +++ b/crates/fuzz/src/actions.rs @@ -66,6 +66,15 @@ pub enum Action { site: u8, to: u32, }, + Undo { + site: u8, + op_len: u32, + }, + // For concurrent undo + SyncAllUndo { + site: u8, + op_len: u32, + }, Sync { from: u8, to: u8, @@ -151,6 +160,18 @@ impl Tabled for Action { fields.extend(action.as_action().unwrap().table_fields()); fields } + Action::Undo { site, op_len } => vec![ + "undo".into(), + format!("{}", site).into(), + format!("{} op len", op_len).into(), + "".into(), + ], + Action::SyncAllUndo { site, op_len } => vec![ + "sync all undo".into(), + format!("{}", site).into(), + format!("{} op len", op_len).into(), + "".into(), + ], } } diff --git a/crates/fuzz/src/actor.rs b/crates/fuzz/src/actor.rs index 3bc1c1f16..c98e6f090 100644 --- a/crates/fuzz/src/actor.rs +++ b/crates/fuzz/src/actor.rs @@ -6,9 +6,10 @@ use std::{ use enum_as_inner::EnumAsInner; use enum_dispatch::enum_dispatch; use fxhash::FxHashMap; -use loro::{Container, ContainerID, ContainerType, Frontiers, LoroDoc, LoroValue, PeerID, ID}; -use rand::SeedableRng; -use rand::{rngs::StdRng, Rng}; +use loro::{ + Container, ContainerID, ContainerType, Frontiers, LoroDoc, LoroValue, PeerID, UndoManager, ID, +}; +use rand::{rngs::StdRng, Rng, SeedableRng}; use tracing::info_span; use crate::{ @@ -21,12 +22,20 @@ use super::{ container::MapActor, }; +#[derive(Debug)] +pub struct Undo { + pub undo: UndoManager, + pub last_container: u8, + pub can_undo_length: u8, +} + pub struct Actor { pub peer: PeerID, pub loro: Arc, pub targets: FxHashMap, pub tracker: Arc>, pub history: FxHashMap, LoroValue>, + pub undo_manager: Undo, pub rng: StdRng, } @@ -34,6 +43,7 @@ impl Actor { pub fn new(id: PeerID) -> Self { let loro = LoroDoc::new(); loro.set_peer_id(id).unwrap(); + let undo = UndoManager::new(&loro); let tracker = Arc::new(Mutex::new(ContainerTracker::Map(MapTracker::empty( ContainerID::new_root("sys:root", ContainerType::Map), )))); @@ -52,6 +62,11 @@ impl Actor { tracker, targets: FxHashMap::default(), history: default_history, + undo_manager: Undo { + undo, + last_container: 255, + can_undo_length: 0, + }, rng: StdRng::from_seed({ let mut seed = [0u8; 32]; let bytes = id.to_be_bytes(); // Convert u64 to [u8; 8] @@ -95,11 +110,43 @@ impl Actor { let actor = self.targets.get_mut(&ty).unwrap(); self.loro.attach(); let idx = action.apply(actor, container as usize); + + if self.undo_manager.last_container != container { + self.undo_manager.last_container = container; + self.undo_manager.can_undo_length += 1; + } + if let Some(idx) = idx { self.add_new_container(idx); } } + pub fn undo(&mut self, undo_length: u32) { + self.loro.attach(); + let mut before_undo = self.loro.get_deep_value(); + for _ in 0..undo_length { + self.undo_manager.undo.undo(&self.loro).unwrap(); + } + + for _ in 0..undo_length { + self.undo_manager.undo.redo(&self.loro).unwrap(); + } + let mut after_undo = self.loro.get_deep_value(); + Self::patch_tree_undo_position(&mut before_undo); + Self::patch_tree_undo_position(&mut after_undo); + assert_value_eq(&before_undo, &after_undo); + } + + fn patch_tree_undo_position(a: &mut LoroValue) { + let root = Arc::make_mut(a.as_map_mut().unwrap()); + let tree = root.get_mut("tree").unwrap(); + let nodes = Arc::make_mut(tree.as_list_mut().unwrap()); + for node in nodes.iter_mut() { + let node = Arc::make_mut(node.as_map_mut().unwrap()); + node.remove("position"); + } + } + pub fn check_tracker(&self) { let loro = &self.loro; info_span!("Check tracker", "peer = {}", loro.peer_id()).in_scope(|| { diff --git a/crates/fuzz/src/container/list.rs b/crates/fuzz/src/container/list.rs index 547c252f7..0698357d8 100644 --- a/crates/fuzz/src/container/list.rs +++ b/crates/fuzz/src/container/list.rs @@ -1,7 +1,7 @@ use std::sync::{Arc, Mutex}; use loro::{Container, ContainerID, ContainerType, LoroDoc, LoroList}; -use tracing::{debug_span}; +use tracing::debug_span; use crate::{ actions::{Actionable, FromGenericAction, GenericAction}, diff --git a/crates/fuzz/src/container/tree.rs b/crates/fuzz/src/container/tree.rs index 98d57428f..724bde030 100644 --- a/crates/fuzz/src/container/tree.rs +++ b/crates/fuzz/src/container/tree.rs @@ -409,7 +409,7 @@ impl ApplyDiff for TreeTracker { self.insert(*index, node); }; } - TreeExternalDiff::Delete => { + TreeExternalDiff::Delete { .. } => { let node = self.find_node_by_id(target).unwrap(); if let Some(parent) = node.parent { let parent = self.find_node_by_id_mut(parent).unwrap(); @@ -423,6 +423,7 @@ impl ApplyDiff for TreeTracker { parent, index, position, + .. } => { let node = self.find_node_by_id(target).unwrap(); let mut node = if let Some(p) = node.parent { diff --git a/crates/fuzz/src/crdt_fuzzer.rs b/crates/fuzz/src/crdt_fuzzer.rs index e30c9a30b..d48d0c8d4 100644 --- a/crates/fuzz/src/crdt_fuzzer.rs +++ b/crates/fuzz/src/crdt_fuzzer.rs @@ -85,6 +85,16 @@ impl CRDTFuzzer { action.convert_to_inner(target); actor.pre_process(action.as_action_mut().unwrap(), container); } + Action::Undo { site, op_len } => { + *site %= max_users; + let actor = &mut self.actors[*site as usize]; + *op_len %= actor.undo_manager.can_undo_length as u32 + 1; + } + Action::SyncAllUndo { site, op_len } => { + *site %= max_users; + let actor = &mut self.actors[*site as usize]; + *op_len %= actor.undo_manager.can_undo_length as u32 + 1; + } } } @@ -139,7 +149,36 @@ impl CRDTFuzzer { let actor = &mut self.actors[*site as usize]; let action = action.as_action().unwrap(); actor.apply(action, *container); - // actor.loro.commit(); + } + Action::Undo { site, op_len } => { + let actor = &mut self.actors[*site as usize]; + if *op_len != 0 { + actor.undo(*op_len); + } + } + Action::SyncAllUndo { site, op_len } => { + for i in 1..self.site_num() { + info_span!("Importing", "importing to 0 from {}", i).in_scope(|| { + let (a, b) = array_mut_ref!(&mut self.actors, [0, i]); + a.loro + .import(&b.loro.export_from(&a.loro.oplog_vv())) + .unwrap(); + }); + } + + for i in 1..self.site_num() { + info_span!("Importing", "importing to {} from {}", i, 0).in_scope(|| { + let (a, b) = array_mut_ref!(&mut self.actors, [0, i]); + b.loro + .import(&a.loro.export_from(&b.loro.oplog_vv())) + .unwrap(); + }); + } + self.actors.iter_mut().for_each(|a| a.record_history()); + let actor = &mut self.actors[*site as usize]; + if *op_len != 0 { + actor.undo(*op_len); + } } } } @@ -248,6 +287,7 @@ pub fn test_multi_sites(site_num: u8, fuzz_targets: Vec, actions: &m let mut applied = Vec::new(); for action in actions.iter_mut() { fuzzer.pre_process(action); + info_span!("ApplyAction", ?action).in_scope(|| { applied.push(action.clone()); info!("OptionsTable \n{}", (&applied).table()); diff --git a/crates/fuzz/tests/test.rs b/crates/fuzz/tests/test.rs index f6dee4a37..fb796e40e 100644 --- a/crates/fuzz/tests/test.rs +++ b/crates/fuzz/tests/test.rs @@ -1,7 +1,11 @@ use std::sync::Arc; use fuzz::{ - actions::{ActionWrapper::*, GenericAction}, + actions::{ + ActionWrapper::{self, *}, + GenericAction, + }, + container::{TreeAction, TreeActionInner}, crdt_fuzzer::{test_multi_sites, Action::*, FuzzTarget, FuzzValue::*}, }; use loro::{ContainerType::*, LoroCounter, LoroDoc}; @@ -5587,3 +5591,57 @@ fn unknown_container() { doc.import(&doc2.export_snapshot()).unwrap(); } + +#[test] +fn undo_tree() { + test_multi_sites( + 5, + vec![FuzzTarget::Tree], + &mut [ + Handle { + site: 0, + target: 0, + container: 0, + action: ActionWrapper::Action(fuzz::actions::ActionInner::Tree(TreeAction { + target: (0, 0), + action: TreeActionInner::Create { index: 0 }, + })), + }, + Handle { + site: 0, + target: 0, + container: 0, + action: ActionWrapper::Action(fuzz::actions::ActionInner::Tree(TreeAction { + target: (0, 1), + action: TreeActionInner::Create { index: 1 }, + })), + }, + SyncAll, + Handle { + site: 0, + target: 0, + container: 0, + action: ActionWrapper::Action(fuzz::actions::ActionInner::Tree(TreeAction { + target: (0, 0), + action: TreeActionInner::Move { + parent: (0, 1), + index: 0, + }, + })), + }, + Handle { + site: 1, + target: 0, + container: 0, + action: ActionWrapper::Action(fuzz::actions::ActionInner::Tree(TreeAction { + target: (0, 1), + action: TreeActionInner::Move { + parent: (0, 0), + index: 0, + }, + })), + }, + SyncAllUndo { site: 0, op_len: 1 }, + ], + ) +} diff --git a/crates/fuzz/tests/undo.rs b/crates/fuzz/tests/undo.rs new file mode 100644 index 000000000..44c1c482f --- /dev/null +++ b/crates/fuzz/tests/undo.rs @@ -0,0 +1,122 @@ +use fuzz::{ + actions::{ActionWrapper::*, GenericAction}, + crdt_fuzzer::{test_multi_sites, Action::*, FuzzTarget, FuzzValue::*}, +}; +use loro_common::ContainerType::*; + +// #[ctor::ctor] +// fn init() { +// dev_utils::setup_test_log(); +// } + +#[test] +fn undo_tree_with_map() { + test_multi_sites( + 5, + vec![FuzzTarget::Tree], + &mut [ + Handle { + site: 174, + target: 0, + container: 0, + action: Generic(GenericAction { + value: I32(117440512), + bool: true, + key: 1275068415, + pos: 18446743068687204667, + length: 46161896180416511, + prop: 18446463698227691775, + }), + }, + SyncAll, + Handle { + site: 0, + target: 0, + container: 0, + action: Generic(GenericAction { + value: I32(-12976128), + bool: true, + key: 131071, + pos: 3399988123389597184, + length: 3400000218017509167, + prop: 3399988123389603631, + }), + }, + Handle { + site: 0, + target: 0, + container: 0, + action: Generic(GenericAction { + value: I32(791621423), + bool: true, + key: 791621423, + pos: 18372433783001394991, + length: 13281205459693609, + prop: 18446744069425331619, + }), + }, + SyncAll, + SyncAllUndo { + site: 149, + op_len: 65533, + }, + ], + ); +} + +#[test] +fn redo_tree_id_diff() { + test_multi_sites( + 2, + vec![FuzzTarget::All], + &mut [ + Handle { + site: 51, + target: 60, + container: 197, + action: Generic(GenericAction { + value: I32(-296905323), + bool: false, + key: 2395151462, + pos: 6335698875578771752, + length: 1716855125946684615, + prop: 2807457672376879961, + }), + }, + Handle { + site: 162, + target: 167, + container: 90, + action: Generic(GenericAction { + value: Container(Tree), + bool: true, + key: 929442508, + pos: 4887648083275096983, + length: 8237173174339417107, + prop: 1571041097810100079, + }), + }, + Checkout { + site: 56, + to: 1826343396, + }, + SyncAllUndo { + site: 10, + op_len: 998370061, + }, + Handle { + site: 112, + target: 78, + container: 159, + action: Generic(GenericAction { + value: Container(MovableList), + bool: false, + key: 1978700208, + pos: 15377364763518525973, + length: 13205966979381542996, + prop: 5155832222345785212, + }), + }, + ], + ); +} diff --git a/crates/loro-common/src/error.rs b/crates/loro-common/src/error.rs index ed3b5cb7b..acedf54d1 100644 --- a/crates/loro-common/src/error.rs +++ b/crates/loro-common/src/error.rs @@ -47,15 +47,21 @@ pub enum LoroError { #[error("Unknown Error ({0})")] Unknown(Box), #[error("The given ID ({0}) is not contained by the doc")] - InvalidFrontierIdNotFound(ID), + FrontiersNotFound(ID), #[error("Cannot import when the doc is in a transaction")] ImportWhenInTxn, #[error("The given method ({method}) is not allowed when the container is detached. You should insert the container to the doc first.")] - MisuseDettachedContainer { method: &'static str }, + MisuseDetachedContainer { method: &'static str }, #[error("Not implemented: {0}")] NotImplemented(&'static str), #[error("Reattach a container that is already attached")] ReattachAttachedContainer, + #[error("Edit is not allowed when the doc is in the detached mode.")] + EditWhenDetached, + #[error("The given ID ({0}) is not contained by the doc")] + UndoInvalidIdSpan(ID), + #[error("PeerID cannot be changed. Expected: {expected:?}, Actual: {actual:?}")] + UndoWithDifferentPeerId { expected: PeerID, actual: PeerID }, } #[derive(Error, Debug)] diff --git a/crates/loro-internal/benches/event.rs b/crates/loro-internal/benches/event.rs index fc5eb4874..0eed285de 100644 --- a/crates/loro-internal/benches/event.rs +++ b/crates/loro-internal/benches/event.rs @@ -2,7 +2,7 @@ use criterion::{criterion_group, criterion_main, Criterion}; #[cfg(feature = "test_utils")] mod event { use super::*; - + use loro_internal::{ListHandler, LoroDoc}; use std::sync::Arc; @@ -24,7 +24,7 @@ mod event { let children_num = 80; let deep = 3; b.iter(|| { - let mut loro = LoroDoc::default(); + let loro = LoroDoc::default(); loro.start_auto_commit(); loro.subscribe_root(Arc::new(|_e| {})); let mut handlers = vec![loro.get_list("list")]; diff --git a/crates/loro-internal/examples/event.rs b/crates/loro-internal/examples/event.rs index c86e5796c..16a110a15 100644 --- a/crates/loro-internal/examples/event.rs +++ b/crates/loro-internal/examples/event.rs @@ -7,7 +7,7 @@ use loro_internal::{ }; fn main() { - let mut doc = LoroDoc::new(); + let doc = LoroDoc::new(); doc.start_auto_commit(); let list = doc.get_list("list"); doc.subscribe_root(Arc::new(|e| { diff --git a/crates/loro-internal/src/delta/map_delta.rs b/crates/loro-internal/src/delta/map_delta.rs index d6047a005..7bb44dd21 100644 --- a/crates/loro-internal/src/delta/map_delta.rs +++ b/crates/loro-internal/src/delta/map_delta.rs @@ -134,6 +134,14 @@ impl ResolvedMapDelta { self.updated.insert(key, map_value); self } + + pub(crate) fn transform(&mut self, b: &ResolvedMapDelta, left_prior: bool) { + for (k, _) in b.updated.iter() { + if !left_prior { + self.updated.remove(k); + } + } + } } impl Hash for MapValue { diff --git a/crates/loro-internal/src/delta/text.rs b/crates/loro-internal/src/delta/text.rs index 8ad248e18..de8709a0c 100644 --- a/crates/loro-internal/src/delta/text.rs +++ b/crates/loro-internal/src/delta/text.rs @@ -158,14 +158,7 @@ impl ToJson for StyleMeta { impl DeltaAttr for StyleMeta { fn compose(&mut self, other: &Self) { for (key, value) in other.map.iter() { - match self.map.get_mut(key) { - Some(old_value) => { - old_value.try_replace(value); - } - None => { - self.map.insert(key.clone(), value.clone()); - } - } + self.map.insert(key.clone(), value.clone()); } } diff --git a/crates/loro-internal/src/delta/tree.rs b/crates/loro-internal/src/delta/tree.rs index 33deec5a7..9f40e2fc7 100644 --- a/crates/loro-internal/src/delta/tree.rs +++ b/crates/loro-internal/src/delta/tree.rs @@ -1,4 +1,6 @@ use fractional_index::FractionalIndex; +use fxhash::FxHashMap; +use itertools::Itertools; use loro_common::{IdFull, TreeID}; use std::fmt::Debug; use std::ops::{Deref, DerefMut}; @@ -27,8 +29,13 @@ pub enum TreeExternalDiff { parent: Option, index: usize, position: FractionalIndex, + old_parent: TreeParentId, + old_index: usize, + }, + Delete { + old_parent: TreeParentId, + old_index: usize, }, - Delete, } impl TreeDiff { @@ -42,6 +49,123 @@ impl TreeDiff { self.diff.extend(other); self } + + pub(crate) fn transform(&mut self, b: &TreeDiff, left_prior: bool) { + if b.is_empty() || self.is_empty() { + return; + } + + let b_update: FxHashMap<_, _> = b.diff.iter().map(|d| (d.target, &d.action)).collect(); + let mut self_update: FxHashMap<_, _> = self + .diff + .iter() + .enumerate() + .map(|(i, d)| (d.target, (&d.action, i))) + .collect(); + if !left_prior { + let mut removes = Vec::new(); + for (target, _) in b_update { + if let Some((_, i)) = self_update.remove(&target) { + removes.push(i); + } + } + for i in removes.into_iter().sorted().rev() { + self.diff.remove(i); + } + } + let mut b_parent = FxHashMap::default(); + + fn reset_index( + b_parent: &FxHashMap>, + index: &mut usize, + parent: &TreeParentId, + left_priority: bool, + ) { + if let Some(b_indices) = b_parent.get(parent) { + for i in b_indices.iter() { + if (i.unsigned_abs() as usize) < *index + || (i.unsigned_abs() as usize == *index && !left_priority) + { + if i > &0 { + *index += 1; + } else if *index > (i.unsigned_abs() as usize) { + *index = index.saturating_sub(1); + } + } else { + break; + } + } + } + } + + for diff in b.diff.iter() { + match &diff.action { + TreeExternalDiff::Create { + parent, + index, + position: _, + } => { + b_parent + .entry(TreeParentId::from(*parent)) + .or_insert_with(Vec::new) + .push(*index as i32); + } + TreeExternalDiff::Move { + parent, + index, + position: _, + old_parent, + old_index, + } => { + b_parent + .entry(*old_parent) + .or_insert_with(Vec::new) + .push(-(*old_index as i32)); + b_parent + .entry(TreeParentId::from(*parent)) + .or_insert_with(Vec::new) + .push(*index as i32); + } + TreeExternalDiff::Delete { + old_index, + old_parent, + } => { + b_parent + .entry(*old_parent) + .or_insert_with(Vec::new) + .push(-(*old_index as i32)); + } + } + } + b_parent + .iter_mut() + .for_each(|(_, v)| v.sort_unstable_by_key(|i| i.abs())); + for diff in self.iter_mut() { + match &mut diff.action { + TreeExternalDiff::Create { + parent, + index, + position: _, + } => reset_index(&b_parent, index, &TreeParentId::from(*parent), left_prior), + TreeExternalDiff::Move { + parent, + index, + position: _, + old_parent, + old_index, + } => { + reset_index(&b_parent, index, &TreeParentId::from(*parent), left_prior); + reset_index(&b_parent, old_index, old_parent, left_prior); + } + TreeExternalDiff::Delete { + old_index, + old_parent, + } => { + reset_index(&b_parent, old_index, old_parent, left_prior); + } + } + } + } } /// Representation of differences in movable tree. It's an ordered list of [`TreeDiff`]. diff --git a/crates/loro-internal/src/event.rs b/crates/loro-internal/src/event.rs index d8788b144..bc28f654a 100644 --- a/crates/loro-internal/src/event.rs +++ b/crates/loro-internal/src/event.rs @@ -355,6 +355,27 @@ impl InternalDiff { } impl Diff { + pub(crate) fn compose_ref(&mut self, diff: &Diff) { + // PERF: avoid clone + match (self, diff) { + (Diff::List(a), Diff::List(b)) => { + a.compose(b); + } + (Diff::Text(a), Diff::Text(b)) => { + a.compose(b); + } + (Diff::Map(a), Diff::Map(b)) => { + *a = a.clone().compose(b.clone()); + } + (Diff::Tree(a), Diff::Tree(b)) => { + *a = a.clone().compose(b.clone()); + } + #[cfg(feature = "counter")] + (Diff::Counter(a), Diff::Counter(b)) => *a += b, + (_, _) => unreachable!(), + } + } + pub(crate) fn compose(self, diff: Diff) -> Result { // PERF: avoid clone match (self, diff) { @@ -375,6 +396,26 @@ impl Diff { } } + // Transform this diff based on the other diff + pub(crate) fn transform(&mut self, other: &Self, left_prior: bool) { + match (self, other) { + (Diff::List(a), Diff::List(b)) => a.transform_(b, left_prior), + (Diff::Text(a), Diff::Text(b)) => a.transform_(b, left_prior), + (Diff::Map(a), Diff::Map(b)) => a.transform(b, left_prior), + (Diff::Tree(a), Diff::Tree(b)) => a.transform(b, left_prior), + #[cfg(feature = "counter")] + (Diff::Counter(a), Diff::Counter(b)) => { + if left_prior { + *a += b; + } else { + *a -= b; + } + } + _ => {} + } + } + + #[allow(unused)] pub(crate) fn is_empty(&self) -> bool { match self { Diff::List(s) => s.is_empty(), diff --git a/crates/loro-internal/src/handler.rs b/crates/loro-internal/src/handler.rs index 918f1c159..ab8401d4d 100644 --- a/crates/loro-internal/src/handler.rs +++ b/crates/loro-internal/src/handler.rs @@ -7,8 +7,8 @@ use crate::{ richtext::{richtext_state::PosType, RichtextState, StyleOp, TextStyleInfoFlag}, }, cursor::{Cursor, Side}, - delta::{DeltaItem, StyleMeta}, - event::TextDiffItem, + delta::{DeltaItem, StyleMeta, TreeExternalDiff}, + event::{Diff, TextDiffItem}, op::ListSlice, state::{ContainerState, IndexType, State}, txn::EventHint, @@ -28,7 +28,7 @@ use std::{ ops::Deref, sync::{Arc, Mutex, Weak}, }; -use tracing::{info, instrument}; +use tracing::{error, info, instrument}; mod tree; pub use tree::TreeHandler; @@ -73,7 +73,7 @@ pub trait HandlerTrait: Clone + Sized { fn with_state(&self, f: impl FnOnce(&mut State) -> LoroResult) -> LoroResult { let inner = self .attached_handler() - .ok_or(LoroError::MisuseDettachedContainer { + .ok_or(LoroError::MisuseDetachedContainer { method: "with_state", })?; let state = inner.state.upgrade().unwrap(); @@ -151,7 +151,7 @@ impl MaybeDetached { fn try_attached_state(&self) -> LoroResult<&BasicHandler> { match self { - MaybeDetached::Detached(_) => Err(LoroError::MisuseDettachedContainer { + MaybeDetached::Detached(_) => Err(LoroError::MisuseDetachedContainer { method: "inner_state", }), MaybeDetached::Attached(a) => Ok(a), @@ -1056,6 +1056,77 @@ impl Handler { Self::Unknown(x) => x.get_deep_value(), } } + + pub(crate) fn apply_diff( + &self, + diff: Diff, + on_container_remap: &mut dyn FnMut(ContainerID, ContainerID), + ) -> LoroResult<()> { + match self { + Self::Map(x) => { + let diff = diff.into_map().unwrap(); + for (key, value) in diff.updated.into_iter() { + match value.value { + Some(ValueOrHandler::Handler(h)) => { + let old_id = h.id(); + let new_h = x.insert_container( + &key, + Handler::new_unattached(old_id.container_type()), + )?; + let new_id = new_h.id(); + on_container_remap(old_id, new_id); + } + Some(ValueOrHandler::Value(v)) => { + x.insert_without_skipping(&key, v)?; + } + None => { + x.delete(&key)?; + } + } + } + } + Self::Text(x) => { + let delta = diff.into_text().unwrap(); + x.apply_delta(&TextDelta::from_text_diff(delta.iter()))?; + } + Self::List(x) => { + let delta = diff.into_list().unwrap(); + x.apply_delta(delta, on_container_remap)?; + } + Self::MovableList(x) => { + let delta = diff.into_list().unwrap(); + x.apply_delta(delta, on_container_remap)?; + } + Self::Tree(x) => { + for diff in diff.into_tree().unwrap().diff { + let target = diff.target; + match diff.action { + TreeExternalDiff::Create { + parent, + index, + position: _, + } => { + x.create_at_with_target(parent, index, target)?; + // create map event + } + TreeExternalDiff::Delete { .. } => x.delete(target)?, + TreeExternalDiff::Move { parent, index, .. } => { + x.move_to(target, parent, index)? + } + } + } + } + #[cfg(feature = "counter")] + Self::Counter(x) => { + let delta = diff.into_counter().unwrap(); + x.increment(delta)?; + } + Self::Unknown(_) => { + // do nothing + } + } + Ok(()) + } } #[derive(Clone, EnumAsInner, Debug)] @@ -1337,6 +1408,7 @@ impl TextHandler { } if pos + len > self.len_event() { + error!("pos={} len={} len_event={}", pos, len, self.len_event()); return Err(LoroError::OutOfBound { pos: pos + len, len: self.len_event(), @@ -1344,7 +1416,7 @@ impl TextHandler { } let inner = self.inner.try_attached_state()?; - let s = tracing::span!(tracing::Level::INFO, "delete pos={} len={}", pos, len); + let s = tracing::span!(tracing::Level::INFO, "delete", "pos={} len={}", pos, len); let _e = s.enter(); let ranges = inner.with_state(|state| { let richtext_state = state.as_richtext_state_mut().unwrap(); @@ -2077,6 +2149,55 @@ impl ListHandler { } } } + + fn apply_delta( + &self, + delta: loro_delta::DeltaRope< + loro_delta::array_vec::ArrayVec, + crate::event::ListDeltaMeta, + >, + on_container_remap: &mut dyn FnMut(ContainerID, ContainerID), + ) -> LoroResult<()> { + match &self.inner { + MaybeDetached::Detached(_) => unimplemented!(), + MaybeDetached::Attached(_) => { + let mut index = 0; + for item in delta.iter() { + match item { + loro_delta::DeltaItem::Retain { len, .. } => { + index += len; + } + loro_delta::DeltaItem::Replace { value, delete, .. } => { + if *delete > 0 { + self.delete(index, *delete)?; + } + + for v in value.iter() { + match v { + ValueOrHandler::Value(v) => { + self.insert(index, v.clone())?; + } + ValueOrHandler::Handler(h) => { + let old_id = h.id(); + let new_h = self.insert_container( + index, + Handler::new_unattached(old_id.container_type()), + )?; + let new_id = new_h.id(); + on_container_remap(old_id, new_id); + } + } + + index += 1; + } + } + } + } + } + } + + Ok(()) + } } impl MovableListHandler { @@ -2693,6 +2814,59 @@ impl MovableListHandler { } } } + + fn apply_delta( + &self, + delta: loro_delta::DeltaRope< + loro_delta::array_vec::ArrayVec, + crate::event::ListDeltaMeta, + >, + on_container_remap: &mut dyn FnMut(ContainerID, ContainerID), + ) -> LoroResult<()> { + match &self.inner { + MaybeDetached::Detached(_) => { + unimplemented!(); + } + MaybeDetached::Attached(_) => { + let mut index = 0; + for d in delta.iter() { + match d { + loro_delta::DeltaItem::Retain { len, .. } => { + index += len; + } + loro_delta::DeltaItem::Replace { + value, + delete, + attr: _attr, + } => { + // TODO: handle move error + self.delete(index, *delete)?; + for v in value.iter() { + match v { + ValueOrHandler::Value(v) => { + self.insert(index, v.clone())?; + } + ValueOrHandler::Handler(h) => { + let old_id = h.id(); + let new_h = self.insert_container( + index, + Handler::new_unattached(old_id.container_type()), + )?; + let new_id = new_h.id(); + on_container_remap(old_id, new_id); + } + } + + index += 1; + } + } + } + } + + Ok(()) + } + } + } } impl MapHandler { @@ -2719,6 +2893,43 @@ impl MapHandler { } } + /// This method will insert the value even if the same value is already in the given entry. + fn insert_without_skipping(&self, key: &str, value: impl Into) -> LoroResult<()> { + match &self.inner { + MaybeDetached::Detached(m) => { + let mut m = m.try_lock().unwrap(); + m.value + .insert(key.into(), ValueOrHandler::Value(value.into())); + Ok(()) + } + MaybeDetached::Attached(a) => a.with_txn(|txn| { + let this = &self; + let value = value.into(); + if let Some(_value) = value.as_container() { + return Err(LoroError::ArgErr( + INSERT_CONTAINER_VALUE_ARG_ERROR + .to_string() + .into_boxed_str(), + )); + } + + let inner = this.inner.try_attached_state()?; + txn.apply_local_op( + inner.container_idx, + crate::op::RawOpContent::Map(crate::container::map::MapSet { + key: key.into(), + value: Some(value.clone()), + }), + EventHint::Map { + key: key.into(), + value: Some(value.clone()), + }, + &inner.state, + ) + }), + } + } + pub fn insert_with_txn( &self, txn: &mut Transaction, @@ -2754,10 +2965,6 @@ impl MapHandler { } pub fn insert_container(&self, key: &str, handler: T) -> LoroResult { - if handler.is_attached() { - return Err(LoroError::ReattachAttachedContainer); - } - match &self.inner { MaybeDetached::Detached(m) => { let mut m = m.try_lock().unwrap(); @@ -2885,7 +3092,7 @@ impl MapHandler { pub fn get_deep_value_with_id(&self) -> LoroResult { match &self.inner { - MaybeDetached::Detached(_) => Err(LoroError::MisuseDettachedContainer { + MaybeDetached::Detached(_) => Err(LoroError::MisuseDetachedContainer { method: "get_deep_value_with_id", }), MaybeDetached::Attached(inner) => Ok(inner.with_doc_state(|state| { diff --git a/crates/loro-internal/src/handler/tree.rs b/crates/loro-internal/src/handler/tree.rs index b46aa1a14..53dfd0926 100644 --- a/crates/loro-internal/src/handler/tree.rs +++ b/crates/loro-internal/src/handler/tree.rs @@ -49,6 +49,19 @@ impl TreeInner { id } + fn create_with_target( + &mut self, + parent: Option, + index: usize, + target: TreeID, + ) -> TreeID { + self.map.insert(target, MapHandler::new_detached()); + self.parent_links.insert(target, parent); + let children = self.children_links.entry(parent).or_default(); + children.insert(index, target); + target + } + fn mov(&mut self, target: TreeID, new_parent: Option, index: usize) -> LoroResult<()> { let old_parent = self .parent_links @@ -294,7 +307,13 @@ impl TreeHandler { }), EventHint::Tree(TreeDiffItem { target, - action: TreeExternalDiff::Delete, + action: TreeExternalDiff::Delete { + old_parent: self + .get_node_parent(&target) + .map(TreeParentId::from) + .unwrap_or(TreeParentId::Unexist), + old_index: self.get_index_by_tree_id(&target).unwrap_or(0), + }, }), &inner.state, ) @@ -322,6 +341,45 @@ impl TreeHandler { } } + /// For undo/redo, Specify the TreeID of the created node + pub(crate) fn create_at_with_target( + &self, + parent: Option, + index: usize, + target: TreeID, + ) -> LoroResult<()> { + if let Some(p) = parent { + if !self.contains(p) { + return Ok(()); + } + } + match &self.inner { + MaybeDetached::Detached(t) => { + let t = &mut t.try_lock().unwrap().value; + t.create_with_target(parent, index, target); + Ok(()) + } + MaybeDetached::Attached(a) => a.with_txn(|txn| { + let inner = self.inner.try_attached_state()?; + match self.generate_position_at(&target, parent, index) { + FractionalIndexGenResult::Ok(position) => { + self.create_with_position(inner, txn, target, parent, index, position)?; + } + FractionalIndexGenResult::Rearrange(ids) => { + for (i, (id, position)) in ids.into_iter().enumerate() { + if i == 0 { + self.create_with_position(inner, txn, id, parent, index, position)?; + continue; + } + self.mov_with_position(inner, txn, id, parent, index + i, position)?; + } + } + }; + Ok(()) + }), + } + } + pub fn create_with_txn>>( &self, txn: &mut Transaction, @@ -510,6 +568,11 @@ impl TreeHandler { parent, index, position, + old_parent: self + .get_node_parent(&target) + .map(TreeParentId::from) + .unwrap_or(TreeParentId::Unexist), + old_index: self.get_index_by_tree_id(&target).unwrap_or(0), }, }), &inner.state, diff --git a/crates/loro-internal/src/lib.rs b/crates/loro-internal/src/lib.rs index 12c3634dd..75e17e6dc 100644 --- a/crates/loro-internal/src/lib.rs +++ b/crates/loro-internal/src/lib.rs @@ -17,8 +17,10 @@ pub use handler::{ TreeHandler, UnknownHandler, }; pub use loro::LoroDoc; +pub use loro_common; pub use oplog::OpLog; pub use state::DocState; +pub use undo::UndoManager; pub mod awareness; pub mod cursor; pub mod loro; @@ -53,6 +55,7 @@ pub use error::{LoroError, LoroResult}; pub(crate) mod group; pub(crate) mod macros; pub(crate) mod state; +pub(crate) mod undo; pub(crate) mod value; pub(crate) use id::{PeerID, ID}; diff --git a/crates/loro-internal/src/loro.rs b/crates/loro-internal/src/loro.rs index a0605209c..3a0e45628 100644 --- a/crates/loro-internal/src/loro.rs +++ b/crates/loro-internal/src/loro.rs @@ -10,9 +10,12 @@ use std::{ }, }; -use loro_common::{ContainerID, ContainerType, LoroResult, LoroValue, ID}; +use either::Either; +use fxhash::FxHashMap; +use itertools::Itertools; +use loro_common::{ContainerID, ContainerType, HasIdSpan, IdSpan, LoroResult, LoroValue, ID}; use rle::HasLength; -use tracing::{info_span, instrument, trace_span}; +use tracing::{debug, info_span, instrument}; use crate::{ arena::SharedArena, @@ -32,6 +35,7 @@ use crate::{ id::PeerID, op::InnerContent, oplog::dag::FrontiersNotIncluded, + undo::DiffBatch, version::Frontiers, HandlerTrait, InternalString, LoroError, VersionVector, }; @@ -155,7 +159,7 @@ impl LoroDoc { /// Create a doc with auto commit enabled. #[inline] pub fn new_auto_commit() -> Self { - let mut doc = Self::new(); + let doc = Self::new(); doc.start_auto_commit(); doc } @@ -274,7 +278,7 @@ impl LoroDoc { Ok(v) } - pub fn start_auto_commit(&mut self) { + pub fn start_auto_commit(&self) { self.auto_commit.store(true, Release); let mut self_txn = self.txn.try_lock().unwrap(); if self_txn.is_some() || self.detached.load(Acquire) { @@ -604,6 +608,16 @@ impl LoroDoc { self.get_by_path(&path) } + #[inline] + pub fn get_handler(&self, id: ContainerID) -> Handler { + Handler::new_attached( + id, + self.arena.clone(), + self.get_global_txn(), + Arc::downgrade(&self.state), + ) + } + /// id can be a str, ContainerID, or ContainerIdRaw. /// if it's str it will use Root container, which will not be None #[inline] @@ -695,6 +709,163 @@ impl LoroDoc { .unwrap() } + /// Undo the operations between the given id_span. It can be used even in a collaborative environment. + /// + /// This is an internal API. You should NOT use it directly. + /// + /// # Internal + /// + /// This method will use the diff calculator to calculate the diff required to time travel + /// from the end of id_span to the beginning of the id_span. Then it will convert the diff to + /// operations and apply them to the OpLog with a dep on the last id of the given id_span. + /// + /// This implementation is kinda slow, but it's simple and maintainable. We can optimize it + /// further when it's needed. The time complexity is O(n + m), n is the ops in the id_span, m is the + /// distance from id_span to the current latest version. + #[instrument(level = "info", skip_all)] + pub fn undo_internal( + &self, + id_span: IdSpan, + container_remap: &mut FxHashMap, + post_transform_base: Option<&DiffBatch>, + before_diff: &mut dyn FnMut(&DiffBatch), + ) -> LoroResult { + if self.is_detached() { + return Err(LoroError::EditWhenDetached); + } + + self.commit_then_stop(); + if !self + .oplog() + .lock() + .unwrap() + .vv() + .includes_id(id_span.id_last()) + { + self.renew_txn_if_auto_commit(); + return Err(LoroError::UndoInvalidIdSpan(id_span.id_last())); + } + + let (was_recording, latest_frontiers) = { + let mut state = self.state.lock().unwrap(); + let was_recording = state.is_recording(); + state.stop_and_clear_recording(); + (was_recording, state.frontiers.clone()) + }; + + let spans = self.oplog.lock().unwrap().split_span_based_on_deps(id_span); + let diff = crate::undo::undo( + spans, + match post_transform_base { + Some(d) => Either::Right(d), + None => Either::Left(&latest_frontiers), + }, + |from, to| { + self.checkout_without_emitting(from).unwrap(); + self.state.lock().unwrap().start_recording(); + self.checkout_without_emitting(to).unwrap(); + let mut state = self.state.lock().unwrap(); + let e = state.take_events(); + state.stop_and_clear_recording(); + DiffBatch::new(e) + }, + before_diff, + ); + + self.checkout_without_emitting(&latest_frontiers)?; + self.detached.store(false, Release); + if was_recording { + self.state.lock().unwrap().start_recording(); + } + self.start_auto_commit(); + + self.apply_diff(diff, container_remap).unwrap(); + Ok(CommitWhenDrop { doc: self }) + } + + /// Calculate the diff between the current state and the target state, and apply the diff to the current state. + pub fn diff_and_apply(&self, target: &Frontiers) -> LoroResult<()> { + let f = self.state_frontiers(); + let diff = self.diff(&f, target)?; + self.apply_diff(diff, &mut Default::default()) + } + + /// Calculate the diff between two versions so that apply diff on a will make the state same as b. + /// + /// NOTE: This method will make the doc enter the **detached mode**. + pub fn diff(&self, a: &Frontiers, b: &Frontiers) -> LoroResult { + { + // check whether a and b are valid + let oplog = self.oplog.lock().unwrap(); + for &id in a.iter() { + if !oplog.dag.contains(id) { + return Err(LoroError::FrontiersNotFound(id)); + } + } + for &id in b.iter() { + if !oplog.dag.contains(id) { + return Err(LoroError::FrontiersNotFound(id)); + } + } + } + + self.commit_then_stop(); + + let ans = { + self.state.lock().unwrap().stop_and_clear_recording(); + self.checkout_without_emitting(a).unwrap(); + self.state.lock().unwrap().start_recording(); + self.checkout_without_emitting(b).unwrap(); + let mut state = self.state.lock().unwrap(); + let e = state.take_events(); + state.stop_and_clear_recording(); + DiffBatch::new(e) + }; + + Ok(ans) + } + + /// Apply a diff to the current state. + /// + /// This method will not recreate containers with the same [ContainerID]s. + /// While this can be convenient in certain cases, it can break several internal invariants: + /// + /// 1. Each container should appear only once in the document. Allowing containers with the same ID + /// would result in multiple instances of the same container in the document. + /// 2. Unreachable containers should be removable from the state when necessary. + /// + /// However, the diff may contain operations that depend on container IDs. + /// Therefore, users need to provide a `container_remap` to record and retrieve the container ID remapping. + pub fn apply_diff( + &self, + mut diff: DiffBatch, + container_remap: &mut FxHashMap, + ) -> LoroResult<()> { + if self.is_detached() { + return Err(LoroError::EditWhenDetached); + } + + // Sort container from the top to the bottom, so that we can have correct container remap + let containers = diff.0.keys().cloned().sorted_by_cached_key(|cid| { + let idx = self.arena.id_to_idx(cid).unwrap(); + self.arena.get_depth(idx).unwrap().get() + }); + for mut id in containers { + let diff = diff.0.remove(&id).unwrap(); + + while let Some(rid) = container_remap.get(&id) { + id = rid.clone(); + } + let h = self.get_handler(id); + h.apply_diff(diff, &mut |old_id, new_id| { + container_remap.insert(old_id, new_id); + }) + .unwrap(); + } + + Ok(()) + } + /// This is for debugging purpose. It will travel the whole oplog #[inline] pub fn diagnose_size(&self) { @@ -818,25 +989,31 @@ impl LoroDoc { /// This will make the current [DocState] detached from the latest version of [OpLog]. /// Any further import will not be reflected on the [DocState], until user call [LoroDoc::attach()] pub fn checkout(&self, frontiers: &Frontiers) -> LoroResult<()> { - let from = self.state_frontiers(); - let span = info_span!("checkout", to=?frontiers, ?from); - let _g = span.enter(); + self.checkout_without_emitting(frontiers)?; + self.emit_events(); + Ok(()) + } + + #[instrument(level = "info", skip(self))] + fn checkout_without_emitting(&self, frontiers: &Frontiers) -> Result<(), LoroError> { self.commit_then_stop(); let oplog = self.oplog.lock().unwrap(); let mut state = self.state.lock().unwrap(); self.detached.store(true, Release); let mut calc = self.diff_calculator.lock().unwrap(); - for &f in frontiers.iter() { - if !oplog.dag.contains(f) { - return Err(LoroError::InvalidFrontierIdNotFound(f)); + for &i in frontiers.iter() { + if !oplog.dag.contains(i) { + return Err(LoroError::FrontiersNotFound(i)); } } + let before = &oplog.dag.frontiers_to_vv(&state.frontiers).unwrap(); let Some(after) = &oplog.dag.frontiers_to_vv(frontiers) else { return Err(LoroError::NotFoundError( format!("Cannot find the specified version {:?}", frontiers).into_boxed_str(), )); }; + let diff = calc.calc_diff_internal( &oplog, before, @@ -851,8 +1028,6 @@ impl LoroDoc { by: EventTriggerKind::Checkout, new_version: Cow::Owned(frontiers.clone()), }); - drop(state); - self.emit_events(); Ok(()) } @@ -909,7 +1084,7 @@ impl LoroDoc { IS_CHECKING.store(true, std::sync::atomic::Ordering::Release); let peer_id = self.peer_id(); - let s = trace_span!("CheckStateDiffCalcConsistencySlow", ?peer_id); + let s = info_span!("CheckStateDiffCalcConsistencySlow", ?peer_id); let _g = s.enter(); self.commit_then_stop(); let bytes = self.export_from(&Default::default()); @@ -1098,6 +1273,17 @@ fn find_last_delete_op(oplog: &OpLog, id: ID, idx: ContainerIdx) -> Option { None } +#[derive(Debug)] +pub struct CommitWhenDrop<'a> { + doc: &'a LoroDoc, +} + +impl<'a> Drop for CommitWhenDrop<'a> { + fn drop(&mut self) { + self.doc.commit_then_renew() + } +} + #[cfg(test)] mod test { use loro_common::ID; diff --git a/crates/loro-internal/src/op/content.rs b/crates/loro-internal/src/op/content.rs index 4888b5e6f..e8e3c96e3 100644 --- a/crates/loro-internal/src/op/content.rs +++ b/crates/loro-internal/src/op/content.rs @@ -1,9 +1,11 @@ use enum_as_inner::EnumAsInner; +use loro_common::{ContainerID, LoroValue}; use rle::{HasLength, Mergable, Sliceable}; #[cfg(feature = "wasm")] use serde::{Deserialize, Serialize}; use crate::{ + arena::SharedArena, container::{ list::list_op::{InnerListOp, ListOp}, map::MapSet, @@ -21,6 +23,47 @@ pub enum InnerContent { Future(FutureInnerContent), } +impl InnerContent { + pub fn visit_created_children(&self, arena: &SharedArena, f: &mut dyn FnMut(&ContainerID)) { + match self { + InnerContent::List(l) => match l { + InnerListOp::Insert { slice, .. } => { + for v in arena.iter_value_slice(slice.to_range()) { + if let LoroValue::Container(c) = v { + f(&c); + } + } + } + InnerListOp::Set { value, .. } => { + if let LoroValue::Container(c) = value { + f(c); + } + } + + InnerListOp::Move { .. } => {} + InnerListOp::InsertText { .. } => {} + InnerListOp::Delete(_) => {} + InnerListOp::StyleStart { .. } => {} + InnerListOp::StyleEnd => {} + }, + crate::op::InnerContent::Map(m) => { + if let Some(LoroValue::Container(c)) = &m.value { + f(c); + } + } + crate::op::InnerContent::Tree(t) => { + let id = t.target.associated_meta_container(); + f(&id); + } + crate::op::InnerContent::Future(f) => match &f { + #[cfg(feature = "counter")] + crate::op::FutureInnerContent::Counter(_) => {} + crate::op::FutureInnerContent::Unknown { .. } => {} + }, + } + } +} + #[derive(EnumAsInner, Debug, Clone)] pub enum FutureInnerContent { #[cfg(feature = "counter")] diff --git a/crates/loro-internal/src/oplog.rs b/crates/loro-internal/src/oplog.rs index c868affa5..04e2f272a 100644 --- a/crates/loro-internal/src/oplog.rs +++ b/crates/loro-internal/src/oplog.rs @@ -7,6 +7,7 @@ use std::cell::RefCell; use std::cmp::Ordering; use std::mem::take; use std::rc::Rc; +use tracing::debug; use crate::change::{get_sys_timestamp, Change, Lamport, Timestamp}; use crate::configure::Configure; @@ -528,6 +529,16 @@ impl OpLog { None } + pub fn get_deps_of(&self, id: ID) -> Option { + self.get_change_at(id).map(|c| { + if c.id.counter == id.counter { + c.deps.clone() + } else { + Frontiers::from_id(id.inc(-1)) + } + }) + } + pub fn get_remote_change_at(&self, id: ID) -> Option> { let change = self.get_change_at(id)?; Some(self.convert_change_to_remote(change)) @@ -932,6 +943,33 @@ impl OpLog { let change = self.get_change_at(id)?; change.ops.get_by_atom_index(id.counter).map(|x| x.element) } + + pub(crate) fn split_span_based_on_deps(&self, id_span: IdSpan) -> Vec<(IdSpan, Frontiers)> { + let peer = id_span.peer; + let mut counter = id_span.counter.min(); + let span_end = id_span.counter.norm_end(); + let mut ans = Vec::new(); + + while counter < span_end { + let id = ID::new(peer, counter); + let node = self.dag.get(id).unwrap(); + + let f = if node.cnt == counter { + node.deps.clone() + } else if counter > 0 { + id.inc(-1).into() + } else { + unreachable!() + }; + + let cur_end = node.cnt + node.len as Counter; + let len = cur_end.min(span_end) - counter; + ans.push((id.to_span(len as usize), f)); + counter += len; + } + + ans + } } #[derive(Debug)] diff --git a/crates/loro-internal/src/parent.rs b/crates/loro-internal/src/parent.rs index 44453a992..105490155 100644 --- a/crates/loro-internal/src/parent.rs +++ b/crates/loro-internal/src/parent.rs @@ -9,7 +9,7 @@ use loro_common::LoroValue; use crate::{ change::Change, container::{ - list::list_op::{self, ListOp}, + list::list_op::{ListOp}, map::MapSet, tree::tree_op::TreeOp, }, @@ -25,46 +25,10 @@ impl OpLog { pub(super) fn register_container_and_parent_link(&self, change: &Change) { let arena = &self.arena; for op in change.ops.iter() { - match &op.content { - crate::op::InnerContent::List(l) => match l { - list_op::InnerListOp::Insert { slice, .. } => { - for v in arena.iter_value_slice(slice.to_range()) { - if let LoroValue::Container(c) = v { - let idx = arena.register_container(&c); - arena.set_parent(idx, Some(op.container)); - } - } - } - list_op::InnerListOp::Set { value, .. } => { - if let LoroValue::Container(c) = value { - let idx = arena.register_container(c); - arena.set_parent(idx, Some(op.container)); - } - } - - list_op::InnerListOp::Move { .. } => {} - list_op::InnerListOp::InsertText { .. } => {} - list_op::InnerListOp::Delete(_) => {} - list_op::InnerListOp::StyleStart { .. } => {} - list_op::InnerListOp::StyleEnd => {} - }, - crate::op::InnerContent::Map(m) => { - if let Some(LoroValue::Container(c)) = &m.value { - let idx = arena.register_container(c); - arena.set_parent(idx, Some(op.container)); - } - } - crate::op::InnerContent::Tree(t) => { - let id = t.target.associated_meta_container(); - let idx = arena.register_container(&id); - arena.set_parent(idx, Some(op.container)); - } - crate::op::InnerContent::Future(f) => match &f { - #[cfg(feature = "counter")] - crate::op::FutureInnerContent::Counter(_) => {} - crate::op::FutureInnerContent::Unknown { .. } => {} - }, - } + op.content.visit_created_children(arena, &mut |c| { + let idx = arena.register_container(c); + arena.set_parent(idx, Some(op.container)); + }); } } } diff --git a/crates/loro-internal/src/state.rs b/crates/loro-internal/src/state.rs index 6e8d4ca7c..4e12f6aea 100644 --- a/crates/loro-internal/src/state.rs +++ b/crates/loro-internal/src/state.rs @@ -8,7 +8,7 @@ use enum_dispatch::enum_dispatch; use fxhash::{FxHashMap, FxHashSet}; use loro_common::{ContainerID, LoroError, LoroResult}; use loro_delta::DeltaItem; -use tracing::{info, instrument, trace_span}; +use tracing::{info, instrument}; use crate::{ configure::{Configure, DefaultRandom, SecureRandomGenerator}, @@ -478,8 +478,6 @@ impl DocState { let state = get_or_create!(self, idx); if is_recording { // process bring_back before apply - let span = trace_span!("handle internal recording"); - let _g = span.enter(); let external_diff = if diff.bring_back || to_revive_in_this_layer.contains(&idx) { state.apply_diff( @@ -549,7 +547,7 @@ impl DocState { } diff.diff = diffs.into(); - self.frontiers = (*diff.new_version).to_owned(); + (*diff.new_version).clone_into(&mut self.frontiers); if self.is_recording() { self.record_diff(diff) } @@ -990,13 +988,8 @@ impl DocState { fn get_path(&self, idx: ContainerIdx) -> Option> { let mut ans = Vec::new(); let mut idx = idx; - let id = self.arena.idx_to_id(idx).unwrap(); - let s = tracing::span!(tracing::Level::INFO, "GET PATH ", ?id); - let _e = s.enter(); loop { let id = self.arena.idx_to_id(idx).unwrap(); - let s = tracing::span!(tracing::Level::INFO, "GET PATH ", ?id); - let _e = s.enter(); if let Some(parent_idx) = self.arena.get_parent(idx) { let parent_state = self.states.get(&parent_idx)?; let Some(prop) = parent_state.get_child_index(&id) else { diff --git a/crates/loro-internal/src/state/movable_list_state.rs b/crates/loro-internal/src/state/movable_list_state.rs index 1e1c3144a..d30c442c2 100644 --- a/crates/loro-internal/src/state/movable_list_state.rs +++ b/crates/loro-internal/src/state/movable_list_state.rs @@ -2,7 +2,7 @@ use itertools::Itertools; use loro_delta::{array_vec::ArrayVec, DeltaRope, DeltaRopeBuilder}; use serde_columnar::columnar; use std::sync::{Arc, Mutex, Weak}; -use tracing::{instrument, trace_span, warn}; +use tracing::{instrument, warn}; use fxhash::FxHashMap; use generic_btree::BTree; @@ -962,10 +962,6 @@ impl ContainerState for MovableListState { None }; - let id = arena.idx_to_id(self.idx).unwrap(); - let s = trace_span!("ListState", "ListState.id = {:?}", id); - let _e = s.enter(); - let mut event: ListDiff = DeltaRope::new(); let mut maybe_moved: FxHashMap = FxHashMap::default(); diff --git a/crates/loro-internal/src/state/richtext_state.rs b/crates/loro-internal/src/state/richtext_state.rs index c0617dace..47cad0c37 100644 --- a/crates/loro-internal/src/state/richtext_state.rs +++ b/crates/loro-internal/src/state/richtext_state.rs @@ -245,6 +245,7 @@ impl ContainerState for RichtextState { let mut style_starts: FxHashMap, Pos> = FxHashMap::default(); let mut entity_index = 0; let mut event_index = 0; + let mut new_style_deltas: Vec = Vec::new(); for span in richtext.vec.iter() { match span { crate::delta::DeltaItem::Retain { retain: len, .. } => { @@ -311,7 +312,7 @@ impl ContainerState for RichtextState { } delta.chop(); - style_delta.compose(&delta); + new_style_deltas.push(delta); } } } @@ -399,6 +400,9 @@ impl ContainerState for RichtextState { } } + for s in new_style_deltas { + style_delta.compose(&s); + } // self.check_consistency_between_content_and_style_ranges(); ans.compose(&style_delta); Diff::Text(ans) diff --git a/crates/loro-internal/src/state/tree_state.rs b/crates/loro-internal/src/state/tree_state.rs index e39cf6af7..3380da2ba 100644 --- a/crates/loro-internal/src/state/tree_state.rs +++ b/crates/loro-internal/src/state/tree_state.rs @@ -440,7 +440,7 @@ mod btree { target: &Self::QueryArg, caches: &[generic_btree::Child], ) -> FindResult { - match caches.binary_search_by(|x| { + let result = caches.binary_search_by(|x| { let range = x.cache.range.as_ref().unwrap(); if target < &range.start { core::cmp::Ordering::Greater @@ -449,7 +449,9 @@ mod btree { } else { core::cmp::Ordering::Equal } - }) { + }); + + match result { Ok(i) => FindResult::new_found(i, 0), Err(i) => FindResult::new_missing( i.min(caches.len() - 1), @@ -832,6 +834,8 @@ impl ContainerState for TreeState { }); } TreeInternalDiff::Move { parent, position } => { + let old_parent = self.trees.get(&target).unwrap().parent; + let old_index = self.get_index_by_tree_id(&target).unwrap(); self.mov(target, *parent, last_move_op, Some(position.clone()), false) .unwrap(); let index = self.get_index_by_tree_id(&target).unwrap(); @@ -841,15 +845,22 @@ impl ContainerState for TreeState { parent: parent.into_node().ok(), index, position: position.clone(), + old_parent, + old_index, }, }); } TreeInternalDiff::Delete { parent, position } => { + let old_parent = self.trees.get(&target).unwrap().parent; + let old_index = self.get_index_by_tree_id(&target).unwrap(); self.mov(target, *parent, last_move_op, position.clone(), false) .unwrap(); ans.push(TreeDiffItem { target, - action: TreeExternalDiff::Delete, + action: TreeExternalDiff::Delete { + old_parent, + old_index, + }, }); } TreeInternalDiff::MoveInDelete { parent, position } => { @@ -857,9 +868,14 @@ impl ContainerState for TreeState { .unwrap(); } TreeInternalDiff::UnCreate => { + let old_parent = self.trees.get(&target).unwrap().parent; + let old_index = self.get_index_by_tree_id(&target).unwrap(); ans.push(TreeDiffItem { target, - action: TreeExternalDiff::Delete, + action: TreeExternalDiff::Delete { + old_parent, + old_index, + }, }); // delete it from state let parent = self.trees.remove(&target); diff --git a/crates/loro-internal/src/undo.rs b/crates/loro-internal/src/undo.rs new file mode 100644 index 000000000..16ac1ea08 --- /dev/null +++ b/crates/loro-internal/src/undo.rs @@ -0,0 +1,490 @@ +use std::{ + collections::VecDeque, + sync::{Arc, Mutex}, +}; + +use either::Either; +use fxhash::FxHashMap; +use loro_common::{ + ContainerID, Counter, CounterSpan, HasCounterSpan, HasIdSpan, IdSpan, LoroError, LoroResult, + PeerID, +}; +use tracing::{debug_span, info_span, instrument}; + +use crate::{ + change::get_sys_timestamp, + event::{Diff, EventTriggerKind}, + version::Frontiers, + ContainerDiff, DocDiff, LoroDoc, +}; + +#[derive(Debug, Clone, Default)] +pub struct DiffBatch(pub(crate) FxHashMap); + +impl DiffBatch { + pub fn new(diff: Vec) -> Self { + let mut map: FxHashMap = Default::default(); + for d in diff.into_iter() { + for item in d.diff.into_iter() { + let old = map.insert(item.id.clone(), item.diff); + assert!(old.is_none()); + } + } + + Self(map) + } + + pub fn compose(&mut self, other: &Self) { + if other.0.is_empty() { + return; + } + + for (idx, diff) in self.0.iter_mut() { + if let Some(b_diff) = other.0.get(idx) { + diff.compose_ref(b_diff); + } + } + } + + pub fn transform(&mut self, other: &Self, left_priority: bool) { + if other.0.is_empty() { + return; + } + + for (idx, diff) in self.0.iter_mut() { + if let Some(b_diff) = other.0.get(idx) { + diff.transform(b_diff, left_priority); + } + } + } + + pub fn clear(&mut self) { + self.0.clear(); + } +} + +/// UndoManager is responsible for managing undo/redo from the current peer's perspective. +/// +/// Undo/local is local: it cannot be used to undone the changes made by other peers. +/// If you want to undo changes made by other peers, you may need to use the time travel feature. +/// +/// PeerID cannot be changed during the lifetime of the UndoManager +#[derive(Debug)] +pub struct UndoManager { + peer: PeerID, + container_remap: FxHashMap, + inner: Arc>, +} + +#[derive(Debug)] +struct UndoManagerInner { + latest_counter: Counter, + undo_stack: Stack, + redo_stack: Stack, + processing_undo: bool, + last_undo_time: i64, + merge_interval: i64, + max_stack_size: usize, +} + +#[derive(Debug)] +struct Stack { + stack: VecDeque<(VecDeque, Arc>)>, + size: usize, +} + +impl Stack { + pub fn new() -> Self { + let mut stack = VecDeque::new(); + stack.push_back((VecDeque::new(), Arc::new(Mutex::new(Default::default())))); + Stack { stack, size: 0 } + } + + pub fn pop(&mut self) -> Option<(CounterSpan, Arc>)> { + while self.stack.back().unwrap().0.is_empty() && self.stack.len() > 1 { + let (_, diff) = self.stack.pop_back().unwrap(); + let diff = diff.try_lock().unwrap(); + if !diff.0.is_empty() { + self.stack + .back_mut() + .unwrap() + .1 + .try_lock() + .unwrap() + .compose(&diff); + } + } + + if self.stack.len() == 1 && self.stack.back().unwrap().0.is_empty() { + self.stack.back_mut().unwrap().1.try_lock().unwrap().clear(); + return None; + } + + self.size -= 1; + let last = self.stack.back_mut().unwrap(); + last.0.pop_back().map(|x| (x, last.1.clone())) + } + + pub fn push(&mut self, span: CounterSpan) { + self.push_with_merge(span, false) + } + + pub fn push_with_merge(&mut self, span: CounterSpan, can_merge: bool) { + let last = self.stack.back_mut().unwrap(); + let mut last_remote_diff = last.1.try_lock().unwrap(); + if !last_remote_diff.0.is_empty() { + // If the remote diff is not empty, we cannot merge + if last.0.is_empty() { + last.0.push_back(span); + last_remote_diff.clear(); + } else { + drop(last_remote_diff); + let mut v = VecDeque::new(); + v.push_back(span); + self.stack + .push_back((v, Arc::new(Mutex::new(DiffBatch::default())))); + } + + self.size += 1; + } else { + if can_merge { + if let Some(last_span) = last.0.back_mut() { + if last_span.end == span.start { + // merge the span + last_span.end = span.end; + return; + } + } + } + + self.size += 1; + last.0.push_back(span); + } + } + + pub fn compose_remote_event(&mut self, diff: &[&ContainerDiff]) { + if self.is_empty() { + return; + } + + let remote_diff = &mut self.stack.back_mut().unwrap().1; + let mut remote_diff = remote_diff.try_lock().unwrap(); + for e in diff { + if let Some(d) = remote_diff.0.get_mut(&e.id) { + d.compose_ref(&e.diff); + } else { + remote_diff.0.insert(e.id.clone(), e.diff.clone()); + } + } + } + + pub fn transform_based_on_this_delta(&mut self, diff: &DiffBatch) { + if self.is_empty() { + return; + } + + let remote_diff = &mut self.stack.back_mut().unwrap().1; + remote_diff.try_lock().unwrap().transform(diff, false); + } + + pub fn clear(&mut self) { + self.stack = VecDeque::new(); + self.stack.push_back((VecDeque::new(), Default::default())); + self.size = 0; + } + + pub fn is_empty(&self) -> bool { + self.size == 0 + } + + pub fn len(&self) -> usize { + self.size + } + + fn pop_front(&mut self) { + if self.is_empty() { + return; + } + + self.size -= 1; + let first = self.stack.front_mut().unwrap(); + let f = first.0.pop_front(); + assert!(f.is_some()); + if first.0.is_empty() { + self.stack.pop_front(); + } + } +} + +impl Default for Stack { + fn default() -> Self { + Stack::new() + } +} + +impl UndoManagerInner { + fn new(last_counter: Counter) -> Self { + UndoManagerInner { + latest_counter: last_counter, + undo_stack: Default::default(), + redo_stack: Default::default(), + processing_undo: false, + merge_interval: 0, + last_undo_time: 0, + max_stack_size: usize::MAX, + } + } + + fn record_checkpoint(&mut self, latest_counter: Counter) { + if latest_counter == self.latest_counter { + return; + } + + assert!(self.latest_counter < latest_counter); + let now = get_sys_timestamp(); + let span = CounterSpan::new(self.latest_counter, latest_counter); + if !self.undo_stack.is_empty() && now - self.last_undo_time < self.merge_interval { + self.undo_stack.push_with_merge(span, true); + } else { + self.last_undo_time = now; + self.undo_stack.push(span); + } + + self.latest_counter = latest_counter; + self.redo_stack.clear(); + while self.undo_stack.len() > self.max_stack_size { + self.undo_stack.pop_front(); + } + } +} + +fn get_counter_end(doc: &LoroDoc, peer: PeerID) -> Counter { + doc.oplog() + .lock() + .unwrap() + .get_peer_changes(peer) + .and_then(|x| x.last().map(|x| x.ctr_end())) + .unwrap_or(0) +} + +impl UndoManager { + pub fn new(doc: &LoroDoc) -> Self { + let peer = doc.peer_id(); + let inner = Arc::new(Mutex::new(UndoManagerInner::new(get_counter_end( + doc, peer, + )))); + let inner_clone = inner.clone(); + doc.subscribe_root(Arc::new(move |event| match event.event_meta.by { + EventTriggerKind::Local => { + // TODO: PERF undo can be significantly faster if we can get + // the DiffBatch for undo here + let Ok(mut inner) = inner_clone.try_lock() else { + return; + }; + if !inner.processing_undo { + if let Some(id) = event.event_meta.to.iter().find(|x| x.peer == peer) { + inner.record_checkpoint(id.counter + 1); + } + } + } + EventTriggerKind::Import => { + let mut inner = inner_clone.try_lock().unwrap(); + inner.undo_stack.compose_remote_event(event.events); + inner.redo_stack.compose_remote_event(event.events); + } + EventTriggerKind::Checkout => {} + })); + + UndoManager { + peer, + container_remap: Default::default(), + inner, + } + } + + pub fn set_merge_interval(&mut self, interval: i64) { + self.inner.try_lock().unwrap().merge_interval = interval; + } + + pub fn set_max_undo_steps(&mut self, size: usize) { + self.inner.try_lock().unwrap().max_stack_size = size; + } + + pub fn record_new_checkpoint(&mut self, doc: &LoroDoc) -> LoroResult<()> { + if doc.peer_id() != self.peer { + return Err(LoroError::UndoWithDifferentPeerId { + expected: self.peer, + actual: doc.peer_id(), + }); + } + + doc.commit_then_renew(); + let counter = get_counter_end(doc, self.peer); + self.inner.try_lock().unwrap().record_checkpoint(counter); + Ok(()) + } + + #[instrument(skip_all)] + pub fn undo(&mut self, doc: &LoroDoc) -> LoroResult<()> { + self.perform(doc, |x| &mut x.undo_stack, |x| &mut x.redo_stack) + } + + #[instrument(skip_all)] + pub fn redo(&mut self, doc: &LoroDoc) -> LoroResult<()> { + self.perform(doc, |x| &mut x.redo_stack, |x| &mut x.undo_stack) + } + + fn perform( + &mut self, + doc: &LoroDoc, + get_stack: impl Fn(&mut UndoManagerInner) -> &mut Stack, + get_opposite: impl Fn(&mut UndoManagerInner) -> &mut Stack, + ) -> LoroResult<()> { + self.record_new_checkpoint(doc)?; + let end_counter = get_counter_end(doc, self.peer); + let mut top = { + let mut inner = self.inner.try_lock().unwrap(); + inner.processing_undo = true; + get_stack(&mut inner).pop() + }; + + while let Some((span, e)) = top { + { + let inner = self.inner.clone(); + // TODO: Perf we can try to avoid this clone + let e = e.try_lock().unwrap().clone(); + doc.undo_internal( + IdSpan { + peer: self.peer, + counter: span, + }, + &mut self.container_remap, + Some(&e), + &mut |diff| { + info_span!("transform remote diff").in_scope(|| { + let mut inner = inner.try_lock().unwrap(); + get_stack(&mut inner).transform_based_on_this_delta(diff); + }); + }, + )?; + } + let new_counter = get_counter_end(doc, self.peer); + if end_counter != new_counter { + let mut inner = self.inner.try_lock().unwrap(); + get_opposite(&mut inner).push(CounterSpan::new(end_counter, new_counter)); + inner.latest_counter = new_counter; + break; + } else { + // continue to pop the undo item as this undo is a no-op + top = get_stack(&mut self.inner.try_lock().unwrap()).pop(); + continue; + } + } + + self.inner.try_lock().unwrap().processing_undo = false; + Ok(()) + } + + pub fn can_undo(&self) -> bool { + !self.inner.try_lock().unwrap().undo_stack.is_empty() + } + + pub fn can_redo(&self) -> bool { + !self.inner.try_lock().unwrap().redo_stack.is_empty() + } +} + +/// Undo the given spans of operations. +/// +/// # Parameters +/// +/// - `spans`: A vector of tuples where each tuple contains an `IdSpan` and its associated `Frontiers`. +/// - `IdSpan`: Represents a span of operations identified by an ID. +/// - `Frontiers`: Represents the deps of the given id_span +/// - `latest_frontiers`: The latest frontiers of the document +/// - `calc_diff`: A closure that takes two `Frontiers` and calculates the difference between them, returning a `DiffBatch`. +/// +/// # Returns +/// +/// - `DiffBatch`: Applying this batch on the `latest_frontiers` will undo the ops in the given spans. +pub(crate) fn undo( + spans: Vec<(IdSpan, Frontiers)>, + last_frontiers_or_last_bi: Either<&Frontiers, &DiffBatch>, + calc_diff: impl Fn(&Frontiers, &Frontiers) -> DiffBatch, + on_last_event_a: &mut dyn FnMut(&DiffBatch), +) -> DiffBatch { + // The process of performing undo is: + // + // 0. Split the span into a series of continuous spans. There is no external dep within each continuous span. + // + // For each continuous span_i: + // + // 1. a. Calculate the event of checkout from id_span.last to id_span.deps, call it Ai. It undo the ops in the current span. + // b. Calculate A'i = Ai + T(Ci-1, Ai) if i > 0, otherwise A'i = Ai. + // NOTE: A'i can undo the ops in the current span and the previous spans, if it's applied on the id_span.last version. + // 2. Calculate the event of checkout from id_span.last to [the next span's last id] or [the latest version], call it Bi. + // 3. Transform event A'i based on Bi, call it Ci + // 4. If span_i is the last span, apply Ci to the current state. + + // ------------------------------------------------------- + // 0. Split the span into a series of continuous spans + // ------------------------------------------------------- + + let mut last_ci: Option = None; + for i in 0..spans.len() { + debug_span!("Undo", ?i, "Undo span {:?}", &spans[i]).in_scope(|| { + let (this_id_span, this_deps) = &spans[i]; + // --------------------------------------- + // 1.a Calc event A_i + // --------------------------------------- + let mut event_a_i = debug_span!("1. Calc event A_i").in_scope(|| { + // Checkout to the last id of the id_span + calc_diff(&this_id_span.id_last().into(), this_deps) + }); + + // --------------------------------------- + // 2. Calc event B_i + // --------------------------------------- + let mut stack_diff_batch = None; + let event_b_i = debug_span!("2. Calc event B_i").in_scope(|| { + let next = if i + 1 < spans.len() { + spans[i + 1].0.id_last().into() + } else { + match last_frontiers_or_last_bi { + Either::Left(last_frontiers) => last_frontiers.clone(), + Either::Right(right) => return right, + } + }; + stack_diff_batch = Some(calc_diff(&this_id_span.id_last().into(), &next)); + stack_diff_batch.as_ref().unwrap() + }); + + // event_a_prime can undo the ops in the current span and the previous spans + let mut event_a_prime = if let Some(mut last_ci) = last_ci.take() { + // ------------------------------------------------------------------------------ + // 1.b Transform and apply Ci-1 based on Ai, call it A'i + // ------------------------------------------------------------------------------ + + last_ci.transform(&event_a_i, true); + + event_a_i.compose(&last_ci); + event_a_i + } else { + event_a_i + }; + if i == spans.len() - 1 { + on_last_event_a(&event_a_prime); + } + + // -------------------------------------------------- + // 3. Transform event A'_i based on B_i, call it C_i + // -------------------------------------------------- + event_a_prime.transform(event_b_i, true); + let c_i = event_a_prime; + + last_ci = Some(c_i); + }); + } + + last_ci.unwrap() +} diff --git a/crates/loro-internal/src/value.rs b/crates/loro-internal/src/value.rs index 18dc52478..060128834 100644 --- a/crates/loro-internal/src/value.rs +++ b/crates/loro-internal/src/value.rs @@ -568,13 +568,14 @@ pub mod wasm { ) .unwrap(); } - TreeExternalDiff::Delete => { + TreeExternalDiff::Delete { .. } => { js_sys::Reflect::set(&obj, &"action".into(), &"delete".into()).unwrap(); } TreeExternalDiff::Move { parent, index, position, + .. } => { js_sys::Reflect::set(&obj, &"action".into(), &"move".into()).unwrap(); js_sys::Reflect::set(&obj, &"parent".into(), &JsValue::from(*parent)) diff --git a/crates/loro-internal/tests/autocommit.rs b/crates/loro-internal/tests/autocommit.rs index 31f9b56e8..cd6f4deda 100644 --- a/crates/loro-internal/tests/autocommit.rs +++ b/crates/loro-internal/tests/autocommit.rs @@ -4,7 +4,7 @@ use serde_json::json; #[test] fn auto_commit() { - let mut doc_a = LoroDoc::default(); + let doc_a = LoroDoc::default(); doc_a.set_peer_id(1).unwrap(); doc_a.start_auto_commit(); let text_a = doc_a.get_text("text"); @@ -13,7 +13,7 @@ fn auto_commit() { assert_eq!(&**text_a.get_value().as_string().unwrap(), "heo"); let bytes = doc_a.export_from(&Default::default()); - let mut doc_b = LoroDoc::default(); + let doc_b = LoroDoc::default(); doc_b.start_auto_commit(); doc_b.set_peer_id(2).unwrap(); let text_b = doc_b.get_text("text"); @@ -26,7 +26,7 @@ fn auto_commit() { #[test] fn auto_commit_list() { - let mut doc_a = LoroDoc::default(); + let doc_a = LoroDoc::default(); doc_a.start_auto_commit(); let list_a = doc_a.get_list("list"); list_a.insert(0, "hello").unwrap(); @@ -42,7 +42,7 @@ fn auto_commit_list() { #[test] fn auto_commit_with_checkout() { - let mut doc = LoroDoc::default(); + let doc = LoroDoc::default(); doc.set_peer_id(1).unwrap(); doc.start_auto_commit(); let map = doc.get_map("a"); diff --git a/crates/loro-wasm/src/awareness.rs b/crates/loro-wasm/src/awareness.rs index 36fb478b4..1af797cd3 100644 --- a/crates/loro-wasm/src/awareness.rs +++ b/crates/loro-wasm/src/awareness.rs @@ -17,8 +17,10 @@ pub struct AwarenessWasm { #[wasm_bindgen] extern "C" { + /// Awareness states #[wasm_bindgen(typescript_type = "{[peer in PeerID]: unknown}")] pub type JsAwarenessStates; + /// Awareness apply result #[wasm_bindgen(typescript_type = "{ updated: PeerID[], added: PeerID[] }")] pub type JsAwarenessApplyResult; } diff --git a/crates/loro-wasm/src/convert.rs b/crates/loro-wasm/src/convert.rs index 97d1e61c4..a899b26eb 100644 --- a/crates/loro-wasm/src/convert.rs +++ b/crates/loro-wasm/src/convert.rs @@ -289,6 +289,8 @@ pub(crate) fn handler_to_js_value(handler: Handler, doc: Option>) - Handler::List(l) => LoroList { handler: l, doc }.into(), Handler::Tree(t) => LoroTree { handler: t, doc }.into(), Handler::MovableList(m) => LoroMovableList { handler: m, doc }.into(), + #[cfg(feature = "counter")] + Handler::Counter(c) => unimplemented!(), Handler::Unknown(_) => unreachable!(), } } diff --git a/crates/loro-wasm/src/lib.rs b/crates/loro-wasm/src/lib.rs index 8c8d77408..59e07e033 100644 --- a/crates/loro-wasm/src/lib.rs +++ b/crates/loro-wasm/src/lib.rs @@ -1,6 +1,8 @@ //! Loro WASM bindings. #![allow(non_snake_case)] +#![allow(clippy::empty_docs)] #![warn(missing_docs)] + use convert::resolved_diff_to_js; use js_sys::{Array, Object, Promise, Reflect, Uint8Array}; use loro_internal::{ @@ -17,7 +19,7 @@ use loro_internal::{ obs::SubID, version::Frontiers, ContainerType, DiffEvent, HandlerTrait, LoroDoc, LoroValue, MovableListHandler, - VersionVector as InternalVersionVector, + UndoManager as InnerUndoManager, VersionVector as InternalVersionVector, }; use rle::HasLength; use serde::{Deserialize, Serialize}; @@ -153,6 +155,10 @@ extern "C" { pub type JsSide; #[wasm_bindgen(typescript_type = "{ update?: Cursor, offset: number, side: Side }")] pub type JsCursorQueryAns; + #[wasm_bindgen( + typescript_type = "{ mergeInterval?: number, maxUndoSteps?: number } | undefined" + )] + pub type JsUndoConfig; } mod observer { @@ -274,7 +280,7 @@ impl Loro { /// New document will have random peer id. #[wasm_bindgen(constructor)] pub fn new() -> Self { - let mut doc = LoroDoc::new(); + let doc = LoroDoc::new(); doc.start_auto_commit(); Self(Arc::new(doc)) } @@ -391,7 +397,7 @@ impl Loro { /// #[wasm_bindgen(js_name = "fromSnapshot")] pub fn from_snapshot(snapshot: &[u8]) -> JsResult { - let mut doc = LoroDoc::from_snapshot(snapshot)?; + let doc = LoroDoc::from_snapshot(snapshot)?; doc.start_auto_commit(); Ok(Self(Arc::new(doc))) } @@ -3273,6 +3279,84 @@ fn loro_value_to_js_value_or_container( } } +/// `UndoManager` is responsible for handling undo and redo operations. +/// +/// By default, the maxUndoSteps is set to 100, mergeInterval is set to 1000 ms. +/// +/// Each commit made by the current peer is recorded as an undo step in the `UndoManager`. +/// Undo steps can be merged if they occur within a specified merge interval. +/// +/// Note that undo operations are local and cannot revert changes made by other peers. +/// To undo changes made by other peers, consider using the time travel feature. +/// +/// Once the `peerId` is bound to the `UndoManager` in the document, it cannot be changed. +/// Otherwise, the `UndoManager` may not function correctly. +#[wasm_bindgen] +#[derive(Debug)] +pub struct UndoManager { + undo: InnerUndoManager, + doc: Arc, +} + +#[wasm_bindgen] +impl UndoManager { + /// Create a new undo manager. It will bind on the current PeerID. + /// PeerID cannot be changed during the lifetime of the UndoManager. + #[wasm_bindgen(constructor)] + pub fn new(doc: &Loro, config: JsUndoConfig) -> Self { + let max_undo_steps = Reflect::get(&config, &JsValue::from_str("maxUndoSteps")) + .unwrap_or(JsValue::from_f64(100.0)) + .as_f64() + .unwrap_or(100.0) as usize; + let merge_interval = Reflect::get(&config, &JsValue::from_str("mergeInterval")) + .unwrap_or(JsValue::from_f64(1000.0)) + .as_f64() + .unwrap_or(1000.0) as i64; + let mut undo = InnerUndoManager::new(&doc.0); + undo.set_max_undo_steps(max_undo_steps); + undo.set_merge_interval(merge_interval); + UndoManager { + undo, + doc: doc.0.clone(), + } + } + + /// Undo the last operation. + pub fn undo(&mut self) -> JsResult<()> { + self.undo.undo(&self.doc)?; + Ok(()) + } + + /// Redo the last undone operation. + pub fn redo(&mut self) -> JsResult<()> { + self.undo.redo(&self.doc)?; + Ok(()) + } + + /// Can undo the last operation. + pub fn canUndo(&self) -> bool { + self.undo.can_undo() + } + + /// Can redo the last operation. + pub fn canRedo(&self) -> bool { + self.undo.can_redo() + } + + /// The number of max undo steps. + /// If the number of undo steps exceeds this number, the oldest undo step will be removed. + pub fn setMaxUndoSteps(&mut self, steps: usize) { + self.undo.set_max_undo_steps(steps); + } + + /// Set the merge interval (in ms). + /// If the interval is set to 0, the undo steps will not be merged. + /// Otherwise, the undo steps will be merged if the interval between the two steps is less than the given interval. + pub fn setMergeInterval(&mut self, interval: f64) { + self.undo.set_merge_interval(interval as i64); + } +} + /// [VersionVector](https://en.wikipedia.org/wiki/Version_vector) /// is a map from [PeerID] to [Counter]. Its a right-open interval. /// diff --git a/crates/loro/Cargo.toml b/crates/loro/Cargo.toml index ae6eda6f4..900e71398 100644 --- a/crates/loro/Cargo.toml +++ b/crates/loro/Cargo.toml @@ -23,6 +23,7 @@ tracing = "0.1" [dev-dependencies] serde_json = "1.0.87" +anyhow = "1.0.83" ctor = "0.2" dev-utils = { path = "../dev-utils" } diff --git a/crates/loro/src/lib.rs b/crates/loro/src/lib.rs index 1d801664a..973836226 100644 --- a/crates/loro/src/lib.rs +++ b/crates/loro/src/lib.rs @@ -12,6 +12,8 @@ use loro_internal::cursor::Side; use loro_internal::encoding::ImportBlobMetadata; use loro_internal::handler::HandlerTrait; use loro_internal::handler::ValueOrHandler; +use loro_internal::loro::CommitWhenDrop; +use loro_internal::loro_common::IdSpan; use loro_internal::LoroDoc as InnerLoroDoc; use loro_internal::OpLog; @@ -41,6 +43,7 @@ pub use loro_internal::id::{PeerID, TreeID, ID}; pub use loro_internal::obs::SubID; pub use loro_internal::oplog::FrontiersNotIncluded; pub use loro_internal::version::{Frontiers, VersionVector}; +pub use loro_internal::UndoManager as InnerUndoManager; pub use loro_internal::{loro_value, to_value}; pub use loro_internal::{LoroError, LoroResult, LoroValue, ToJson}; @@ -52,6 +55,7 @@ pub use counter::LoroCounter; /// `LoroDoc` is the entry for the whole document. /// When it's dropped, all the associated [`Handler`]s will be invalidated. #[derive(Debug)] +#[repr(transparent)] pub struct LoroDoc { doc: InnerLoroDoc, } @@ -65,7 +69,7 @@ impl Default for LoroDoc { impl LoroDoc { /// Create a new `LoroDoc` instance. pub fn new() -> Self { - let mut doc = InnerLoroDoc::default(); + let doc = InnerLoroDoc::default(); doc.start_auto_commit(); LoroDoc { doc } @@ -240,7 +244,7 @@ impl LoroDoc { #[cfg(feature = "counter")] /// Get a [LoroCounter] by container id. - /// + /// /// If the provided id is string, it will be converted into a root container id with the name of the string. pub fn get_counter(&self, id: I) -> LoroCounter { LoroCounter { @@ -471,6 +475,12 @@ impl LoroDoc { ) -> Result { self.doc.query_pos(cursor) } + + /// Undo the operations between the given id_span. It can be used even in a collaborative environment. + pub fn undo(&self, id_span: IdSpan) -> LoroResult { + self.doc + .undo_internal(id_span, &mut Default::default(), None, &mut |_| {}) + } } /// It's used to prevent the user from implementing the trait directly. @@ -1810,3 +1820,40 @@ pub enum ValueOrContainer { /// A container. Container(Container), } + +/// UndoManager can be used to undo and redo the changes made to the document with a certain peer. +#[derive(Debug)] +#[repr(transparent)] +pub struct UndoManager(InnerUndoManager); + +impl UndoManager { + /// Create a new UndoManager. + pub fn new(doc: &LoroDoc) -> Self { + Self(InnerUndoManager::new(&doc.doc)) + } + + /// Undo the last change made by the peer. + pub fn undo(&mut self, doc: &LoroDoc) -> LoroResult<()> { + self.0.undo(&doc.doc) + } + + /// Redo the last change made by the peer. + pub fn redo(&mut self, doc: &LoroDoc) -> LoroResult<()> { + self.0.redo(&doc.doc) + } + + /// Record a new checkpoint. + pub fn record_new_checkpoint(&mut self, doc: &LoroDoc) -> LoroResult<()> { + self.0.record_new_checkpoint(&doc.doc) + } + + /// Whether the undo manager can undo. + pub fn can_undo(&self) -> bool { + self.0.can_undo() + } + + /// Whether the undo manager can redo. + pub fn can_redo(&self) -> bool { + self.0.can_redo() + } +} diff --git a/crates/loro/tests/integration_test/mod.rs b/crates/loro/tests/integration_test/mod.rs new file mode 100644 index 000000000..2527d521c --- /dev/null +++ b/crates/loro/tests/integration_test/mod.rs @@ -0,0 +1 @@ +mod undo_test; diff --git a/crates/loro/tests/integration_test/undo_test.rs b/crates/loro/tests/integration_test/undo_test.rs new file mode 100644 index 000000000..a30c00f73 --- /dev/null +++ b/crates/loro/tests/integration_test/undo_test.rs @@ -0,0 +1,1554 @@ +use std::sync::Arc; + +use loro::{ + Frontiers, LoroDoc, LoroError, LoroList, LoroMap, LoroResult, LoroText, LoroValue, + StyleConfigMap, ToJson, UndoManager, +}; +use loro_internal::{ + configure::StyleConfig, + id::{Counter, ID}, + loro_common::IdSpan, +}; +use serde_json::json; +use tracing::{debug_span, info_span}; + +#[test] +fn basic_text_undo() -> Result<(), LoroError> { + let doc = LoroDoc::new(); + doc.set_peer_id(1)?; + let text = doc.get_text("text"); + text.insert(0, "123")?; + doc.commit(); + doc.undo(ID::new(1, 1).into())?; + assert_eq!(doc.get_deep_value().to_json_value(), json!({"text": "13"})); + assert_eq!(doc.oplog_frontiers(), Frontiers::from(ID::new(1, 3))); + assert_eq!(doc.state_frontiers(), Frontiers::from(ID::new(1, 3))); + + // This should not change anything, because the content is already deleted + doc.undo(ID::new(1, 1).into())?; + assert_eq!(doc.get_deep_value().to_json_value(), json!({"text": "13"})); + assert_eq!(doc.oplog_frontiers(), Frontiers::from(ID::new(1, 3))); + assert_eq!(doc.state_frontiers(), Frontiers::from(ID::new(1, 3))); + + // This should remove the content + doc.undo(IdSpan::new(1, 0, 3))?; + assert_eq!(doc.get_deep_value().to_json_value(), json!({"text": ""})); + assert_eq!(doc.oplog_frontiers(), Frontiers::from(ID::new(1, 5))); + assert_eq!(doc.state_frontiers(), Frontiers::from(ID::new(1, 5))); + + // Now we redo the undos + doc.undo(IdSpan::new(1, 3, 6))?; + assert_eq!(doc.get_deep_value().to_json_value(), json!({"text": "123"})); + assert_eq!(doc.oplog_frontiers(), Frontiers::from(ID::new(1, 8))); + assert_eq!(doc.state_frontiers(), Frontiers::from(ID::new(1, 8))); + Ok(()) +} + +#[test] +fn text_undo_insert_should_only_delete_once() -> Result<(), LoroError> { + let doc = LoroDoc::new(); + doc.set_peer_id(1)?; + let text = doc.get_text("text"); + text.insert(0, "123")?; + doc.commit(); + text.delete(1, 2)?; + doc.commit(); + assert_eq!(doc.get_deep_value().to_json_value(), json!({"text": "1"})); + + // nothing should happen here, because the delete has already happened + doc.undo(ID::new(1, 1).into())?; + assert_eq!(doc.get_deep_value().to_json_value(), json!({"text": "1"})); + + // nothing should happen here, because the delete has already happened + doc.undo(ID::new(1, 2).into())?; + assert_eq!(doc.get_deep_value().to_json_value(), json!({"text": "1"})); + + doc.undo(ID::new(1, 0).into())?; + assert_eq!(doc.get_deep_value().to_json_value(), json!({"text": ""})); + Ok(()) +} + +#[test] +fn collaborative_text_undo() -> Result<(), LoroError> { + let doc_a = LoroDoc::new(); + doc_a.set_peer_id(1)?; + let text = doc_a.get_text("text"); + text.insert(0, "123")?; + doc_a.commit(); + + let doc_b = LoroDoc::new(); + doc_b.import(&doc_a.export_from(&Default::default()))?; + doc_b.get_text("text").insert(1, "y")?; + doc_b.commit(); + doc_b.get_text("text").insert(0, "x")?; + // doc_b = x1y23 + doc_b.commit(); + // doc_a = x1y23 + doc_a.import(&doc_b.export_from(&Default::default()))?; + + doc_a.undo(ID::new(1, 0).into())?; + assert_eq!( + doc_a.get_deep_value().to_json_value(), + json!({"text": "xy23"}) + ); + assert_eq!(doc_a.oplog_frontiers(), Frontiers::from(ID::new(1, 3))); + assert_eq!(doc_a.state_frontiers(), Frontiers::from(ID::new(1, 3))); + + // This should not change anything, because the content is already deleted + doc_a.undo(ID::new(1, 0).into())?; + assert_eq!( + doc_a.get_deep_value().to_json_value(), + json!({"text": "xy23"}) + ); + assert_eq!(doc_a.oplog_frontiers(), Frontiers::from(ID::new(1, 3))); + assert_eq!(doc_a.state_frontiers(), Frontiers::from(ID::new(1, 3))); + + // This should remove the content created by A + doc_a.undo(IdSpan::new(1, 0, 3))?; + assert_eq!( + doc_a.get_deep_value().to_json_value(), + json!({"text": "xy"}) + ); + assert_eq!(doc_a.oplog_frontiers(), Frontiers::from(ID::new(1, 5))); + assert_eq!(doc_a.state_frontiers(), Frontiers::from(ID::new(1, 5))); + + // Now we redo the undos + doc_a.undo(IdSpan::new(1, 3, 6))?; + assert_eq!( + doc_a.get_deep_value().to_json_value(), + json!({"text": "x1y23"}) + ); + assert_eq!(doc_a.oplog_frontiers(), Frontiers::from(ID::new(1, 8))); + assert_eq!(doc_a.state_frontiers(), Frontiers::from(ID::new(1, 8))); + Ok(()) +} + +#[test] +fn basic_list_undo_insertion() -> Result<(), LoroError> { + let doc = LoroDoc::new(); + doc.set_peer_id(1)?; + let list = doc.get_list("list"); + list.push("12")?; + list.push("34")?; + assert_eq!( + doc.get_deep_value().to_json_value(), + json!({ + "list": ["12", "34"] + }) + ); + doc.undo(ID::new(1, 1).into())?; + assert_eq!( + doc.get_deep_value().to_json_value(), + json!({ + "list": ["12"] + }) + ); + doc.undo(ID::new(1, 0).into())?; + assert_eq!( + doc.get_deep_value().to_json_value(), + json!({ + "list": [] + }) + ); + + Ok(()) +} + +#[test] +fn basic_list_undo_deletion() -> Result<(), LoroError> { + let doc = LoroDoc::new(); + doc.set_peer_id(1)?; + let list = doc.get_list("list"); + list.push("12")?; // op 0 + list.push("34")?; // op 1 + list.delete(1, 1)?; // op 2 + assert_eq!( + doc.get_deep_value().to_json_value(), + json!({ + "list": ["12"] + }) + ); + doc.undo(ID::new(1, 2).into())?; // op 3 + assert_eq!( + doc.get_deep_value().to_json_value(), + json!({ + "list": ["12", "34"] + }) + ); + + // Now, to undo "34" correctly we need to include the latest change + // If we only undo op 1, op 3 will create "34" again. + doc.undo(IdSpan::new(1, 1, 4))?; // op 4 + assert_eq!( + doc.get_deep_value().to_json_value(), + json!({ + "list": ["12"] + }) + ); + + assert_eq!(doc.oplog_frontiers()[0].counter, 4); + + Ok(()) +} + +#[test] +fn basic_map_undo() -> Result<(), LoroError> { + let doc_a = LoroDoc::new(); + doc_a.set_peer_id(1)?; + doc_a.get_map("map").insert("a", "a")?; + doc_a.get_map("map").insert("b", "b")?; + doc_a.commit(); + doc_a.get_map("map").delete("a")?; + doc_a.commit(); + doc_a.undo(ID::new(1, 2).into())?; // op 3 + assert_eq!( + doc_a.get_deep_value().to_json_value(), + json!({"map": {"a": "a", "b": "b"}}) + ); + + doc_a.undo(ID::new(1, 1).into())?; // op 4 + assert_eq!( + doc_a.get_deep_value().to_json_value(), + json!({"map": {"a": "a"}}) + ); + + doc_a.undo(ID::new(1, 0).into())?; // op 5 + assert_eq!(doc_a.get_deep_value().to_json_value(), json!({"map": {}})); + + // Redo + doc_a.undo(ID::new(1, 5).into())?; + assert_eq!( + doc_a.get_deep_value().to_json_value(), + json!({"map": { + "a": "a" + }}) + ); + + // Redo + doc_a.undo(ID::new(1, 4).into())?; + assert_eq!( + doc_a.get_deep_value().to_json_value(), + json!({"map": { + "a": "a", + "b": "b" + }}) + ); + + // Redo + doc_a.undo(ID::new(1, 3).into())?; + assert_eq!( + doc_a.get_deep_value().to_json_value(), + json!({"map": { + "b": "b" + }}) + ); + + Ok(()) +} + +#[test] +fn map_collaborative_undo() -> Result<(), LoroError> { + let doc_a = LoroDoc::new(); + doc_a.set_peer_id(1)?; + doc_a.get_map("map").insert("a", "a")?; + doc_a.commit(); + + let doc_b = LoroDoc::new(); + doc_b.import(&doc_a.export_from(&Default::default()))?; + doc_b.get_map("map").insert("b", "b")?; + doc_b.commit(); + + doc_a.import(&doc_b.export_from(&Default::default()))?; + doc_a.undo(ID::new(1, 0).into())?; + assert_eq!( + doc_a.get_deep_value().to_json_value(), + json!({"map": {"b": "b"}}) + ); + Ok(()) +} + +#[test] +fn map_container_undo() -> Result<(), LoroError> { + let doc = LoroDoc::new(); + doc.set_peer_id(1)?; + let map = doc.get_map("map"); + let text = map.insert_container("text", LoroText::new())?; // op 0 + text.insert(0, "T")?; // op 1 + map.insert("number", 0)?; // op 2 + doc.undo(ID::new(1, 2).into())?; // op 3 + assert_eq!( + doc.get_deep_value().to_json_value(), + json!({"map": {"text": "T"}}) + ); + doc.undo(ID::new(1, 1).into())?; // op 4 + doc.undo(ID::new(1, 0).into())?; // op 5 + assert_eq!(doc.get_deep_value().to_json_value(), json!({"map": {}})); + doc.undo(IdSpan::new(1, 3, 6))?; // redo all + assert_eq!( + doc.get_deep_value().to_json_value(), + json!({"map": {"text": "T", "number": 0}}) + ); + Ok(()) +} + +/// This test case matches the example given here +/// +/// [PLF23] Extending Automerge: Undo, Redo, and Move +/// Leo Stewen, Martin Kleppmann, Liangrun Da +/// https://youtu.be/uP7AKExkMGU?si=TR2JHRdmAitOVaMw&t=768 +/// +/// +/// ┌─A-Set───┐ ┌─B-set ┌──A-undo ┌─A-redo +/// │ │ │ │ │ │ │ │ +/// │ │ │ │ │ │ │ │ +/// │ ▼ │ ▼ │ ▼ │ ▼ +/// ┌────┴────┐ ┌────┴─┐ ┌─────┴──┐ ┌──────┴┐ ┌──────┐ +/// │ │ │ │ │ │ │ │ │ │ +/// │ Black │ │ Red │ │ Green │ │ Black │ │Green │ +/// │ │ │ │ │ │ │ │ │ │ +/// └─────────┘ └──────┘ └────────┘ └───────┘ └──────┘ +/// +/// It's also how the following products implement undo/redo +/// - Google Sheet +/// - Google Slides +/// - Figma +/// - Microsoft Powerpoint +/// - Excel +#[test] +fn one_register_collaborative_undo() -> Result<(), LoroError> { + let doc_a = LoroDoc::new(); + doc_a.set_peer_id(1)?; + let doc_b = LoroDoc::new(); + doc_b.set_peer_id(2)?; + doc_a.get_map("map").insert("color", "black")?; + sync(&doc_a, &doc_b); + let mut undo = UndoManager::new(&doc_a); + doc_a.get_map("map").insert("color", "red")?; + undo.record_new_checkpoint(&doc_a)?; + sync(&doc_a, &doc_b); + doc_b.get_map("map").insert("color", "green")?; + sync(&doc_a, &doc_b); + undo.record_new_checkpoint(&doc_a)?; + undo.undo(&doc_a)?; + assert_eq!( + doc_a.get_deep_value().to_json_value(), + json!({"map": {"color": "black"}}) + ); + undo.redo(&doc_a)?; + assert_eq!( + doc_a.get_deep_value().to_json_value(), + json!({"map": {"color": "green"}}) + ); + Ok(()) +} + +fn sync(a: &LoroDoc, b: &LoroDoc) { + a.import(&b.export_from(&a.oplog_vv())).unwrap(); + b.import(&a.export_from(&b.oplog_vv())).unwrap(); +} + +#[test] +fn undo_id_span_that_contains_remote_deps_inside() -> Result<(), LoroError> { + let doc_a = LoroDoc::new(); + doc_a.set_peer_id(1)?; + let doc_b = LoroDoc::new(); + doc_b.set_peer_id(2)?; + + // ┌──────────────┐ + // │ │ + // Op from B ┌───────────────┤ Delete "A" │◄─────────────────────┐ + // │ │ Insert "B" │ │ + // │ │ │ │ + // │ └──────────────┘ │ + // │ │ + // ▼ │ + // ┌────────────────┐ ┌──────────────────┐ ┌───────┴──────────┐ + // │ │ │ │ │ │ + // Ops from A │ Insert "A" │◄──────┤ Insert " rules" │◄─────────┤ Insert "." │ + // │ │ │ │ │ │ + // └────────────────┘ └──────────────────┘ └──────────────────┘ + + doc_a.get_text("text").insert(0, "A")?; + sync(&doc_a, &doc_b); + doc_b.get_text("text").insert(0, "B")?; + doc_b.get_text("text").delete(1, 1)?; + doc_a.get_text("text").insert(1, " rules")?; + sync(&doc_a, &doc_b); + doc_a.get_text("text").insert(7, ".")?; + // ┌──────────────┐ + // │ │ + // Op from B ┌───────────────┤ Delete "A" │◄─────────────────────┐ + // should not be │ │ Insert "B" │ │ + // undone │ │ │ │ + // │ ├──────────────┤ │ + // ┌────────────┴───────────────┴──────────────┴──────────────────────┼──────────────┐ + // │ │ │ │ + // │ ┌────────────────┐ ┌──────────────────┐ ┌───────┴──────────┐ │ + // │ │ │ │ │ │ │ │ + // Undo │ │ Insert "A" │◄──────┤ Insert " rules" │◄─────────┤ Insert "." │ │ + // These │ │ │ │ │ │ │ │ + // │ └────────────────┘ └──────────────────┘ └──────────────────┘ │ + // │ │ + // └─────────────────────────────────────────────────────────────────────────────────┘ + assert_eq!( + doc_a.get_deep_value().to_json_value(), + json!({ + "text": "B rules." + }) + ); + doc_a.undo(IdSpan::new(1, 0, 8))?; + assert_eq!( + doc_a.get_deep_value().to_json_value(), + json!({ + "text": "B" + }) + ); + assert_eq!(doc_a.oplog_frontiers()[0].counter, 14); + Ok(()) +} + +#[test] +fn undo_id_span_that_contains_remote_deps_inside_many_times() -> Result<(), LoroError> { + let doc_a = LoroDoc::new(); + doc_a.set_peer_id(1)?; + let doc_b = LoroDoc::new(); + doc_b.set_peer_id(2)?; + + const TIMES: usize = 10; + // Replay 10 times + for _ in 0..TIMES { + doc_a.get_text("text").insert(0, "A")?; + sync(&doc_a, &doc_b); + doc_b.get_text("text").insert(0, "B")?; + doc_b.get_text("text").delete(1, 1)?; + doc_a.get_text("text").insert(1, " rules")?; + sync(&doc_a, &doc_b); + doc_a.get_text("text").insert(7, ".")?; + } + + // Undo all ops from A + doc_a.undo(IdSpan::new(1, 0, (TIMES * 8) as Counter))?; + assert_eq!( + doc_a.get_deep_value().to_json_value(), + json!({ + "text": "B".repeat(TIMES ) + }) + ); + Ok(()) +} + +#[test] +fn undo_manager() -> Result<(), LoroError> { + let doc = LoroDoc::new(); + doc.set_peer_id(1)?; + let mut undo = UndoManager::new(&doc); + doc.get_text("text").insert(0, "123")?; + undo.record_new_checkpoint(&doc)?; + doc.get_text("text").insert(3, "456")?; + undo.record_new_checkpoint(&doc)?; + doc.get_text("text").insert(6, "789")?; + undo.record_new_checkpoint(&doc)?; + for i in 0..10 { + info_span!("round", i).in_scope(|| { + assert_eq!(doc.get_text("text").to_string(), "123456789"); + undo.undo(&doc)?; + assert_eq!(doc.get_text("text").to_string(), "123456"); + undo.undo(&doc)?; + assert_eq!(doc.get_text("text").to_string(), "123"); + undo.undo(&doc)?; + assert_eq!(doc.get_text("text").to_string(), ""); + undo.redo(&doc)?; + assert_eq!(doc.get_text("text").to_string(), "123"); + undo.redo(&doc)?; + assert_eq!(doc.get_text("text").to_string(), "123456"); + undo.redo(&doc)?; + assert_eq!(doc.get_text("text").to_string(), "123456789"); + Ok::<(), loro::LoroError>(()) + })?; + } + + Ok(()) +} + +#[test] +fn undo_manager_with_sub_container() -> Result<(), LoroError> { + let doc = LoroDoc::new(); + doc.set_peer_id(1)?; + let mut undo = UndoManager::new(&doc); + let map = doc.get_list("list").insert_container(0, LoroMap::new())?; + undo.record_new_checkpoint(&doc)?; + let text = map.insert_container("text", LoroText::new())?; + undo.record_new_checkpoint(&doc)?; + text.insert(0, "123")?; + undo.record_new_checkpoint(&doc)?; + for i in 0..10 { + info_span!("round", ?i).in_scope(|| { + assert_eq!( + doc.get_deep_value().to_json_value(), + json!({ + "list": [{ + "text": "123" + }] + }) + ); + undo.undo(&doc)?; + assert_eq!( + doc.get_deep_value().to_json_value(), + json!({ + "list": [{ + "text": "" + }] + }) + ); + undo.undo(&doc)?; + assert_eq!( + doc.get_deep_value().to_json_value(), + json!({ + "list": [{}] + }) + ); + undo.undo(&doc)?; + assert_eq!( + doc.get_deep_value().to_json_value(), + json!({ + "list": [] + }) + ); + undo.redo(&doc)?; + assert_eq!( + doc.get_deep_value().to_json_value(), + json!({ + "list": [{}] + }) + ); + undo.redo(&doc)?; + assert_eq!( + doc.get_deep_value().to_json_value(), + json!({ + "list": [{ + "text": "" + }] + }) + ); + undo.redo(&doc)?; + assert_eq!( + doc.get_deep_value().to_json_value(), + json!({ + "list": [{ + "text": "123" + }] + }) + ); + + Ok::<(), loro::LoroError>(()) + })?; + } + + Ok::<(), loro::LoroError>(()) +} + +#[test] +fn test_undo_container_deletion() -> LoroResult<()> { + let doc = LoroDoc::new(); + doc.set_peer_id(1)?; + let mut undo = UndoManager::new(&doc); + + let map = doc.get_map("map"); + let text = map.insert_container("text", LoroText::new())?; + undo.record_new_checkpoint(&doc)?; + text.insert(0, "T")?; + undo.record_new_checkpoint(&doc)?; + assert_eq!( + doc.get_deep_value().to_json_value(), + json!({"map": {"text": "T"}}) + ); + map.delete("text")?; + assert_eq!(doc.get_deep_value().to_json_value(), json!({"map": {}})); + undo.record_new_checkpoint(&doc)?; + undo.undo(&doc)?; + assert_eq!( + doc.get_deep_value().to_json_value(), + json!({"map": {"text": "T"}}) + ); + undo.redo(&doc)?; + assert_eq!(doc.get_deep_value().to_json_value(), json!({"map": {}})); + undo.undo(&doc)?; + assert_eq!( + doc.get_deep_value().to_json_value(), + json!({"map": {"text": "T"}}) + ); + undo.redo(&doc)?; + assert_eq!(doc.get_deep_value().to_json_value(), json!({"map": {}})); + doc.commit(); + Ok(()) +} + +#[test] +fn test_richtext_checkout() -> LoroResult<()> { + let doc = LoroDoc::new(); + doc.set_peer_id(1)?; + let text = doc.get_text("text"); + text.insert(0, "Hello")?; // op 0-5 + text.mark(0..5, "bold", true)?; // op 5-7 + text.unmark(0..5, "bold")?; // op 7-9 + text.delete(0, 5)?; + doc.commit(); + + doc.subscribe_root(Arc::new(|event| { + dbg!(&event); + let t = event.events[0].diff.as_text().unwrap(); + let i = t[0].as_insert().unwrap(); + let style = i.1.as_ref().unwrap().get("bold").unwrap(); + assert_eq!(style, &LoroValue::Bool(true)); + })); + doc.checkout(&ID::new(1, 6).into())?; + assert_eq!( + text.to_delta().to_json_value(), + json!([{"insert": "Hello", "attributes": {"bold": true}}]) + ); + Ok(()) +} + +#[test] +fn undo_richtext_editing() -> LoroResult<()> { + let doc = LoroDoc::new(); + doc.set_peer_id(1)?; + let mut undo = UndoManager::new(&doc); + let text = doc.get_text("text"); + text.insert(0, "Hello")?; + undo.record_new_checkpoint(&doc)?; + text.mark(0..5, "bold", true)?; + undo.record_new_checkpoint(&doc)?; + assert_eq!( + text.to_delta().to_json_value(), + json!([ + {"insert": "Hello", "attributes": {"bold": true}} + ]) + ); + for i in 0..10 { + debug_span!("round", i).in_scope(|| { + undo.undo(&doc)?; + assert_eq!( + text.to_delta().to_json_value(), + json!([ + {"insert": "Hello", } + ]) + ); + undo.undo(&doc)?; + assert_eq!(text.to_delta().to_json_value(), json!([])); + debug_span!("redo 1").in_scope(|| { + undo.redo(&doc).unwrap(); + }); + assert_eq!( + text.to_delta().to_json_value(), + json!([ + {"insert": "Hello", } + ]) + ); + debug_span!("redo 2").in_scope(|| { + undo.redo(&doc).unwrap(); + }); + assert_eq!( + text.to_delta().to_json_value(), + json!([ + {"insert": "Hello", "attributes": {"bold": true}} + ]) + ); + + Ok::<(), loro::LoroError>(()) + })?; + } + Ok(()) +} + +#[test] +fn undo_richtext_editing_collab() -> LoroResult<()> { + let doc_a = LoroDoc::new(); + doc_a.set_peer_id(1)?; + let mut undo = UndoManager::new(&doc_a); + let doc_b = LoroDoc::new(); + doc_b.set_peer_id(2)?; + doc_a.get_text("text").insert(0, "A fox jumped")?; + undo.record_new_checkpoint(&doc_a)?; + sync(&doc_a, &doc_b); + doc_b.get_text("text").mark(2..12, "italic", true)?; + sync(&doc_a, &doc_b); + doc_a.get_text("text").mark(0..5, "bold", true)?; + undo.record_new_checkpoint(&doc_a)?; + sync(&doc_a, &doc_b); + assert_eq!( + doc_a.get_text("text").to_delta().to_json_value(), + json!([ + {"insert": "A ", "attributes": {"bold": true}}, + {"insert": "fox", "attributes": {"bold": true, "italic": true}}, + {"insert": " jumped", "attributes": {"italic": true}} + ]) + ); + for _ in 0..10 { + undo.undo(&doc_a)?; + assert_eq!( + doc_a.get_text("text").to_delta().to_json_value(), + json!([ + {"insert": "A " }, + {"insert": "fox jumped", "attributes": {"italic": true}} + ]) + ); + // FIXME: right now redo/undo like this is wasteful + undo.redo(&doc_a)?; + assert_eq!( + doc_a.get_text("text").to_delta().to_json_value(), + json!([ + {"insert": "A ", "attributes": {"bold": true}}, + {"insert": "fox", "attributes": {"bold": true, "italic": true}}, + {"insert": " jumped", "attributes": {"italic": true}} + ]) + ); + } + + Ok(()) +} + +#[test] +fn undo_richtext_conflict_set_style() -> LoroResult<()> { + let doc_a = LoroDoc::new(); + doc_a.set_peer_id(1)?; + let mut config = StyleConfigMap::new(); + config.insert( + "color".into(), + StyleConfig { + expand: loro::ExpandType::After, + }, + ); + doc_a.config_text_style(config.clone()); + let mut undo = UndoManager::new(&doc_a); + let doc_b = LoroDoc::new(); + doc_b.config_text_style(config.clone()); + doc_b.set_peer_id(2)?; + + doc_a.get_text("text").insert(0, "A fox jumped")?; + undo.record_new_checkpoint(&doc_a)?; + sync(&doc_a, &doc_b); + doc_b.get_text("text").mark(2..12, "color", "red")?; + sync(&doc_a, &doc_b); + doc_a.get_text("text").mark(0..5, "color", "green")?; + undo.record_new_checkpoint(&doc_a)?; + sync(&doc_a, &doc_b); + assert_eq!( + doc_a.get_text("text").to_delta().to_json_value(), + json!([ + {"insert": "A fox", "attributes": {"color": "green"}}, + {"insert": " jumped", "attributes": {"color": "red"}} + ]) + ); + for _ in 0..10 { + undo.undo(&doc_a)?; + assert_eq!( + doc_a.get_text("text").to_delta().to_json_value(), + json!([ + {"insert": "A " }, + {"insert": "fox jumped", "attributes": {"color": "red"}} + ]) + ); + undo.undo(&doc_a)?; + assert_eq!(doc_a.get_text("text").to_delta().to_json_value(), json!([])); + undo.redo(&doc_a)?; + assert_eq!( + doc_a.get_text("text").to_delta().to_json_value(), + json!([ + {"insert": "A " }, + {"insert": "fox jumped", "attributes": {"color": "red"}} + ]) + ); + undo.redo(&doc_a)?; + assert_eq!( + doc_a.get_text("text").to_delta().to_json_value(), + json!([ + {"insert": "A fox", "attributes": {"color": "green"}}, + {"insert": " jumped", "attributes": {"color": "red"}} + ]) + ); + } + + Ok(()) +} + +#[test] +fn undo_text_collab_delete() -> LoroResult<()> { + let doc_a = LoroDoc::new(); + doc_a.set_peer_id(1)?; + let mut undo = UndoManager::new(&doc_a); + let doc_b = LoroDoc::new(); + doc_b.set_peer_id(2)?; + doc_a.get_text("text").insert(0, "A ")?; + undo.record_new_checkpoint(&doc_a)?; + doc_a.get_text("text").insert(2, "fox ")?; + undo.record_new_checkpoint(&doc_a)?; + doc_a.get_text("text").insert(6, "jumped")?; + undo.record_new_checkpoint(&doc_a)?; + sync(&doc_a, &doc_b); + + doc_b.get_text("text").delete(2, 4)?; + sync(&doc_a, &doc_b); + doc_a.get_text("text").insert(0, "123!")?; + undo.record_new_checkpoint(&doc_a)?; + for _ in 0..3 { + assert_eq!(doc_a.get_text("text").to_string(), "123!A jumped"); + undo.undo(&doc_a)?; + assert_eq!(doc_a.get_text("text").to_string(), "A jumped"); + undo.undo(&doc_a)?; + assert_eq!(doc_a.get_text("text").to_string(), "A "); + undo.undo(&doc_a)?; + assert_eq!(doc_a.get_text("text").to_string(), ""); + undo.redo(&doc_a)?; + assert_eq!(doc_a.get_text("text").to_string(), "A "); + undo.redo(&doc_a)?; + assert_eq!(doc_a.get_text("text").to_string(), "A jumped"); + undo.redo(&doc_a)?; + assert_eq!(doc_a.get_text("text").to_string(), "123!A jumped"); + } + Ok(()) +} + +/// +/// ┌────────────┐ ┌────────────┐ ┌────────────┐ +/// │ │ │ │ │ │ +/// Ops From B │ A_ │◀──┬────│ fox │ ◀─┬─│ _jumped. │ +/// │ │ │ │ │ │ │ │ +/// └────────────┘ │ └────────────┘ │ └────────────┘ +/// │ │ +/// │ │ +/// │ │ +/// ┌────────────┐ │ ┌────────────┐ │ ┌────────────┐ +/// │ │ │ │ Make │ │ │ │ +/// Ops From A │ Hello_ │◀──┴────│ "A" │◀──┴──│ World │ +/// │ │ │ bold │ │ │ +/// └────────────┘ └────────────┘ └────────────┘ +/// loop 3 { +/// A undo 3 times and redo 3 times +/// } +/// loop 3 { +/// B undo 3 times and redo 3 times +/// } +#[test] +fn collab_undo() -> anyhow::Result<()> { + let doc_a = LoroDoc::new(); + doc_a.set_peer_id(1)?; + let mut undo_a = UndoManager::new(&doc_a); + let doc_b = LoroDoc::new(); + doc_b.set_peer_id(2)?; + let mut undo_b = UndoManager::new(&doc_b); + + doc_a.get_text("text").insert(0, "Hello ")?; + doc_b.get_text("text").insert(0, "A ")?; + sync(&doc_a, &doc_b); + doc_b.get_text("text").insert(2 + 6, "fox")?; + doc_a.get_text("text").mark(6..7, "bold", true)?; + sync(&doc_a, &doc_b); // Hello A fox + doc_b.get_text("text").insert(2 + 6 + 3, " jumped.")?; + doc_a.get_text("text").insert(6, "World! ")?; + sync(&doc_a, &doc_b); // Hello World! A fox jumped. + + for j in 0..3 { + debug_span!("round A", j).in_scope(|| { + assert!(!undo_a.can_redo(), "{:#?}", &undo_a); + assert_eq!( + doc_a.get_text("text").to_delta().to_json_value(), + json!([ + {"insert": "Hello World! "}, + {"insert": "A", "attributes": {"bold": true}}, + {"insert": " fox jumped."} + ]) + ); + undo_a.undo(&doc_a)?; + assert!(undo_a.can_redo()); + assert_eq!( + doc_a.get_text("text").to_delta().to_json_value(), + json!([ + {"insert": "Hello "}, + {"insert": "A", "attributes": {"bold": true}}, + {"insert": " fox jumped."} + ]) + ); + undo_a.undo(&doc_a)?; + assert_eq!( + doc_a.get_text("text").to_delta().to_json_value(), + json!([ + {"insert": "Hello A fox jumped."}, + ]) + ); + undo_a.undo(&doc_a)?; + assert_eq!( + doc_a.get_text("text").to_delta().to_json_value(), + json!([ + {"insert": "A fox jumped."}, + ]) + ); + + assert!(!undo_a.can_undo()); + undo_a.redo(&doc_a)?; + assert_eq!( + doc_a.get_text("text").to_delta().to_json_value(), + json!([ + {"insert": "Hello A fox jumped."}, + ]) + ); + + undo_a.redo(&doc_a)?; + assert_eq!( + doc_a.get_text("text").to_delta().to_json_value(), + json!([ + {"insert": "Hello "}, + {"insert": "A", "attributes": {"bold": true}}, + {"insert": " fox jumped."} + ]) + ); + undo_a.redo(&doc_a)?; + Ok::<(), LoroError>(()) + })?; + } + + sync(&doc_a, &doc_b); + for _ in 0..3 { + assert!(!undo_b.can_redo()); + assert_eq!( + doc_b.get_text("text").to_delta().to_json_value(), + json!([ + {"insert": "Hello World! "}, + {"insert": "A", "attributes": {"bold": true}}, + {"insert": " fox jumped."} + ]) + ); + undo_b.undo(&doc_b)?; + assert!(undo_b.can_redo()); + assert_eq!( + doc_b.get_text("text").to_delta().to_json_value(), + json!([ + {"insert": "Hello World! "}, + {"insert": "A", "attributes": {"bold": true}}, + {"insert": " fox"} + ]) + ); + + undo_b.undo(&doc_b)?; + assert_eq!( + doc_b.get_text("text").to_delta().to_json_value(), + json!([ + {"insert": "Hello World! "}, + {"insert": "A", "attributes": {"bold": true}}, + {"insert": " "}, + ]) + ); + undo_b.undo(&doc_b)?; + assert_eq!( + doc_b.get_text("text").to_delta().to_json_value(), + json!([ + {"insert": "Hello World! "}, + ]) + ); + assert!(!undo_b.can_undo()); + assert!(undo_b.can_redo()); + undo_b.redo(&doc_b)?; + assert_eq!( + doc_b.get_text("text").to_delta().to_json_value(), + json!([ + {"insert": "Hello World! "}, + {"insert": "A", "attributes": {"bold": true}}, + {"insert": " "}, + ]) + ); + undo_b.redo(&doc_b)?; + assert_eq!( + doc_b.get_text("text").to_delta().to_json_value(), + json!([ + {"insert": "Hello World! "}, + {"insert": "A", "attributes": {"bold": true}}, + {"insert": " fox"} + ]) + ); + undo_b.redo(&doc_b)?; + } + + Ok(()) +} + +/// Undo/Redo this column +/// +/// ┌───────┐ +/// │ Map │ +/// └───────┘ +/// ▲ +/// │ +/// ┌───────┐ +/// │ List │ 1 +/// └───────┘ +/// ▲ +/// │ "Hello World!" +/// ┌───────┐ ┌────────┐ +/// │ Text │◀─2──────────│ Remote │ "Fox" +/// └───────┘ │ Change │ +/// ▲ └────────┘ +/// │ ▲ +/// ┌───────┐ │ +/// │ Text │ " World!" │ +/// │ Edit │ 3 │ +/// └───────┘ │ +/// ▲ │ +/// │ │ +/// │ Mark bold │ +/// ┌───────┐ "Fox World!" │ +/// │ Text │ 4 │ +/// │ Edit │──────────────────┘ +/// └───────┘ +/// +#[test] +fn undo_sub_sub_container() -> anyhow::Result<()> { + let doc_a = LoroDoc::new(); + doc_a.set_peer_id(1)?; + let mut undo_a = UndoManager::new(&doc_a); + let doc_b = LoroDoc::new(); + doc_b.set_peer_id(2)?; + let map_a = doc_a.get_map("map"); + let list_a = map_a.insert_container("list", LoroList::new())?; + doc_a.commit(); + let text_a = list_a.insert_container(0, LoroText::new())?; + doc_a.commit(); + text_a.insert(0, "Hello World!")?; + sync(&doc_a, &doc_b); + + let text_b = doc_b.get_text(text_a.id()); + text_a.delete(0, 5)?; + text_b.insert(0, "F")?; + text_b.insert(2, "o")?; + text_b.insert(4, "x")?; + assert_eq!( + text_b.to_delta().to_json_value(), + json!([ + {"insert": "FHoexllo World!"}, + ]) + ); + sync(&doc_a, &doc_b); + text_a.mark(0..3, "bold", true)?; + assert_eq!( + text_a.to_delta().to_json_value(), + json!([ + {"insert": "Fox", "attributes": { "bold": true }}, + {"insert": " World!"} + ]) + ); + + undo_a.undo(&doc_a)?; // 4 -> 3 + assert_eq!( + text_a.to_delta().to_json_value(), + json!([ + {"insert": "Fox World!"}, + ]) + ); + undo_a.undo(&doc_a)?; // 3 -> 2 + // It should be "FHoexllo World!" here ideally + // But it's too expensive to calculate and make the code too complicated + // So we skip the test + undo_a.undo(&doc_a)?; // 2 -> 1.5 + assert_eq!( + text_a.to_delta().to_json_value(), + json!([ + {"insert": "Fox"}, + ]) + ); + undo_a.undo(&doc_a)?; // 1.5 -> 1 + assert_eq!( + doc_a.get_deep_value().to_json_value(), + json!({ + "map": {"list": []} + }) + ); + + undo_a.undo(&doc_a)?; // 1 -> 0 + assert_eq!( + doc_a.get_deep_value().to_json_value(), + json!({ + "map": {} + }) + ); + + undo_a.redo(&doc_a)?; // 0 -> 1 + assert_eq!( + doc_a.get_deep_value().to_json_value(), + json!({ + "map": {"list": []} + }) + ); + undo_a.redo(&doc_a)?; // 1 -> 1.5 + assert_eq!( + text_a.to_delta().to_json_value(), + json!([ + {"insert": "Fox"}, + ]) + ); + undo_a.redo(&doc_a)?; // 1.5 -> 2 + + undo_a.redo(&doc_a)?; // 2 -> 3 + + assert_eq!( + doc_a.get_deep_value().to_json_value(), + json!({ + "map": { + "list": [ + "Fox World!" + ] + } + }) + ); + // there is a new text container, so we need to get it again + let text_a = doc_a + .get_by_str_path("map/list/0") + .unwrap() + .into_container() + .unwrap() + .into_text() + .unwrap(); + undo_a.redo(&doc_a)?; // 3 -> 4 + assert_eq!( + text_a.to_delta().to_json_value(), + json!([ + {"insert": "Fox", "attributes": { "bold": true }}, + {"insert": " World!"} + ]) + ); + + Ok(()) +} + +/// ┌────┐ +/// │"B" │ +/// └────┘ +/// ▲ +/// ┌─┴───────┐ +/// │bold "B" │ +/// └─────────┘ +/// ▲ Concurrent +/// │ Delete +/// ┌─┴────────┐ ┌──────┐ +/// │"Hello B" │◀───────│ "B" │ +/// └──────────┘ └──────┘ +/// ▲ ▲ +/// │ │ +/// ┌─┴──┐ │ +/// │Undo│──────────────────┘ +/// └────┘ +/// ▲ +/// │ +/// ┌─┴──┐ +/// │Undo│ +/// └────┘ +#[test] +fn test_remote_merge_transform() -> LoroResult<()> { + let doc_a = LoroDoc::new(); + doc_a.set_peer_id(1)?; + let mut undo_a = UndoManager::new(&doc_a); + let doc_b = LoroDoc::new(); + doc_b.set_peer_id(2)?; + + // Initial insert "B" in doc_a + let text_a = doc_a.get_text("text"); + text_a.insert(0, "B")?; + doc_a.commit(); + + // Mark "B" as bold in doc_a + text_a.mark(0..1, "bold", true)?; + doc_a.commit(); + + text_a.insert(0, "Hello ")?; + doc_a.commit(); + + // Sync doc_a to doc_b + sync(&doc_a, &doc_b); + + // Concurrently delete "B" in doc_b + let text_b = doc_b.get_text("text"); + text_b.delete(0, 6)?; + sync(&doc_a, &doc_b); + + // Check the state after concurrent operations + assert_eq!( + text_a.to_delta().to_json_value(), + json!([ + {"insert": "B", "attributes": {"bold": true}} + ]) + ); + + undo_a.undo(&doc_a)?; + assert_eq!( + text_a.to_delta().to_json_value(), + json!([ + {"insert": "B"} + ]) + ); + + undo_a.undo(&doc_a)?; + assert_eq!(text_a.to_delta().to_json_value(), json!([])); + + Ok(()) +} + +#[test] +fn undo_tree_move() -> LoroResult<()> { + let doc_a = LoroDoc::new(); + doc_a.set_peer_id(1)?; + let doc_b = LoroDoc::new(); + doc_b.set_peer_id(2)?; + let mut undo = UndoManager::new(&doc_a); + let mut undo2 = UndoManager::new(&doc_b); + let tree_a = doc_a.get_tree("tree"); + let tree_b = doc_b.get_tree("tree"); + let root = tree_a.create(None)?; + let root2 = tree_b.create(None)?; + doc_a.import(&doc_b.export_from(&Default::default()))?; + doc_b.import(&doc_a.export_from(&Default::default()))?; + tree_a.mov(root, root2)?; + tree_b.mov(root2, root)?; + doc_a.import(&doc_b.export_from(&Default::default()))?; + doc_b.import(&doc_a.export_from(&Default::default()))?; + let latest_value = tree_a.get_value(); + // a + undo.undo(&doc_a)?; + let a_value = tree_a.get_value().as_list().unwrap().clone(); + assert_eq!(a_value.len(), 2); + assert!(a_value[0] + .as_map() + .unwrap() + .get("parent") + .unwrap() + .is_null()); + assert!(a_value[1] + .as_map() + .unwrap() + .get("parent") + .unwrap() + .is_null()); + + undo.redo(&doc_a)?; + assert_eq!(tree_a.get_value(), latest_value); + // b + undo2.undo(&doc_b)?; + let b_value = tree_b.get_value().as_list().unwrap().clone(); + assert_eq!(b_value.len(), 0); + undo.undo(&doc_a)?; + undo.undo(&doc_a)?; + let a_value = tree_a.get_value().as_list().unwrap().clone(); + assert_eq!(a_value.len(), 1); + assert_eq!( + a_value[0] + .as_map() + .unwrap() + .get("id") + .unwrap() + .to_json_value(), + json!("0@2") + ); + assert!(a_value[0] + .as_map() + .unwrap() + .get("parent") + .unwrap() + .is_null()); + Ok(()) +} + +#[test] +fn undo_tree_concurrent_delete() -> LoroResult<()> { + let doc_a = LoroDoc::new(); + let tree_a = doc_a.get_tree("tree"); + let root = tree_a.create(None)?; + let child = tree_a.create(Some(root))?; + let doc_b = LoroDoc::new(); + let mut undo_b = UndoManager::new(&doc_b); + let tree_b = doc_b.get_tree("tree"); + doc_b.import(&doc_a.export_from(&Default::default()))?; + tree_a.delete(root)?; + tree_b.delete(child)?; + doc_a.import(&doc_b.export_from(&Default::default()))?; + doc_b.import(&doc_a.export_from(&Default::default()))?; + undo_b.undo(&doc_b)?; + assert!(tree_b.get_value().as_list().unwrap().is_empty()); + Ok(()) +} + +#[test] +fn undo_tree_concurrent_delete2() -> LoroResult<()> { + let doc_a = LoroDoc::new(); + doc_a.set_peer_id(1)?; + let tree_a = doc_a.get_tree("tree"); + let root = tree_a.create(None)?; + let child = tree_a.create(None)?; + let doc_b = LoroDoc::new(); + doc_b.set_peer_id(2)?; + let mut undo_b = UndoManager::new(&doc_b); + let tree_b = doc_b.get_tree("tree"); + doc_b.import(&doc_a.export_from(&Default::default()))?; + tree_a.mov(child, root)?; + tree_a.delete(root)?; + tree_b.delete(child)?; + doc_a.import(&doc_b.export_from(&Default::default()))?; + doc_b.import(&doc_a.export_from(&Default::default()))?; + undo_b.undo(&doc_b)?; + assert_eq!(tree_b.get_value().as_list().unwrap().len(), 1); + assert_eq!( + tree_b.get_value().as_list().unwrap()[0] + .as_map() + .unwrap() + .get("id") + .unwrap() + .to_json_value(), + json!("1@1") + ); + Ok(()) +} + +/// ┌ ─ ─ ─ ─ ┌ ─ ─ ─ ─ +/// Peer 1 │ Peer 2 │ +/// └ ─ ─ ─ ─ └ ─ ─ ─ ─ +/// ┌────────┐ +/// │ Hello │ +/// └────▲───┘ +/// │ ┌────────┐ +/// ┌────┴───┐ │ Delete │ +/// │ World │◀──────────│ Hello │ +/// └────▲───┘ └────▲───┘ +/// │ │ +/// ┌────┴───┐ ┌────┴───┐ +/// │Insert 0│ │Insert │ +/// │Alice ◀────┐┌────▶│Hi │ +/// └────▲───┘ ││ └────▲───┘ +/// │ ││ │ +/// ┌ ─ ─ ─ ─ ─ ─ ─ ─ ┌────┴───┐ ││ ┌────┴───┐ +/// Hi World │ │ Undo │────┼┘ │Delete │ +/// └ ─ ─ ─ ─ ─ ─ ─ ─ └────▲───┘ └──────│Alice │ +/// │ ┌─────▶└────▲───┘ +/// ┌ ─ ─ ─ ─ ─ ─ ─ ─ ┌────┴───┐ │ │ +/// Hi │ │ Undo ├────┘ ┌────┴───┐ +/// └ ─ ─ ─ ─ ─ ─ ─ ─ └────▲───┘ │Insert │ +/// │ ┌─────▶│Bob │ +/// ┌ ─ ─ ─ ─ ─ ─ ─ ─ ┌────┴───┐ │ └────────┘ +/// Bob Hi │ │ Undo ├────┘ +/// └ ─ ─ ─ ─ ─ ─ ─ ─ └────▲───┘ +/// │ +/// ┌ ─ ─ ─ ─ ─ ─ ─ ─ ┌────┴───┐ +/// Bob Hi_ │ │ Redo │ +/// └ ─ ─ ─ ─ ─ ─ ─ ─ └────▲───┘ +/// │ +/// ┌ ─ ─ ─ ─ ─ ─ ─ ─ ┌────┴───┐ +/// Bob Hi World │ │ Redo │ +/// └ ─ ─ ─ ─ ─ ─ ─ ─ └────▲───┘ +/// │ +/// - ─ ─ ─ ─ ─ ─ ─ ─ ┌────┴───┐ +/// Bob AliceHi World │ Redo │ It will reinsert "Alice" even if they were deleted by peer 2 +/// - ─ ─ ─ ─ ─ ─ ─ ─ └────▲───┘ +/// │ +/// ┌ ─ ─ ─ ─ +/// Cannot │ +/// │ Redo +/// ─ ─ ─ ─ ┘ +#[test] +fn undo_redo_when_collab() -> anyhow::Result<()> { + let doc_a = LoroDoc::new(); + doc_a.set_peer_id(1).unwrap(); + let mut undo_a = UndoManager::new(&doc_a); + let doc_b = LoroDoc::new(); + doc_b.set_peer_id(2).unwrap(); + + let text_a = doc_a.get_text("text"); + text_a.insert(0, "Hello ")?; + doc_a.commit(); + text_a.insert(6, "World")?; + doc_a.commit(); + + sync(&doc_a, &doc_b); + + let text_b = doc_b.get_text("text"); + text_b.delete(0, 5)?; + doc_b.commit(); + text_b.insert(0, "Hi")?; + doc_b.commit(); + + text_a.insert(0, "Alice")?; + sync(&doc_a, &doc_b); + text_b.delete(0, 5)?; + undo_a.undo(&doc_a)?; + assert_eq!( + doc_a.get_deep_value().to_json_value(), + json!({ + "text": "Hi World" + }) + ); + doc_a.import(&doc_b.export_from(&Default::default()))?; + undo_a.undo(&doc_a)?; + assert_eq!( + doc_a.get_deep_value().to_json_value(), + json!({ + "text": "Hi " + }) + ); + text_b.insert(0, "Bob ")?; + doc_a.import(&doc_b.export_from(&Default::default()))?; + undo_a.undo(&doc_a)?; + assert_eq!( + doc_a.get_deep_value().to_json_value(), + json!({ + "text": "Bob Hi" + }) + ); + + assert!(undo_a.can_redo()); + undo_a.redo(&doc_a)?; + assert_eq!( + doc_a.get_deep_value().to_json_value(), + json!({ + "text": "Bob Hi " + }) + ); + undo_a.redo(&doc_a)?; + assert_eq!( + doc_a.get_deep_value().to_json_value(), + json!({ + "text": "Bob Hi World" + }) + ); + undo_a.redo(&doc_a)?; + assert_eq!( + doc_a.get_deep_value().to_json_value(), + json!({ + "text": "AliceBob Hi World" + }) + ); + + assert!(!undo_a.can_redo()); + + Ok(()) +} + +#[test] +fn undo_list_move() -> anyhow::Result<()> { + let doc = LoroDoc::new(); + let list = doc.get_movable_list("list"); + let mut undo = UndoManager::new(&doc); + list.insert(0, "0")?; + doc.commit(); + list.insert(1, "1")?; + doc.commit(); + list.insert(2, "2")?; + doc.commit(); + + list.mov(0, 2)?; + doc.commit(); + list.mov(1, 0)?; + doc.commit(); + for _ in 0..3 { + assert!(!undo.can_redo()); + assert_eq!( + doc.get_deep_value().to_json_value(), + json!({ + "list": ["2", "1", "0"] + }) + ); + undo.undo(&doc)?; + assert!(undo.can_redo()); + assert_eq!( + doc.get_deep_value().to_json_value(), + json!({ + "list": ["1", "2", "0"] + }) + ); + undo.undo(&doc)?; + assert_eq!( + doc.get_deep_value().to_json_value(), + json!({ + "list": ["0", "1", "2"] + }) + ); + undo.undo(&doc)?; + assert_eq!( + doc.get_deep_value().to_json_value(), + json!({ + "list": ["0", "1"] + }) + ); + + undo.undo(&doc)?; + undo.undo(&doc)?; + assert!(!undo.can_undo()); + undo.redo(&doc)?; + assert!(undo.can_undo()); + undo.redo(&doc)?; + + undo.redo(&doc)?; + assert_eq!( + doc.get_deep_value().to_json_value(), + json!({ + "list": ["0", "1", "2"] + }) + ); + undo.redo(&doc)?; + assert_eq!( + doc.get_deep_value().to_json_value(), + json!({ + "list": ["1", "2", "0"] + }) + ); + undo.redo(&doc)?; + assert_eq!( + doc.get_deep_value().to_json_value(), + json!({ + "list": ["2", "1", "0"] + }) + ); + assert!(!undo.can_redo()); + } + Ok(()) +} + +#[ignore] +#[test] +fn undo_collab_list_move() -> LoroResult<()> { + let doc = LoroDoc::new(); + doc.set_peer_id(1)?; + let list = doc.get_movable_list("list"); + list.insert(0, "0")?; + list.insert(1, "1")?; + list.insert(2, "2")?; + doc.commit(); + let mut undo = UndoManager::new(&doc); + let doc_b = LoroDoc::new(); + doc_b.set_peer_id(2)?; + doc_b.import(&doc.export_snapshot())?; + list.mov(0, 2)?; + assert_eq!(list.get_value().to_json_value(), json!(["1", "2", "0"])); + doc.commit(); + doc_b.get_movable_list("list").mov(0, 1)?; + sync(&doc, &doc_b); + assert_eq!(list.get_value().to_json_value(), json!(["1", "0", "2"])); + undo.undo(&doc)?; + // FIXME: cannot infer move correctly for now + assert_eq!(list.get_value().to_json_value(), json!(["0", "1", "2"])); + Ok(()) +} + +#[ignore] +#[test] +fn tree_undo() -> Result<(), LoroError> { + let doc_a = LoroDoc::new(); + doc_a.set_peer_id(1)?; + let tree_a = doc_a.get_tree("tree"); + let root = tree_a.create(None)?; + let root2 = tree_a.create(None)?; + let doc_b = LoroDoc::new(); + let tree_b = doc_b.get_tree("tree"); + doc_b.import(&doc_a.export_from(&Default::default()))?; + tree_a.mov(root, root2)?; + tree_b.mov(root2, root)?; + doc_a.import(&doc_b.export_from(&Default::default()))?; + doc_b.import(&doc_a.export_from(&Default::default()))?; + + doc_a.undo(ID::new(1, 1).into())?; + + Ok(()) +} diff --git a/crates/loro/tests/loro_rust_test.rs b/crates/loro/tests/loro_rust_test.rs index 732aa5cb8..0aa22c567 100644 --- a/crates/loro/tests/loro_rust_test.rs +++ b/crates/loro/tests/loro_rust_test.rs @@ -8,6 +8,8 @@ use loro_internal::{handler::TextDelta, id::ID, vv, LoroResult}; use serde_json::json; use tracing::trace_span; +mod integration_test; + #[ctor::ctor] fn init() { dev_utils::setup_test_log(); diff --git a/loro-js/src/index.ts b/loro-js/src/index.ts index b5009e26a..e44186a97 100644 --- a/loro-js/src/index.ts +++ b/loro-js/src/index.ts @@ -82,9 +82,21 @@ export type MapDiff = { }; export type TreeDiffItem = - | { target: TreeID; action: "create"; parent: TreeID | undefined; index: number; position: string } + | { + target: TreeID; + action: "create"; + parent: TreeID | undefined; + index: number; + position: string; + } | { target: TreeID; action: "delete" } - | { target: TreeID; action: "move"; parent: TreeID | undefined; index: number; position: string }; + | { + target: TreeID; + action: "move"; + parent: TreeID | undefined; + index: number; + position: string; + }; export type TreeDiff = { type: "tree"; @@ -153,11 +165,15 @@ export function isContainer(value: any): value is Container { */ export function getType( value: T, -): T extends LoroText ? "Text" - : T extends LoroMap ? "Map" - : T extends LoroTree ? "Tree" - : T extends LoroList ? "List" - : "Json" { +): T extends LoroText + ? "Text" + : T extends LoroMap + ? "Map" + : T extends LoroTree + ? "Tree" + : T extends LoroList + ? "List" + : "Json" { if (isContainer(value)) { return value.kind() as unknown as any; } @@ -187,7 +203,7 @@ declare module "loro-wasm" { * const map = doc.getMap("map"); * ``` */ - getMap( + getMap( name: Key, ): T[Key] extends LoroMap ? T[Key] : LoroMap; /** @@ -204,7 +220,7 @@ declare module "loro-wasm" { * const list = doc.getList("list"); * ``` */ - getList( + getList( name: Key, ): T[Key] extends LoroList ? T[Key] : LoroList; /** @@ -221,7 +237,7 @@ declare module "loro-wasm" { * const list = doc.getList("list"); * ``` */ - getMovableList( + getMovableList( name: Key, ): T[Key] extends LoroMovableList ? T[Key] : LoroMovableList; /** @@ -238,7 +254,7 @@ declare module "loro-wasm" { * const tree = doc.getTree("tree"); * ``` */ - getTree( + getTree( name: Key, ): T[Key] extends LoroTree ? T[Key] : LoroTree; getText(key: string | ContainerID): LoroText; @@ -531,7 +547,7 @@ declare module "loro-wasm" { T extends Record = Record, > { new (): LoroTree; - createNode(parent?: TreeID, index?: number ): LoroTreeNode; + createNode(parent?: TreeID, index?: number): LoroTreeNode; move(target: TreeID, parent?: TreeID, index?: number): void; delete(target: TreeID): void; has(target: TreeID): boolean; @@ -552,9 +568,7 @@ declare module "loro-wasm" { children(): Array>; } - interface AwarenessWasm< - T extends Value = Value, - > { + interface AwarenessWasm { getState(peer: PeerID): T | undefined; getTimestamp(peer: PeerID): number | undefined; getAllStates(): Record; diff --git a/loro-js/tests/undo.test.ts b/loro-js/tests/undo.test.ts new file mode 100644 index 000000000..84ece37f1 --- /dev/null +++ b/loro-js/tests/undo.test.ts @@ -0,0 +1,81 @@ +import { Loro, UndoManager } from "../src"; +import { describe, expect, test } from "vitest"; + +describe("undo", () => { + test("basic text undo", () => { + const doc = new Loro(); + doc.setPeerId(1); + const undo = new UndoManager(doc, { maxUndoSteps: 100, mergeInterval: 0 }); + expect(undo.canRedo()).toBeFalsy(); + expect(undo.canUndo()).toBeFalsy(); + doc.getText("text").insert(0, "hello"); + doc.commit(); + doc.getText("text").insert(5, " world!"); + doc.commit(); + expect(undo.canRedo()).toBeFalsy(); + expect(undo.canUndo()).toBeTruthy(); + undo.undo(); + expect(undo.canRedo()).toBeTruthy(); + expect(undo.canUndo()).toBeTruthy(); + expect(doc.toJSON()).toStrictEqual({ + text: "hello", + }); + undo.undo(); + expect(undo.canRedo()).toBeTruthy(); + expect(undo.canUndo()).toBeFalsy(); + expect(doc.toJSON()).toStrictEqual({ + text: "", + }); + undo.redo(); + expect(undo.canRedo()).toBeTruthy(); + expect(undo.canUndo()).toBeTruthy(); + expect(doc.toJSON()).toStrictEqual({ + text: "hello", + }); + undo.redo(); + expect(undo.canRedo()).toBeFalsy(); + expect(undo.canUndo()).toBeTruthy(); + expect(doc.toJSON()).toStrictEqual({ + text: "hello world!", + }); + }); + + test("merge", async () => { + const doc = new Loro(); + const undo = new UndoManager(doc, { maxUndoSteps: 100, mergeInterval: 5 }); + for (let i = 0; i < 10; i++) { + doc.getText("text").insert(i, i.toString()); + doc.commit(); + } + await new Promise((resolve) => setTimeout(resolve, 10)); + for (let i = 0; i < 10; i++) { + doc.getText("text").insert(i, i.toString()); + doc.commit(); + } + expect(doc.toJSON()).toStrictEqual({ + text: "01234567890123456789", + }); + undo.undo(); + expect(doc.toJSON()).toStrictEqual({ + text: "0123456789", + }); + undo.undo(); + expect(doc.toJSON()).toStrictEqual({ + text: "", + }); + }); + + test("max undo steps", () => { + const doc = new Loro(); + const undo = new UndoManager(doc, { maxUndoSteps: 100, mergeInterval: 0 }); + for (let i = 0; i < 200; i++) { + doc.getText("text").insert(0, "0"); + doc.commit(); + } + expect(doc.getText("text").length).toBe(200); + while (undo.canUndo()) { + undo.undo(); + } + expect(doc.getText("text").length).toBe(100); + }); +});