Improve public API & add docs #4
68 changed files with 6840 additions and 787 deletions
5
.github/dependabot.yml
vendored
5
.github/dependabot.yml
vendored
|
|
@ -14,3 +14,8 @@ updates:
|
|||
directories: ["**"]
|
||||
schedule:
|
||||
interval: "daily"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directories: ["/reconcile-js"]
|
||||
schedule:
|
||||
interval: "daily"
|
||||
|
|
|
|||
2
.github/workflows/check.yml
vendored
2
.github/workflows/check.yml
vendored
|
|
@ -40,4 +40,6 @@ jobs:
|
|||
- name: Test
|
||||
run: |
|
||||
cargo test --verbose -- --include-ignored
|
||||
cargo test --features serde
|
||||
cargo test --features wasm
|
||||
wasm-pack test --node --features wasm
|
||||
|
|
|
|||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -3,3 +3,9 @@
|
|||
|
||||
# Rust build folder
|
||||
target
|
||||
|
||||
# Node dependencies
|
||||
node_modules
|
||||
|
||||
# WebPack build output
|
||||
dist
|
||||
|
|
|
|||
15
.vscode/settings.json
vendored
15
.vscode/settings.json
vendored
|
|
@ -1,7 +1,12 @@
|
|||
{
|
||||
"files.exclude": {
|
||||
"**/dist": true,
|
||||
"**/node_modules": true,
|
||||
"**/snapshots": true,
|
||||
}
|
||||
}
|
||||
"**/snapshots": true, // cargo-insta outputs
|
||||
"**/node_modules": true, // node.js dependencies
|
||||
"**/dist": true, // webpack build directory
|
||||
"pkg": true, // wasm-pack build directory
|
||||
"target": true, // rust build directory
|
||||
},
|
||||
"rust-analyzer.cargo.features": [
|
||||
"all"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
230
Cargo.lock
generated
230
Cargo.lock
generated
|
|
@ -4,35 +4,41 @@ version = 4
|
|||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.16.0"
|
||||
version = "3.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c"
|
||||
checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.2"
|
||||
version = "1.2.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f34d93e62b03caf570cccc334cbc6c2fceca82f39211051345108adcba3eebdc"
|
||||
checksum = "d487aa071b5f64da6f19a3e848e3578944b726ee5a4854b82172f02aa876bfdc"
|
||||
dependencies = [
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.0"
|
||||
version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
|
||||
checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268"
|
||||
|
||||
[[package]]
|
||||
name = "console"
|
||||
version = "0.15.8"
|
||||
version = "0.15.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e1f83fc076bd6dd27517eacdf25fef6c4dfe5f1d7448bafaaf3a26f13b5e4eb"
|
||||
checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8"
|
||||
dependencies = [
|
||||
"encode_unicode",
|
||||
"lazy_static",
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"once_cell",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -41,7 +47,7 @@ version = "0.1.7"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cfg-if 1.0.1",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
|
|
@ -53,27 +59,27 @@ checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
|
|||
|
||||
[[package]]
|
||||
name = "encode_unicode"
|
||||
version = "0.3.6"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f"
|
||||
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.1"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.2"
|
||||
version = "0.15.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289"
|
||||
checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.7.0"
|
||||
version = "2.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "62f822373a4fe84d4bb149bf54e584a7f4abec90e072ed49cda0edea5b95471f"
|
||||
checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
|
|
@ -81,50 +87,36 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "insta"
|
||||
version = "1.42.2"
|
||||
version = "1.43.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "50259abbaa67d11d2bcafc7ba1d094ed7a0c70e3ce893f0d0997f73558cb3084"
|
||||
checksum = "154934ea70c58054b556dd430b99a98c2a7ff5309ac9891597e339b5c28f4371"
|
||||
dependencies = [
|
||||
"console",
|
||||
"linked-hash-map",
|
||||
"once_cell",
|
||||
"pin-project",
|
||||
"similar",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.14"
|
||||
version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674"
|
||||
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.76"
|
||||
version = "0.3.77"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6717b6b5b077764fb5966237269cb3c64edddde4b14ce42647430a78ced9e7b7"
|
||||
checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.171"
|
||||
version = "0.2.174"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6"
|
||||
|
||||
[[package]]
|
||||
name = "linked-hash-map"
|
||||
version = "0.5.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
|
||||
checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
|
|
@ -132,6 +124,12 @@ version = "0.4.27"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
|
||||
|
||||
[[package]]
|
||||
name = "memory_units"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8452105ba047068f40ff7093dd1d9da90898e63dd61736462e9cdda6a90ad3c3"
|
||||
|
||||
[[package]]
|
||||
name = "minicov"
|
||||
version = "0.3.7"
|
||||
|
|
@ -144,29 +142,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.20.2"
|
||||
version = "1.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project"
|
||||
version = "1.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a"
|
||||
dependencies = [
|
||||
"pin-project-internal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-internal"
|
||||
version = "1.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "pretty_assertions"
|
||||
|
|
@ -180,18 +158,18 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.92"
|
||||
version = "1.0.95"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0"
|
||||
checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.37"
|
||||
version = "1.0.40"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af"
|
||||
checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
|
@ -200,6 +178,7 @@ dependencies = [
|
|||
name = "reconcile"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"cfg-if 1.0.1",
|
||||
"console_error_panic_hook",
|
||||
"insta",
|
||||
"pretty_assertions",
|
||||
|
|
@ -208,13 +187,20 @@ dependencies = [
|
|||
"test-case",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-test",
|
||||
"wee_alloc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.18"
|
||||
name = "rustversion"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f"
|
||||
checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
|
|
@ -225,12 +211,6 @@ dependencies = [
|
|||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scoped-tls"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.219"
|
||||
|
|
@ -272,15 +252,15 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
|||
|
||||
[[package]]
|
||||
name = "similar"
|
||||
version = "2.6.0"
|
||||
version = "2.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1de1d4f81173b03af4c0cbed3c898f6bff5b870e4a7f5d6f4057d62a7a4b686e"
|
||||
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.90"
|
||||
version = "2.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "919d3b74a5dd0ccd15aeb8f93e7006bd9e14c295087c9896a110f490752bcf31"
|
||||
checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
|
@ -302,7 +282,7 @@ version = "3.3.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "adcb7fd841cd518e279be3d5a3eb0636409487998a4aff22f3de87b81e88384f"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cfg-if 1.0.1",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
|
|
@ -322,9 +302,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.14"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83"
|
||||
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
|
||||
|
||||
[[package]]
|
||||
name = "unsafe-libyaml"
|
||||
|
|
@ -344,20 +324,21 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.99"
|
||||
version = "0.2.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a474f6281d1d70c17ae7aa6a613c87fce69a127e2624002df63dcb39d6cf6396"
|
||||
checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cfg-if 1.0.1",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-backend"
|
||||
version = "0.2.99"
|
||||
version = "0.2.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f89bb38646b4f81674e8f5c3fb81b562be1fd936d84320f3264486418519c79"
|
||||
checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"log",
|
||||
|
|
@ -369,11 +350,11 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-futures"
|
||||
version = "0.4.49"
|
||||
version = "0.4.50"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38176d9b44ea84e9184eff0bc34cc167ed044f816accfe5922e54d84cf48eca2"
|
||||
checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cfg-if 1.0.1",
|
||||
"js-sys",
|
||||
"once_cell",
|
||||
"wasm-bindgen",
|
||||
|
|
@ -382,9 +363,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.99"
|
||||
version = "0.2.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2cc6181fd9a7492eef6fef1f33961e3695e4579b9872a6f7c83aee556666d4fe"
|
||||
checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
|
|
@ -392,9 +373,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.99"
|
||||
version = "0.2.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30d7a95b763d3c45903ed6c81f156801839e5ee968bb07e534c44df0fcd330c2"
|
||||
checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
|
@ -405,19 +386,21 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.99"
|
||||
version = "0.2.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "943aab3fdaaa029a6e0271b35ea10b72b943135afe9bffca82384098ad0e06a6"
|
||||
checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-test"
|
||||
version = "0.3.49"
|
||||
version = "0.3.50"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c61d44563646eb934577f2772656c7ad5e9c90fac78aa8013d776fcdaf24625d"
|
||||
checksum = "66c8d5e33ca3b6d9fa3b4676d774c5778031d27a578c2b007f905acf816152c3"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"minicov",
|
||||
"scoped-tls",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-bindgen-test-macro",
|
||||
|
|
@ -425,9 +408,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-test-macro"
|
||||
version = "0.3.49"
|
||||
version = "0.3.50"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "54171416ce73aa0b9c377b51cc3cb542becee1cd678204812e8392e5b0e4a031"
|
||||
checksum = "17d5042cc5fa009658f9a7333ef24291b1291a25b6382dd68862a7f3b969f69b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
|
@ -436,31 +419,56 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.76"
|
||||
version = "0.3.77"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04dd7223427d52553d3702c004d3b2fe07c148165faa56313cb00211e31c12bc"
|
||||
checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wee_alloc"
|
||||
version = "0.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbb3b5a6b2bb17cb6ad44a2e68a43e8d2722c997da10e928665c72ec6c0a0b8e"
|
||||
dependencies = [
|
||||
"cfg-if 0.1.10",
|
||||
"libc",
|
||||
"memory_units",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[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.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
|
|
|
|||
15
Cargo.toml
15
Cargo.toml
|
|
@ -21,10 +21,13 @@ wasm-bindgen = { version = "0.2.99", optional = true }
|
|||
# code size when deploying.
|
||||
console_error_panic_hook = { version = "0.1.7", optional = true }
|
||||
|
||||
wee_alloc = { version = "0.4.2", optional = true }
|
||||
cfg-if = "1.0.1"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
serde = [ "dep:serde" ]
|
||||
wasm = [ "dep:wasm-bindgen"]
|
||||
wasm = [ "dep:wasm-bindgen" ]
|
||||
console_error_panic_hook = [ "dep:console_error_panic_hook" ]
|
||||
|
||||
[dev-dependencies]
|
||||
|
|
@ -39,7 +42,10 @@ wasm-bindgen-test = "0.3.49"
|
|||
codegen-units = 1
|
||||
lto = true
|
||||
opt-level = 3
|
||||
strip="debuginfo" # Keep some info for better panics
|
||||
strip="symbols"
|
||||
|
||||
[package.metadata.wasm-pack.profile.release]
|
||||
wasm-opt = ['-O4', '--enable-bulk-memory']
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
|
@ -76,11 +82,6 @@ verbose_file_reads = "warn"
|
|||
|
||||
large_stack_arrays = { level = "allow", priority = 1 } # https://github.com/rust-lang/rust-clippy/issues/13774
|
||||
|
||||
# TODO: fix these
|
||||
cast_possible_truncation = { level = "allow", priority = 1 }
|
||||
cast_sign_loss = { level = "allow", priority = 1 }
|
||||
cast_possible_wrap = { level = "allow", priority = 1 }
|
||||
|
||||
# Silly lints
|
||||
implicit_return = { level = "allow", priority = 1 }
|
||||
question_mark_used = { level = "allow", priority = 1 }
|
||||
|
|
|
|||
170
README.md
170
README.md
|
|
@ -1,54 +1,150 @@
|
|||
# VaultLink self-hosted Obsidian plugin for file syncing
|
||||
# Reconcile: conflict-free 3-way text merging
|
||||
|
||||
[](https://github.com/schmelczer/reconcile/actions/workflows/check.yml)
|
||||
[](https://github.com/schmelczer/reconcile/actions/workflows/gh-pages.yml)
|
||||
|
||||
## Develop
|
||||
> [`diff3`](https://www.gnu.org/software/diffutils/manual/html_node/Invoking-diff3.html) (or `git merge`) but with automatic conflict resolution.
|
||||
|
||||
### Install [nvm](https://github.com/nvm-sh/nvm)
|
||||
Reconcile is a Rust and JavaScript (through WebAssembly) library for merging text without user intervention. It automatically resolves conflicts that would typically require user action in traditional 3-way merge tools.
|
||||
|
||||
- `curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash`
|
||||
Try out the [interactive demo](https://schmelczer.dev/reconcile)!
|
||||
|
||||
TODO: add links for crates and npm
|
||||
|
||||
## Features
|
||||
|
||||
- **Conflict-free output** - No more git conflict markers in the result
|
||||
- **Cursor/selection position tracking** - Automatically updates cursor positions during merging
|
||||
- **Pluggable tokenizer** - Choose between word-level, character-level, or custom tokenization
|
||||
- **Full UTF-8 support** - Handles Unicode text correctly
|
||||
- **WebAssembly support** - Use from JavaScript/TypeScript applications
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Rust
|
||||
|
||||
Add to your `Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
reconcile = "0.4"
|
||||
```
|
||||
|
||||
```rust
|
||||
use reconcile::{reconcile, BuiltinTokenizer};
|
||||
|
||||
let parent = "Hello world";
|
||||
let left = "Hello beautiful world";
|
||||
let right = "Hi world";
|
||||
|
||||
let result = reconcile(parent, &left.into(), &right.into(), &*BuiltinTokenizer::Word);
|
||||
assert_eq!(result.apply().text(), "Hi beautiful world");
|
||||
```
|
||||
|
||||
### JavaScript/TypeScript
|
||||
|
||||
```bash
|
||||
npm install reconcile
|
||||
```
|
||||
|
||||
```javascript
|
||||
import { init, reconcile } from "reconcile";
|
||||
|
||||
// Initialize the WASM module (required before first use)
|
||||
await init();
|
||||
|
||||
const parent = "Hello world";
|
||||
const left = "Hello beautiful world";
|
||||
const right = "Hi world";
|
||||
|
||||
const result = reconcile(parent, left, right);
|
||||
console.log(result.text); // "Hi beautiful world"
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### Tokenizers
|
||||
|
||||
Reconcile supports different tokenization strategies:
|
||||
|
||||
- **Word tokenizer** (`BuiltinTokenizer::Word`): Splits text into words (default, recommended for most use cases)
|
||||
- **Character tokenizer** (`BuiltinTokenizer::Character`): Splits text into individual characters (fine-grained merging)
|
||||
- **Custom tokenizer**: Implement your own tokenization logic
|
||||
|
||||
### Cursor Tracking
|
||||
|
||||
Reconcile can automatically update cursor and selection positions during merging:
|
||||
|
||||
```javascript
|
||||
const result = reconcile(
|
||||
"Hello world",
|
||||
{
|
||||
text: "Hello beautiful world",
|
||||
cursors: [{ id: 1, position: 6 }], // After "Hello "
|
||||
},
|
||||
{
|
||||
text: "Hi world",
|
||||
cursors: [{ id: 2, position: 0 }], // At beginning
|
||||
}
|
||||
);
|
||||
|
||||
// Result includes updated cursor positions
|
||||
console.log(result.cursors); // [{ id: 1, position: 3 }, { id: 2, position: 0 }]
|
||||
```
|
||||
|
||||
### History Tracking
|
||||
|
||||
Use `reconcileWithHistory` to get detailed information about the merge process:
|
||||
|
||||
```javascript
|
||||
const result = reconcileWithHistory(parent, left, right);
|
||||
console.log(result.history); // Array of spans with their origins
|
||||
```
|
||||
|
||||
## Algorithm
|
||||
|
||||
The algorithm starts similarly to `diff3`. Its inputs are a **parent** document and two conflicting versions: `left` and `right` which have been created from the parent through any series of concurrent edits.
|
||||
|
||||
1. **Diff calculation**: First, 2-way diffs of (parent & left) and (parent & right) are computed using Myers' algorithm
|
||||
2. **Tokenization**: The text is split into tokens (words, characters, etc.) for granular merging
|
||||
3. **Diff cleaning**: The tokens of the same diff are reordered and merged to end up to maximise patch sizes
|
||||
4. **Operation transformation (OT)**: The resulting edits are weaved together using operational transformation principles, ensuring no changes are lost
|
||||
|
||||
`EditedText` (at least in the Rust library) exposes an implementation of OT. The primary purpose of this library isn't to implement OT but to provide automated text merging, howver, OT happens to provide an easy way of merging the output of Myers' diff. The same result could be achieved through many CRDT implementations as well. However, the merging quality is only as good as the 2-way diffs are. For instance, `reconcile` doesn't support `move` semantics as these are decomposed into an `insert` and `delete` operation by Myers'.
|
||||
|
||||
## Motivation
|
||||
|
||||
Sometimes documents get edited concurrently by multiple users (or the same user from multiple devices) resulting in divergent changes.
|
||||
|
||||
To allow for offline editing, we could use CRDTs or Operational Transformation (OT) to come to a consistent resolution of the competing version. However, this requires capturing all user actions: insertions, deletes, move, copies, and pastes. In some applications, this is trivial if the document can only be edited through an editor that's in our control. But this isn't always the case. Users enjoy composable systems that don't lock them in. For example, one of the unique selling points of Obsidian is to provide an editor experience over a folder of Markdown files leaving the user free to change their technology of choice on a whim.
|
||||
|
||||
This means that files can be edited out-of-channel and the only information a text synchronization system can know is the current content of each tracked file. This is described as Differential Synchronization [1]. This is the same problem as what Git and similar version control systems solve but in a manual way. Although the problem is similar, there's a relevant difference between syncing source code and personal notes: in the case of the former, a semantically incorrect conflict resolution can wreak havoc in a code base, or worse, introduce a correctness bug unnoticed. Text notes are different though, humans are well-equipped to finding the signal in a noisy environment and "bad merges" might result in a clumsy sentence but the reader will likely still understand the gist and can fix it if necessary.
|
||||
|
||||
> There are domains of human text which are less tolerant of mis-merges: for instance, two conflicting changes to a contract could result in a term getting negated in different ways from both sides, resulting in a double-negation, thus unknowingly changing the meaning.
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
#### Install Node.js
|
||||
|
||||
- Install [nvm](https://github.com/nvm-sh/nvm): `curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash`
|
||||
- `nvm install 22`
|
||||
- `nvm use 22`
|
||||
- Optionally set the system-wide default: `nvm alias default 22`
|
||||
|
||||
### Set up Rust
|
||||
#### Set up Rust
|
||||
|
||||
- Install [`rustup`](https://rustup.rs): `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh`
|
||||
- Install [`wasm-pack`](https://rustwasm.github.io/wasm-pack/installer): `curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh`
|
||||
- `cargo install cargo-insta sqlx-cli cargo-edit`
|
||||
|
||||
### Install Obsidian on Linux
|
||||
|
||||
```sh
|
||||
apt install flatpak
|
||||
flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||
flatpak install flathub md.obsidian.Obsidian
|
||||
flatpak run md.obsidian.Obsidian
|
||||
```
|
||||
- `cargo install wasm-pack cargo-insta cargo-edit`
|
||||
|
||||
### Scripts
|
||||
|
||||
#### Update HTTP API TS bindings
|
||||
- **Running tests**: `scripts/test.sh`
|
||||
- **Formatting**: `scripts/lint.sh`
|
||||
- **Building website**: `scripts/dev-website.sh`
|
||||
- **Publishing new version**: `scripts/bump-version.sh patch`
|
||||
|
||||
```sh
|
||||
scripts/update-api-types.sh
|
||||
```
|
||||
TODO: license
|
||||
|
||||
#### Publish new version
|
||||
|
||||
```sh
|
||||
scripts/bump-version.sh patch
|
||||
```
|
||||
|
||||
#### Run E2E tests
|
||||
|
||||
```sh
|
||||
scripts/e2e.sh
|
||||
```
|
||||
|
||||
And to clean up the logs & database files, run `scripts/clean-up.sh`
|
||||
|
||||
## Projects
|
||||
|
||||
- [Sync server](./backend/sync_server/README.md)
|
||||
[1]: https://static.googleusercontent.com/media/research.google.com/en//pubs/archive/35605.pdf
|
||||
|
|
|
|||
3
examples/website/.gitignore
vendored
3
examples/website/.gitignore
vendored
|
|
@ -1,3 +0,0 @@
|
|||
reconcile.js
|
||||
reconcile_bg.wasm
|
||||
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import init, { mergeTextWithHistory } from "./reconcile.js";
|
||||
import { init, reconcileWithHistory } from "./dist/index.js";
|
||||
|
||||
const originalTextArea = document.getElementById("original");
|
||||
const leftTextArea = document.getElementById("left");
|
||||
|
|
@ -37,16 +37,15 @@ function updateMergedText() {
|
|||
const left = leftTextArea.value;
|
||||
const right = rightTextArea.value;
|
||||
|
||||
const results = mergeTextWithHistory(original, left, right);
|
||||
const results = reconcileWithHistory(original, left, right);
|
||||
|
||||
mergedTextArea.innerHTML = "";
|
||||
|
||||
for (const result of results) {
|
||||
for (const {text, history} of results.history) {
|
||||
const span = document.createElement("span");
|
||||
span.className = result.history();
|
||||
span.textContent = result.text();
|
||||
span.className = history;
|
||||
span.textContent = text;
|
||||
mergedTextArea.appendChild(span);
|
||||
result.free();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
3
reconcile-js/jest.config.js
Normal file
3
reconcile-js/jest.config.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module.exports = {
|
||||
preset: "ts-jest/presets/js-with-babel-esm"
|
||||
};
|
||||
4905
reconcile-js/package-lock.json
generated
Normal file
4905
reconcile-js/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
24
reconcile-js/package.json
Normal file
24
reconcile-js/package.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"name": "reconcile",
|
||||
"version": "0.4.0",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/types/index.d.ts",
|
||||
"files": [
|
||||
"dist/*"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "webpack --mode production",
|
||||
"test": "NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" jest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
"jest": "^29.7.0",
|
||||
"reconcile": "file:../pkg",
|
||||
"ts-jest": "^29.3.4",
|
||||
"ts-loader": "^9.5.2",
|
||||
"tslib": "2.8.1",
|
||||
"typescript": "5.8.3",
|
||||
"webpack": "^5.99.9",
|
||||
"webpack-cli": "^6.0.1"
|
||||
}
|
||||
}
|
||||
70
reconcile-js/src/index.test.ts
Normal file
70
reconcile-js/src/index.test.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { init, reconcile, reconcileWithHistory } from "./index";
|
||||
import * as fs from "fs";
|
||||
|
||||
describe("reconcile", () => {
|
||||
it("tries calling functions without init", () => {
|
||||
expect(() => reconcile("Hello", "Hello world", "Hi world")).toThrow(
|
||||
/call init()/
|
||||
);
|
||||
|
||||
expect(() =>
|
||||
reconcileWithHistory("Hello", "Hello world", "Hi world")
|
||||
).toThrow(/call init()/);
|
||||
});
|
||||
|
||||
it("call reconcile without cursors", async () => {
|
||||
await initWasm();
|
||||
|
||||
expect(reconcile("Hello", "Hello world", "Hi world").text).toEqual(
|
||||
"Hi world"
|
||||
);
|
||||
});
|
||||
|
||||
it("call reconcile with cursors", async () => {
|
||||
await initWasm();
|
||||
|
||||
const result = reconcile(
|
||||
"Hello",
|
||||
{
|
||||
text: "Hello world",
|
||||
cursors: [
|
||||
{
|
||||
id: 3,
|
||||
position: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Hi world",
|
||||
cursors: [
|
||||
{
|
||||
id: 4,
|
||||
position: 0,
|
||||
},
|
||||
{ id: 5, position: 3 },
|
||||
],
|
||||
}
|
||||
);
|
||||
|
||||
expect(result.text).toEqual("Hi world");
|
||||
expect(result.cursors).toEqual([
|
||||
{ id: 3, position: 0 },
|
||||
{ id: 4, position: 0 },
|
||||
{ id: 5, position: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("call reconcileWithHistory", async () => {
|
||||
await initWasm();
|
||||
|
||||
const result = reconcileWithHistory("Hello", "Hello world", "Hi world");
|
||||
|
||||
expect(result.text).toEqual("Hi world");
|
||||
expect(result.history.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
async function initWasm() {
|
||||
const wasmBin = fs.readFileSync("../pkg/reconcile_bg.wasm");
|
||||
await init({ module_or_path: wasmBin });
|
||||
}
|
||||
201
reconcile-js/src/index.ts
Normal file
201
reconcile-js/src/index.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import wasmInit, {
|
||||
CursorPosition as wasmCursorPosition,
|
||||
reconcile as wasmReconcile,
|
||||
TextWithCursors as wasmTextWithCursors,
|
||||
SpanWithHistory as wasmSpanWithHistory,
|
||||
BuiltinTokenizer,
|
||||
reconcileWithHistory as wasmReconcileWithHistory,
|
||||
History,
|
||||
InitInput,
|
||||
} from "reconcile";
|
||||
|
||||
export interface TextWithCursors {
|
||||
/** The document's entire content */
|
||||
text: string;
|
||||
/** List of cursor positions, can be null or undefined if there are no cursors */
|
||||
cursors: null | undefined | CursorPosition[];
|
||||
}
|
||||
|
||||
export interface CursorPosition {
|
||||
/** Unique identifier for the cursor */
|
||||
id: number;
|
||||
/** Character position in the text, 0-based */
|
||||
position: number;
|
||||
}
|
||||
|
||||
export interface TextWithCursorsAndHistory {
|
||||
/** The document's entire content */
|
||||
text: string;
|
||||
/** List of cursor positions, can be null or undefined if there are no cursors */
|
||||
cursors: null | undefined | CursorPosition[];
|
||||
/** List of operations leading to `text` from the 3 ancestors */
|
||||
history: SpanWithHistory[];
|
||||
}
|
||||
|
||||
export interface SpanWithHistory {
|
||||
/** Span of text associated with the historical opearion */
|
||||
text: string;
|
||||
/** Origin of the `text` span */
|
||||
history: History;
|
||||
}
|
||||
|
||||
export type Tokenizer = "Line" | "Word" | "Character";
|
||||
const TOKENIZERS = ["Line", "Word", "Character"];
|
||||
|
||||
let isInitialised = false;
|
||||
|
||||
const UNINITIALISED_MODULE_ERROR =
|
||||
"Reconcile module has not been initialized. Please call init() before using any other functions.";
|
||||
|
||||
const UNSUPPORTED_TOKENIZER_ERROR = `Unsupported tokenizer. Only ${TOKENIZERS.join(
|
||||
", "
|
||||
)} are supported.`;
|
||||
|
||||
/**
|
||||
* Initializes the WASM module for text reconciliation.
|
||||
* Must be called before using any other functions.
|
||||
*
|
||||
* The function is idempotent.
|
||||
*
|
||||
* @param content - Optional initialization input for the WASM module during testing.
|
||||
* @returns Promise that resolves when initialization is complete
|
||||
*/
|
||||
export async function init(content?: InitInput) {
|
||||
if (isInitialised) {
|
||||
return;
|
||||
}
|
||||
|
||||
await wasmInit(content);
|
||||
|
||||
isInitialised = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges three versions of text (original, left, right) and cursor positions.
|
||||
*
|
||||
* @param original - The original/base version of the text
|
||||
* @param left - The left version of the text, either as string or TextWithCursors
|
||||
* @param right - The right version of the text, either as string or TextWithCursors
|
||||
* @param tokenizer - The tokenization strategy to use (default: "Word")
|
||||
* @returns The reconciled text with merged cursor positions
|
||||
*/
|
||||
export function reconcile(
|
||||
original: string,
|
||||
left: string | TextWithCursors,
|
||||
right: string | TextWithCursors,
|
||||
tokenizer: BuiltinTokenizer = "Word"
|
||||
): TextWithCursors {
|
||||
if (!isInitialised) {
|
||||
throw new Error(UNINITIALISED_MODULE_ERROR);
|
||||
}
|
||||
|
||||
if (!TOKENIZERS.includes(tokenizer)) {
|
||||
throw new Error(UNSUPPORTED_TOKENIZER_ERROR);
|
||||
}
|
||||
|
||||
const leftCursor = toWasmTextWithCursors(left);
|
||||
const rightCursor = toWasmTextWithCursors(right);
|
||||
|
||||
const result = wasmReconcile(original, leftCursor, rightCursor, tokenizer);
|
||||
|
||||
leftCursor.free();
|
||||
rightCursor.free();
|
||||
|
||||
const jsResult = toTextWithCursors(result);
|
||||
result.free();
|
||||
|
||||
return jsResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges three versions of text and returns the result with historical information.
|
||||
*
|
||||
* Calculating the `history` is somewhat more expensive, otherwise this function behaves like `reconcile`.
|
||||
*
|
||||
* @param original - The original/base version of the text
|
||||
* @param left - The left version of the text, either as string or TextWithCursors
|
||||
* @param right - The right version of the text, either as string or TextWithCursors
|
||||
* @param tokenizer - The tokenization strategy to use (default: "Word")
|
||||
* @returns The reconciled text with cursor positions and history of changes
|
||||
*/
|
||||
export function reconcileWithHistory(
|
||||
original: string,
|
||||
left: string | TextWithCursors,
|
||||
right: string | TextWithCursors,
|
||||
tokenizer: BuiltinTokenizer = "Word"
|
||||
): TextWithCursorsAndHistory {
|
||||
if (!isInitialised) {
|
||||
throw new Error(UNINITIALISED_MODULE_ERROR);
|
||||
}
|
||||
|
||||
if (!TOKENIZERS.includes(tokenizer)) {
|
||||
throw new Error(UNSUPPORTED_TOKENIZER_ERROR);
|
||||
}
|
||||
|
||||
const leftCursor = toWasmTextWithCursors(left);
|
||||
const rightCursor = toWasmTextWithCursors(right);
|
||||
|
||||
const result = wasmReconcileWithHistory(
|
||||
original,
|
||||
leftCursor,
|
||||
rightCursor,
|
||||
tokenizer
|
||||
);
|
||||
|
||||
leftCursor.free();
|
||||
rightCursor.free();
|
||||
|
||||
const jsResult = toTextWithCursors(result);
|
||||
const history = result.history().map(toSpanWithHistory);
|
||||
result.free();
|
||||
|
||||
return {
|
||||
...jsResult,
|
||||
history,
|
||||
};
|
||||
}
|
||||
|
||||
function toWasmTextWithCursors(
|
||||
text: string | TextWithCursors
|
||||
): wasmTextWithCursors {
|
||||
const isInputString = typeof text == "string";
|
||||
const leftText = isInputString ? text : text.text;
|
||||
const leftCursors = isInputString ? [] : text.cursors ?? [];
|
||||
|
||||
return new wasmTextWithCursors(
|
||||
leftText,
|
||||
leftCursors.map(toWasmCursorPosition)
|
||||
);
|
||||
}
|
||||
|
||||
function toWasmCursorPosition({
|
||||
id,
|
||||
position,
|
||||
}: CursorPosition): wasmCursorPosition {
|
||||
return new wasmCursorPosition(id, position);
|
||||
}
|
||||
|
||||
function toTextWithCursors(
|
||||
textWithCursor: wasmTextWithCursors
|
||||
): TextWithCursors {
|
||||
return {
|
||||
text: textWithCursor.text(),
|
||||
cursors: textWithCursor.cursors().map(toCursorPosition),
|
||||
};
|
||||
}
|
||||
|
||||
function toCursorPosition(cursor: wasmCursorPosition): CursorPosition {
|
||||
return {
|
||||
id: cursor.id(),
|
||||
position: cursor.characterIndex(),
|
||||
};
|
||||
}
|
||||
|
||||
function toSpanWithHistory(
|
||||
textWithHistory: wasmSpanWithHistory
|
||||
): SpanWithHistory {
|
||||
return {
|
||||
text: textWithHistory.text(),
|
||||
history: textWithHistory.history(),
|
||||
};
|
||||
}
|
||||
19
reconcile-js/tsconfig.json
Normal file
19
reconcile-js/tsconfig.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"target": "ESNext",
|
||||
"strict": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"moduleResolution": "bundler",
|
||||
"declaration": true,
|
||||
"declarationDir": "./dist/types",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"skipLibCheck": true,
|
||||
"inlineSourceMap": true
|
||||
},
|
||||
"exclude": [
|
||||
"./dist"
|
||||
]
|
||||
}
|
||||
37
reconcile-js/webpack.config.js
Normal file
37
reconcile-js/webpack.config.js
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
const path = require("path");
|
||||
|
||||
|
||||
module.exports = {
|
||||
entry: "./src/index.ts",
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.ts$/,
|
||||
use: ["ts-loader"]
|
||||
},
|
||||
{
|
||||
test: /\.wasm$/,
|
||||
type: "asset/inline"
|
||||
}
|
||||
]
|
||||
},
|
||||
optimization: {
|
||||
// the consuming project should take care of minification
|
||||
minimize: false
|
||||
},
|
||||
resolve: {
|
||||
extensions: [".ts"],
|
||||
alias: {
|
||||
root: __dirname,
|
||||
src: path.resolve(__dirname, "src")
|
||||
}
|
||||
},
|
||||
output: {
|
||||
path: path.resolve(__dirname, "dist"),
|
||||
filename: "index.js",
|
||||
libraryTarget: "module"
|
||||
},
|
||||
experiments: {
|
||||
outputModule: true
|
||||
},
|
||||
};
|
||||
|
|
@ -2,13 +2,12 @@
|
|||
|
||||
set -e
|
||||
|
||||
rm -rf pkg
|
||||
wasm-pack build --target web --features wasm,wee_alloc
|
||||
cd reconcile-js
|
||||
npm run build
|
||||
mkdir -p ../examples/website/dist
|
||||
cp -R dist/index.js ../examples/website/dist/index.js
|
||||
|
||||
wasm-pack build --target web --features wasm
|
||||
|
||||
cp -R pkg/reconcile.js examples/website/
|
||||
cp -R pkg/reconcile_bg.wasm examples/website/
|
||||
|
||||
cd examples/website/
|
||||
cd ../examples/website
|
||||
|
||||
python3 -m http.server $1 --bind 0.0.0.0
|
||||
|
|
|
|||
8
scripts/lint.sh
Executable file
8
scripts/lint.sh
Executable file
|
|
@ -0,0 +1,8 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
cargo clippy --all-targets --all-features --fix --allow-dirty --allow-staged
|
||||
cargo fmt --all
|
||||
|
||||
echo "Success!"
|
||||
16
scripts/test.sh
Executable file
16
scripts/test.sh
Executable file
|
|
@ -0,0 +1,16 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
wasm-pack build --target web --features wasm,wee_alloc
|
||||
cargo test --verbose
|
||||
cargo test --features serde
|
||||
cargo test --features wasm,wee_alloc
|
||||
wasm-pack test --node --features wasm,wee_alloc
|
||||
|
||||
cd reconcile-js
|
||||
npm install
|
||||
npm run test
|
||||
cd -
|
||||
|
||||
echo "Success!"
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
pub mod myers;
|
||||
pub mod raw_operation;
|
||||
118
src/lib.rs
118
src/lib.rs
|
|
@ -1,16 +1,120 @@
|
|||
#![feature(stmt_expr_attributes)]
|
||||
//! # Reconcile
|
||||
//!
|
||||
//! [`diff3`](https://www.gnu.org/software/diffutils/manual/html_node/Invoking-diff3.html) (or `git merge`)
|
||||
//! but with automatic conflict resolution.
|
||||
//!
|
||||
//! Reconcile is a Rust and JavaScript (through WebAssembly) library for merging
|
||||
//! text without user intervention. It automatically resolves conflicts that
|
||||
//! would typically require user action in traditional 3-way merge tools.
|
||||
//!
|
||||
//! Try out the [interactive demo](https://schmelczer.dev/reconcile)!
|
||||
//!
|
||||
//! ```
|
||||
//! use reconcile::{reconcile, BuiltinTokenizer};
|
||||
//!
|
||||
//! let parent = "Merging text is hard!";
|
||||
//! let left = "Merging text is easy!";
|
||||
//! let right = "With reconcile, merging documents is hard!";
|
||||
//!
|
||||
//! let deconflicted = reconcile(parent, &left.into(), &right.into(), &*BuiltinTokenizer::Word);
|
||||
//! assert_eq!(deconflicted.apply().text(), "With reconcile, merging documents is easy!");
|
||||
//! ```
|
||||
//! > You can also try out an interactive demo at [schmelczer.dev/reconcile](https://schmelczer.dev/reconcile).
|
||||
//!
|
||||
//! ## Tokenizing
|
||||
//!
|
||||
//! Merging is done on the token level, the granularity of which is
|
||||
//! configurable. By default, words are the atoms for merging and thus words
|
||||
//! can't get jumbled up at the end of reconciling.
|
||||
//!
|
||||
//! ### Built-in tokenizers
|
||||
//!
|
||||
//! ```
|
||||
//! use reconcile::{reconcile, BuiltinTokenizer};
|
||||
//!
|
||||
//! let parent = "The quick brown fox\n";
|
||||
//! let left = "The very quick brown fox\n";
|
||||
//! let right = "The quick red fox\n";
|
||||
//!
|
||||
//! let result = reconcile(parent, &left.into(), &right.into(), &*BuiltinTokenizer::Line);
|
||||
//! assert_eq!(result.apply().text(), "The quick red foxThe very quick brown fox\n");
|
||||
//! ```
|
||||
//!
|
||||
//! ### Custom tokenization
|
||||
//!
|
||||
//! If something custom is needed, for instance, to better support structured
|
||||
//! text such as Markdown or HTML, a custom tokenizer can be implemented:
|
||||
//!
|
||||
//! ```
|
||||
//! use reconcile::{reconcile, Token, BuiltinTokenizer};
|
||||
//!
|
||||
//! // Example with custom tokenizer - split by sentences
|
||||
//! let sentence_tokenizer = |text: &str| {
|
||||
//! text.split(". ")
|
||||
//! .map(|sentence| Token::new(
|
||||
//! sentence.to_string(),
|
||||
//! sentence.to_string(),
|
||||
//! false, // don't allow joining token with the preceeding on
|
||||
//! false // don't allow joining token with the following one
|
||||
//! ))
|
||||
//! .collect::<Vec<_>>()
|
||||
//! };
|
||||
//!
|
||||
//! let parent = "Hello world. This is a test.";
|
||||
//! let left = "Hello beautiful world. This is a test.";
|
||||
//! let right = "Hello world. This is a great test.";
|
||||
//!
|
||||
//! // Using built-in tokenizer is usually sufficient
|
||||
//! let result = reconcile(parent, &left.into(), &right.into(), &*BuiltinTokenizer::Word);
|
||||
//! assert_eq!(result.apply().text(), "Hello beautiful world. This is a great test.");
|
||||
//! ```
|
||||
//! > By setting the joinability to `false`, longer runs of inserts with be
|
||||
//! > interleaved like LRLRLR instead of LLLRRR.
|
||||
//!
|
||||
//! ## Cursors and selection ranges
|
||||
//!
|
||||
//! The library supports updating cursor and selection ranges during the merging
|
||||
//! for interactive workflows:
|
||||
//!
|
||||
//! ```
|
||||
//! use reconcile::{reconcile, BuiltinTokenizer, TextWithCursors, CursorPosition};
|
||||
//!
|
||||
//! let parent = "Hello world";
|
||||
//! let left = TextWithCursors::new(
|
||||
//! "Hello beautiful world".to_string(),
|
||||
//! vec![CursorPosition { id: 1, char_index: 6 }] // After "Hello "
|
||||
//! );
|
||||
//! let right = TextWithCursors::new(
|
||||
//! "Hi world".to_string(),
|
||||
//! vec![CursorPosition { id: 2, char_index: 0 }] // At beginning
|
||||
//! );
|
||||
//!
|
||||
//! let result = reconcile(parent, &left, &right, &*BuiltinTokenizer::Word);
|
||||
//! let merged = result.apply();
|
||||
//!
|
||||
//! assert_eq!(merged.text(), "Hi beautiful world");
|
||||
//! // Cursors are automatically repositioned
|
||||
//! assert_eq!(merged.cursors().len(), 2);
|
||||
//! ```
|
||||
//!
|
||||
//! ## The algorithm
|
||||
//!
|
||||
//! For a discussion of the algorithm and architecture, see the
|
||||
//! [README](README.md#algorithm) page.
|
||||
|
||||
mod diffs;
|
||||
mod operation_transformation;
|
||||
mod raw_operation;
|
||||
mod tokenizer;
|
||||
mod types;
|
||||
mod utils;
|
||||
|
||||
pub use operation_transformation::{
|
||||
CursorPosition, EditedText, TextWithCursors, reconcile, reconcile_with_cursors,
|
||||
reconcile_with_history, reconcile_with_tokenizer,
|
||||
pub use operation_transformation::{EditedText, reconcile};
|
||||
pub use tokenizer::{BuiltinTokenizer, Tokenizer, token::Token};
|
||||
pub use types::{
|
||||
cursor_position::CursorPosition, history::History, side::Side,
|
||||
span_with_history::SpanWithHistory, text_with_cursors::TextWithCursors,
|
||||
};
|
||||
pub use tokenizer::{Tokenizer, token::Token, word_tokenizer::word_tokenizer};
|
||||
pub use utils::{history::History, side::Side};
|
||||
pub use utils::is_binary::is_binary;
|
||||
|
||||
#[cfg(feature = "wasm")]
|
||||
pub mod wasm;
|
||||
|
|
|
|||
|
|
@ -1,64 +1,59 @@
|
|||
mod cursor;
|
||||
mod edited_text;
|
||||
mod operation;
|
||||
mod utils;
|
||||
use std::fmt::Debug;
|
||||
|
||||
pub use cursor::{CursorPosition, TextWithCursors};
|
||||
pub use edited_text::EditedText;
|
||||
pub use operation::Operation;
|
||||
|
||||
use crate::{
|
||||
Tokenizer,
|
||||
utils::{history::History, side::Side},
|
||||
types::{side::Side, text_with_cursors::TextWithCursors},
|
||||
};
|
||||
|
||||
/// Given an `original` document and two concurrent edits to it,
|
||||
/// return a document containing all changes from both `left`
|
||||
/// and `right`.
|
||||
///
|
||||
/// If a span has been inserted in either the `left` or `right`
|
||||
/// versions, it will be present in the return value. If both sides
|
||||
/// insert the same span with a common prefix, that prefix will only
|
||||
/// be present once in the output.
|
||||
///
|
||||
/// When both sides delete the same span, it will be deleted in the
|
||||
/// return value. If one side deletes a span and the other side inserts
|
||||
/// into that span, the inserted text will be present in the return
|
||||
/// value.
|
||||
///
|
||||
/// The function supports UTF-8. The arguments are tokenized at the
|
||||
/// granularity of words.
|
||||
///
|
||||
/// ```
|
||||
/// use reconcile::{reconcile, BuiltinTokenizer};
|
||||
///
|
||||
/// let parent = "Merging text is hard!";
|
||||
/// let left = "Merging text is easy!";
|
||||
/// let right = "With reconcile, merging documents is hard!";
|
||||
///
|
||||
/// let deconflicted = reconcile(parent, &left.into(), &right.into(), &*BuiltinTokenizer::Word);
|
||||
/// assert_eq!(deconflicted.apply().text(), "With reconcile, merging documents is easy!");
|
||||
/// ```
|
||||
#[must_use]
|
||||
pub fn reconcile(original: &str, left: &str, right: &str) -> String {
|
||||
reconcile_with_cursors(original, left.into(), right.into())
|
||||
.text
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn reconcile_with_history(original: &str, left: &str, right: &str) -> Vec<(History, String)> {
|
||||
let left_operations = EditedText::from_strings(original, left.into(), Side::Left);
|
||||
let right_operations = EditedText::from_strings(original, right.into(), Side::Right);
|
||||
|
||||
left_operations.merge(right_operations).apply_with_history()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn reconcile_with_cursors<'a>(
|
||||
pub fn reconcile<'a, T>(
|
||||
original: &'a str,
|
||||
left: TextWithCursors<'a>,
|
||||
right: TextWithCursors<'a>,
|
||||
) -> TextWithCursors<'static> {
|
||||
let left_operations = EditedText::from_strings(original, left, Side::Left);
|
||||
let right_operations = EditedText::from_strings(original, right, Side::Right);
|
||||
|
||||
let merged_operations = left_operations.merge(right_operations);
|
||||
|
||||
TextWithCursors::new_owned(merged_operations.apply(), merged_operations.cursors)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn reconcile_with_tokenizer<'a, F, T>(
|
||||
original: &str,
|
||||
left: TextWithCursors<'a>,
|
||||
right: TextWithCursors<'a>,
|
||||
left: &TextWithCursors,
|
||||
right: &TextWithCursors,
|
||||
tokenizer: &Tokenizer<T>,
|
||||
) -> TextWithCursors<'static>
|
||||
) -> EditedText<'a, T>
|
||||
where
|
||||
T: PartialEq + Clone + std::fmt::Debug,
|
||||
T: PartialEq + Clone + Debug,
|
||||
{
|
||||
let left_operations =
|
||||
EditedText::from_strings_with_tokenizer(original, left, tokenizer, Side::Left);
|
||||
let right_operations =
|
||||
EditedText::from_strings_with_tokenizer(original, right, tokenizer, Side::Right);
|
||||
|
||||
let merged_operations = left_operations.merge(right_operations);
|
||||
|
||||
TextWithCursors::new_owned(merged_operations.apply(), merged_operations.cursors)
|
||||
left_operations.merge(right_operations)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -69,13 +64,13 @@ mod test {
|
|||
use test_case::test_matrix;
|
||||
|
||||
use super::*;
|
||||
use crate::CursorPosition;
|
||||
use crate::{BuiltinTokenizer, CursorPosition, types::text_with_cursors::TextWithCursors};
|
||||
|
||||
#[test]
|
||||
fn test_cursor_complex() {
|
||||
let original = "this is some complex text to test cursor positions";
|
||||
let original: &'static str = "this is some complex text to test cursor positions";
|
||||
let left = TextWithCursors::new(
|
||||
"this is really complex text for testing cursor positions",
|
||||
"this is really complex text for testing cursor positions".to_owned(),
|
||||
vec![
|
||||
CursorPosition {
|
||||
id: 0,
|
||||
|
|
@ -88,7 +83,7 @@ mod test {
|
|||
],
|
||||
);
|
||||
let right = TextWithCursors::new(
|
||||
"that was some complex sample to test cursor movements",
|
||||
"that was some complex sample to test cursor movements".to_owned(),
|
||||
vec![
|
||||
CursorPosition {
|
||||
id: 2,
|
||||
|
|
@ -101,31 +96,31 @@ mod test {
|
|||
],
|
||||
);
|
||||
|
||||
let merged = reconcile_with_cursors(original, left, right);
|
||||
|
||||
let merged = reconcile(original, &left, &right, &*BuiltinTokenizer::Word).apply();
|
||||
assert_eq!(
|
||||
merged,
|
||||
TextWithCursors::new(
|
||||
"that was really complex sample for testing cursor movements",
|
||||
vec![
|
||||
CursorPosition {
|
||||
id: 2,
|
||||
char_index: 5
|
||||
}, // unchanged
|
||||
CursorPosition {
|
||||
id: 0,
|
||||
char_index: 9
|
||||
}, // before "really"
|
||||
CursorPosition {
|
||||
id: 1,
|
||||
char_index: 23
|
||||
}, // inside of "s|ample" because "text" got replaced by "sample"
|
||||
CursorPosition {
|
||||
id: 3,
|
||||
char_index: 30
|
||||
}, // after "complex sample"
|
||||
]
|
||||
)
|
||||
&merged.text(),
|
||||
"that was really complex sample for testing cursor movements"
|
||||
);
|
||||
assert_eq!(
|
||||
merged.cursors(),
|
||||
vec![
|
||||
CursorPosition {
|
||||
id: 2,
|
||||
char_index: 5
|
||||
}, // unchanged
|
||||
CursorPosition {
|
||||
id: 0,
|
||||
char_index: 9
|
||||
}, // before "really"
|
||||
CursorPosition {
|
||||
id: 1,
|
||||
char_index: 23
|
||||
}, // inside of "s|ample" because "text" got replaced by "sample"
|
||||
CursorPosition {
|
||||
id: 3,
|
||||
char_index: 30
|
||||
}, // after "complex sample"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -173,6 +168,11 @@ mod test {
|
|||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let _ = reconcile(&contents[0], &contents[1], &contents[2]);
|
||||
let _ = reconcile(
|
||||
&contents[0],
|
||||
&(&contents[1]).into(),
|
||||
&(&contents[2]).into(),
|
||||
&*BuiltinTokenizer::Word,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,57 +0,0 @@
|
|||
use std::borrow::Cow;
|
||||
|
||||
#[cfg(feature = "serde")]
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// CursorPosition represents the position of an identifiable cursor in a text
|
||||
// document based on its (UTF-8) character index.
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct CursorPosition {
|
||||
pub id: usize,
|
||||
pub char_index: usize,
|
||||
}
|
||||
|
||||
impl CursorPosition {
|
||||
#[must_use]
|
||||
pub fn with_index(&self, index: usize) -> Self {
|
||||
CursorPosition {
|
||||
id: self.id,
|
||||
char_index: index,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct TextWithCursors<'a> {
|
||||
pub text: Cow<'a, str>,
|
||||
pub cursors: Vec<CursorPosition>,
|
||||
}
|
||||
|
||||
impl<'a> TextWithCursors<'a> {
|
||||
#[must_use]
|
||||
pub fn new(text: &'a str, cursors: Vec<CursorPosition>) -> Self {
|
||||
Self {
|
||||
text: text.into(),
|
||||
cursors,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn new_owned(text: String, cursors: Vec<CursorPosition>) -> Self {
|
||||
Self {
|
||||
text: text.into(),
|
||||
cursors,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<&'a str> for TextWithCursors<'a> {
|
||||
fn from(text: &'a str) -> Self {
|
||||
Self {
|
||||
text: text.into(),
|
||||
cursors: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +1,18 @@
|
|||
use std::fmt::Debug;
|
||||
|
||||
#[cfg(feature = "serde")]
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{CursorPosition, Operation, TextWithCursors};
|
||||
use crate::{
|
||||
diffs::{myers::diff, raw_operation::RawOperation},
|
||||
operation_transformation::utils::{
|
||||
cook_operations::cook_operations, elongate_operations::elongate_operations,
|
||||
BuiltinTokenizer, CursorPosition, TextWithCursors,
|
||||
operation_transformation::{
|
||||
Operation,
|
||||
utils::{cook_operations::cook_operations, elongate_operations::elongate_operations},
|
||||
},
|
||||
tokenizer::{Tokenizer, word_tokenizer::word_tokenizer},
|
||||
utils::{history::History, side::Side, string_builder::StringBuilder},
|
||||
raw_operation::RawOperation,
|
||||
tokenizer::Tokenizer,
|
||||
types::{history::History, side::Side, span_with_history::SpanWithHistory},
|
||||
utils::string_builder::StringBuilder,
|
||||
};
|
||||
|
||||
/// A text document and a sequence of operations that can be applied to the text
|
||||
|
|
@ -27,11 +31,11 @@ use crate::{
|
|||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct EditedText<'a, T>
|
||||
where
|
||||
T: PartialEq + Clone + std::fmt::Debug,
|
||||
T: PartialEq + Clone + Debug,
|
||||
{
|
||||
text: &'a str,
|
||||
operations: Vec<Operation<T>>,
|
||||
pub(crate) cursors: Vec<CursorPosition>,
|
||||
cursors: Vec<CursorPosition>,
|
||||
}
|
||||
|
||||
impl<'a> EditedText<'a, String> {
|
||||
|
|
@ -42,14 +46,14 @@ impl<'a> EditedText<'a, String> {
|
|||
/// word tokenizer is used to tokenize the text which splits the text on
|
||||
/// whitespaces.
|
||||
#[must_use]
|
||||
pub fn from_strings(original: &'a str, updated: TextWithCursors<'a>, side: Side) -> Self {
|
||||
Self::from_strings_with_tokenizer(original, updated, &word_tokenizer, side)
|
||||
pub fn from_strings(original: &'a str, updated: &TextWithCursors, side: Side) -> Self {
|
||||
Self::from_strings_with_tokenizer(original, updated, &*BuiltinTokenizer::Word, side)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, T> EditedText<'a, T>
|
||||
where
|
||||
T: PartialEq + Clone + std::fmt::Debug,
|
||||
T: PartialEq + Clone + Debug,
|
||||
{
|
||||
/// Create an `EditedText` from the given original (old) and updated (new)
|
||||
/// strings. The returned `EditedText` represents the changes from the
|
||||
|
|
@ -58,19 +62,19 @@ where
|
|||
/// function is used to tokenize the text.
|
||||
pub fn from_strings_with_tokenizer(
|
||||
original: &'a str,
|
||||
updated: TextWithCursors<'a>,
|
||||
updated: &TextWithCursors,
|
||||
tokenizer: &Tokenizer<T>,
|
||||
side: Side,
|
||||
) -> Self {
|
||||
let original_tokens = (tokenizer)(original);
|
||||
let updated_tokens = (tokenizer)(&updated.text);
|
||||
let updated_tokens = (tokenizer)(&updated.text());
|
||||
|
||||
let diff: Vec<RawOperation<T>> = diff(&original_tokens, &updated_tokens);
|
||||
let diff: Vec<RawOperation<T>> = RawOperation::vec_from(&original_tokens, &updated_tokens);
|
||||
|
||||
Self::new(
|
||||
original,
|
||||
cook_operations(elongate_operations(diff), side).collect(),
|
||||
updated.cursors,
|
||||
updated.cursors(),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -88,6 +92,7 @@ where
|
|||
}
|
||||
|
||||
#[must_use]
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub fn merge(self, other: Self) -> Self {
|
||||
debug_assert_eq!(
|
||||
self.text, other.text,
|
||||
|
|
@ -139,27 +144,34 @@ where
|
|||
Operation::Insert { .. } | Operation::Equal { .. }
|
||||
);
|
||||
|
||||
let original_length = operation.len() as i64;
|
||||
let original_length = operation.len();
|
||||
let result = match side {
|
||||
Side::Left => {
|
||||
let result = operation.merge_operations(&mut last_other_op);
|
||||
|
||||
if let ref op @ (Operation::Insert { .. } | Operation::Equal { .. }) = result {
|
||||
let shift = merged_length as i64 - seen_left_length as i64
|
||||
+ op.len() as i64
|
||||
- original_length;
|
||||
let merged_length_signed =
|
||||
isize::try_from(merged_length).unwrap_or(isize::MAX);
|
||||
let seen_left_length_signed =
|
||||
isize::try_from(seen_left_length).unwrap_or(isize::MAX);
|
||||
let op_len_signed = isize::try_from(op.len()).unwrap_or(isize::MAX);
|
||||
let original_length_signed =
|
||||
isize::try_from(original_length).unwrap_or(isize::MAX);
|
||||
|
||||
let shift = merged_length_signed - seen_left_length_signed + op_len_signed
|
||||
- original_length_signed;
|
||||
|
||||
while let Some(cursor) = left_cursors.next_if(|cursor| {
|
||||
cursor.char_index <= seen_left_length + original_length as usize
|
||||
cursor.char_index <= seen_left_length + original_length
|
||||
}) {
|
||||
merged_cursors.push(
|
||||
cursor.with_index((cursor.char_index as i64 + shift) as usize),
|
||||
cursor.with_index(cursor.char_index.saturating_add_signed(shift)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if is_advancing_operation {
|
||||
seen_left_length += original_length as usize;
|
||||
seen_left_length += original_length;
|
||||
}
|
||||
|
||||
maybe_left_op = left_iter.next();
|
||||
|
|
@ -171,21 +183,28 @@ where
|
|||
let result = operation.merge_operations(&mut last_other_op);
|
||||
|
||||
if let ref op @ (Operation::Insert { .. } | Operation::Equal { .. }) = result {
|
||||
let shift = merged_length as i64 - seen_right_length as i64
|
||||
+ op.len() as i64
|
||||
- original_length;
|
||||
let merged_length_signed =
|
||||
isize::try_from(merged_length).unwrap_or(isize::MAX);
|
||||
let seen_right_length_signed =
|
||||
isize::try_from(seen_right_length).unwrap_or(isize::MAX);
|
||||
let op_len_signed = isize::try_from(op.len()).unwrap_or(isize::MAX);
|
||||
let original_length_signed =
|
||||
isize::try_from(original_length).unwrap_or(isize::MAX);
|
||||
|
||||
let shift = merged_length_signed - seen_right_length_signed + op_len_signed
|
||||
- original_length_signed;
|
||||
|
||||
while let Some(cursor) = right_cursors.next_if(|cursor| {
|
||||
cursor.char_index <= seen_right_length + original_length as usize
|
||||
cursor.char_index <= seen_right_length + original_length
|
||||
}) {
|
||||
merged_cursors.push(
|
||||
cursor.with_index((cursor.char_index as i64 + shift) as usize),
|
||||
cursor.with_index(cursor.char_index.saturating_add_signed(shift)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if is_advancing_operation {
|
||||
seen_right_length += original_length as usize;
|
||||
seen_right_length += original_length;
|
||||
}
|
||||
|
||||
maybe_right_op = right_iter.next();
|
||||
|
|
@ -215,18 +234,18 @@ where
|
|||
|
||||
/// Apply the operations to the text and return the resulting text.
|
||||
#[must_use]
|
||||
pub fn apply(&self) -> String {
|
||||
pub fn apply(&self) -> TextWithCursors {
|
||||
let mut builder: StringBuilder<'_> = StringBuilder::new(self.text);
|
||||
|
||||
for operation in &self.operations {
|
||||
builder = operation.apply(builder);
|
||||
}
|
||||
|
||||
builder.build()
|
||||
TextWithCursors::new(builder.take(), self.cursors.clone())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn apply_with_history(&self) -> Vec<(History, String)> {
|
||||
pub fn apply_with_history(&self) -> Vec<SpanWithHistory> {
|
||||
let mut builder: StringBuilder<'_> = StringBuilder::new(self.text);
|
||||
|
||||
let mut history = Vec::with_capacity(self.operations.len());
|
||||
|
|
@ -235,10 +254,17 @@ where
|
|||
builder = operation.apply(builder);
|
||||
|
||||
match operation {
|
||||
Operation::Equal { .. } => history.push((History::Unchanged, builder.take())),
|
||||
Operation::Equal { .. } => {
|
||||
history.push(SpanWithHistory::new(builder.take(), History::Unchanged));
|
||||
}
|
||||
Operation::Insert { side, .. } => match side {
|
||||
Side::Left => history.push((History::AddedFromLeft, builder.take())),
|
||||
Side::Right => history.push((History::AddedFromRight, builder.take())),
|
||||
Side::Left => {
|
||||
history.push(SpanWithHistory::new(builder.take(), History::AddedFromLeft));
|
||||
}
|
||||
Side::Right => history.push(SpanWithHistory::new(
|
||||
builder.take(),
|
||||
History::AddedFromRight,
|
||||
)),
|
||||
},
|
||||
Operation::Delete {
|
||||
deleted_character_count,
|
||||
|
|
@ -248,8 +274,12 @@ where
|
|||
} => {
|
||||
let deleted = self.text[*order..*order + *deleted_character_count].to_string();
|
||||
match side {
|
||||
Side::Left => history.push((History::RemovedFromLeft, deleted)),
|
||||
Side::Right => history.push((History::RemovedFromRight, deleted)),
|
||||
Side::Left => {
|
||||
history.push(SpanWithHistory::new(deleted, History::RemovedFromLeft));
|
||||
}
|
||||
Side::Right => {
|
||||
history.push(SpanWithHistory::new(deleted, History::RemovedFromRight));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -271,24 +301,24 @@ mod tests {
|
|||
let left = "hello world! How are you? Adam";
|
||||
let right = "Hello, my friend! How are you doing? Albert";
|
||||
|
||||
let operations = EditedText::from_strings(left, right.into(), Side::Right);
|
||||
let operations = EditedText::from_strings(left, &right.into(), Side::Right);
|
||||
|
||||
insta::assert_debug_snapshot!(operations);
|
||||
|
||||
let new_right = operations.apply();
|
||||
assert_eq!(new_right.to_string(), right);
|
||||
assert_eq!(new_right.text(), right);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calculate_operations_with_no_diff() {
|
||||
let text = "hello world!";
|
||||
|
||||
let operations = EditedText::from_strings(text, text.into(), Side::Right);
|
||||
let operations = EditedText::from_strings(text, &text.into(), Side::Right);
|
||||
|
||||
assert_debug_snapshot!(operations);
|
||||
|
||||
let new_right = operations.apply();
|
||||
assert_eq!(new_right.to_string(), text);
|
||||
assert_eq!(new_right.text(), text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -298,10 +328,10 @@ mod tests {
|
|||
let right = "Hello world! How are you?";
|
||||
let expected = "Hello world! How are you? I'm Andras.";
|
||||
|
||||
let operations_1 = EditedText::from_strings(original, left.into(), Side::Left);
|
||||
let operations_2 = EditedText::from_strings(original, right.into(), Side::Right);
|
||||
let operations_1 = EditedText::from_strings(original, &left.into(), Side::Left);
|
||||
let operations_2 = EditedText::from_strings(original, &right.into(), Side::Right);
|
||||
|
||||
let operations = operations_1.merge(operations_2);
|
||||
assert_eq!(operations.apply(), expected);
|
||||
assert_eq!(operations.apply().text(), expected);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ use core::fmt::{Debug, Display};
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
Token,
|
||||
Side, Token,
|
||||
utils::{
|
||||
find_longest_prefix_contained_within::find_longest_prefix_contained_within, side::Side,
|
||||
find_longest_prefix_contained_within::find_longest_prefix_contained_within,
|
||||
string_builder::StringBuilder,
|
||||
},
|
||||
};
|
||||
|
|
@ -16,7 +16,7 @@ use crate::{
|
|||
#[derive(Clone, PartialEq)]
|
||||
pub enum Operation<T>
|
||||
where
|
||||
T: PartialEq + Clone + std::fmt::Debug,
|
||||
T: PartialEq + Clone + Debug,
|
||||
{
|
||||
Equal {
|
||||
order: usize,
|
||||
|
|
@ -46,7 +46,7 @@ where
|
|||
|
||||
impl<T> Operation<T>
|
||||
where
|
||||
T: PartialEq + Clone + std::fmt::Debug,
|
||||
T: PartialEq + Clone + Debug,
|
||||
{
|
||||
/// Creates an equal operation with the given index.
|
||||
/// This operation is used to indicate that the text at the given index
|
||||
|
|
@ -241,7 +241,7 @@ where
|
|||
*last_delete_order + *last_delete_deleted_character_count;
|
||||
|
||||
let new_length = deleted_character_count
|
||||
.min(0.max(operation_end_index as i64 - last_delete_end_index as i64) as usize);
|
||||
.min(operation_end_index.saturating_sub(last_delete_end_index));
|
||||
|
||||
let overlap = deleted_character_count - new_length;
|
||||
|
||||
|
|
@ -282,30 +282,21 @@ where
|
|||
let last_delete_end_index =
|
||||
*last_delete_order + *last_delete_deleted_character_count;
|
||||
|
||||
let overlap =
|
||||
0.max((length as i64).min(last_delete_end_index as i64 - order as i64));
|
||||
let overlap = length.min(last_delete_end_index.saturating_sub(order));
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
let updated_equal = text.as_ref().map_or_else(
|
||||
|| {
|
||||
Operation::create_equal(
|
||||
order + overlap as usize,
|
||||
(length as i64 - overlap) as usize,
|
||||
)
|
||||
},
|
||||
|| Operation::create_equal(order + overlap, length - overlap),
|
||||
|text| {
|
||||
Operation::create_equal_with_text(
|
||||
order + overlap as usize,
|
||||
text.chars().skip(overlap as usize).collect::<String>(),
|
||||
order + overlap,
|
||||
text.chars().skip(overlap).collect::<String>(),
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
let updated_equal = Operation::create_equal(
|
||||
order + overlap as usize,
|
||||
(length as i64 - overlap) as usize,
|
||||
);
|
||||
let updated_equal = Operation::create_equal(order + overlap, length - overlap);
|
||||
|
||||
updated_equal
|
||||
}
|
||||
|
|
@ -332,7 +323,7 @@ where
|
|||
|
||||
impl<T> Display for Operation<T>
|
||||
where
|
||||
T: PartialEq + Clone + std::fmt::Debug,
|
||||
T: PartialEq + Clone + Debug,
|
||||
{
|
||||
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
||||
match self {
|
||||
|
|
@ -400,7 +391,7 @@ where
|
|||
|
||||
impl<T> Debug for Operation<T>
|
||||
where
|
||||
T: PartialEq + Clone + std::fmt::Debug,
|
||||
T: PartialEq + Clone + Debug,
|
||||
{
|
||||
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { write!(f, "{self}") }
|
||||
}
|
||||
|
|
@ -421,7 +412,7 @@ mod tests {
|
|||
let mut builder = delete_operation.apply(builder);
|
||||
builder = retain_operation.apply(builder);
|
||||
|
||||
assert_eq!(builder.build(), "world");
|
||||
assert_eq!(builder.take(), "world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -434,6 +425,6 @@ mod tests {
|
|||
let mut builder = retain_operation.apply(builder);
|
||||
builder = insert_operation.apply(builder);
|
||||
|
||||
assert_eq!(builder.build(), "hello my friend");
|
||||
assert_eq!(builder.take(), "hello my friend");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
use crate::{
|
||||
diffs::raw_operation::RawOperation, operation_transformation::Operation, utils::side::Side,
|
||||
};
|
||||
use std::fmt::Debug;
|
||||
|
||||
use crate::{operation_transformation::Operation, raw_operation::RawOperation, types::side::Side};
|
||||
|
||||
/// Turn raw operations into ordered operations while keeping track of the
|
||||
/// original token's indexes.
|
||||
pub fn cook_operations<I, T>(raw_operations: I, side: Side) -> impl Iterator<Item = Operation<T>>
|
||||
where
|
||||
I: IntoIterator<Item = RawOperation<T>>,
|
||||
T: PartialEq + Clone + std::fmt::Debug,
|
||||
T: PartialEq + Clone + Debug,
|
||||
{
|
||||
let mut original_text_index = 0; // this is the start index of the operation on the original text
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
use core::iter;
|
||||
use std::fmt::Debug;
|
||||
|
||||
use crate::diffs::raw_operation::RawOperation;
|
||||
use crate::raw_operation::RawOperation;
|
||||
|
||||
/// Elongates the operations by merging adjacent insertions and deletions that
|
||||
/// can be joined. This makes the subsequent merging of operations more
|
||||
|
|
@ -8,7 +9,7 @@ use crate::diffs::raw_operation::RawOperation;
|
|||
pub fn elongate_operations<I, T>(raw_operations: I) -> Vec<RawOperation<T>>
|
||||
where
|
||||
I: IntoIterator<Item = RawOperation<T>>,
|
||||
T: PartialEq + Clone + std::fmt::Debug,
|
||||
T: PartialEq + Clone + Debug,
|
||||
{
|
||||
// This might look bad, but this makes sense. The inserts and deltes can be
|
||||
// interleaved, such as: IDIDID and we need to turn this into IIIDDD.
|
||||
|
|
@ -24,7 +25,7 @@ where
|
|||
.flat_map(|next| match next {
|
||||
RawOperation::Insert(..) => match maybe_previous_insert.take() {
|
||||
Some(prev) if prev.is_right_joinable() && next.is_left_joinable() => {
|
||||
maybe_previous_insert = Some(prev.extend(next));
|
||||
maybe_previous_insert = Some(prev.join(next));
|
||||
Box::new(iter::empty()) as Box<dyn Iterator<Item = RawOperation<T>>>
|
||||
}
|
||||
prev => {
|
||||
|
|
@ -34,7 +35,7 @@ where
|
|||
},
|
||||
RawOperation::Delete(..) => match maybe_previous_delete.take() {
|
||||
Some(prev) if prev.is_right_joinable() && next.is_left_joinable() => {
|
||||
maybe_previous_delete = Some(prev.extend(next));
|
||||
maybe_previous_delete = Some(prev.join(next));
|
||||
Box::new(iter::empty()) as Box<dyn Iterator<Item = RawOperation<T>>>
|
||||
}
|
||||
prev => {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,15 @@
|
|||
use crate::tokenizer::token::Token;
|
||||
use std::fmt::Debug;
|
||||
|
||||
use crate::{tokenizer::token::Token, utils::myers_diff::myers_diff};
|
||||
|
||||
/// Text editing operation containing the to-be-changed `Tokens`-s.
|
||||
///
|
||||
/// `RawOperations` can be joined together when the underlying tokens
|
||||
/// allow for joining subseqeunt operations.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum RawOperation<T>
|
||||
where
|
||||
T: PartialEq + Clone + std::fmt::Debug,
|
||||
T: PartialEq + Clone + Debug,
|
||||
{
|
||||
Insert(Vec<Token<T>>),
|
||||
Delete(Vec<Token<T>>),
|
||||
|
|
@ -12,8 +18,10 @@ where
|
|||
|
||||
impl<T> RawOperation<T>
|
||||
where
|
||||
T: PartialEq + Clone + std::fmt::Debug,
|
||||
T: PartialEq + Clone + Debug,
|
||||
{
|
||||
pub fn vec_from(left: &[Token<T>], right: &[Token<T>]) -> Vec<Self> { myers_diff(left, right) }
|
||||
|
||||
pub fn tokens(&self) -> &Vec<Token<T>> {
|
||||
match self {
|
||||
RawOperation::Insert(tokens)
|
||||
|
|
@ -41,7 +49,7 @@ where
|
|||
/// Extends the operation with another operation. Only operations of the
|
||||
/// same type as self can be used to extend self, otherwise the function
|
||||
/// will panic.
|
||||
pub fn extend(self, other: RawOperation<T>) -> RawOperation<T> {
|
||||
pub fn join(self, other: RawOperation<T>) -> RawOperation<T> {
|
||||
debug_assert!(
|
||||
std::mem::discriminant(&self) == std::mem::discriminant(&other),
|
||||
"Cannot extend operations of different types. This should have been handled before \
|
||||
|
|
@ -1,7 +1,49 @@
|
|||
mod character_tokenizer;
|
||||
mod line_tokenizer;
|
||||
mod word_tokenizer;
|
||||
|
||||
use std::ops::Deref;
|
||||
|
||||
#[cfg(feature = "serde")]
|
||||
use serde::{Deserialize, Serialize};
|
||||
use token::Token;
|
||||
#[cfg(feature = "wasm")]
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
pub mod token;
|
||||
pub mod word_tokenizer;
|
||||
|
||||
/// A trait for tokenizers that take a string and return a list of tokens.
|
||||
pub type Tokenizer<T> = dyn Fn(&str) -> Vec<Token<T>>;
|
||||
|
||||
#[cfg_attr(feature = "wasm", wasm_bindgen)]
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg(feature = "wasm")]
|
||||
pub enum BuiltinTokenizer {
|
||||
Character = "Character",
|
||||
Line = "Line",
|
||||
Word = "Word",
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg(not(feature = "wasm"))]
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
pub enum BuiltinTokenizer {
|
||||
Character,
|
||||
Line,
|
||||
Word,
|
||||
}
|
||||
|
||||
impl Deref for BuiltinTokenizer {
|
||||
type Target = Tokenizer<String>;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
match self {
|
||||
BuiltinTokenizer::Character => &character_tokenizer::character_tokenizer,
|
||||
BuiltinTokenizer::Line => &line_tokenizer::line_tokenizer,
|
||||
BuiltinTokenizer::Word => &word_tokenizer::word_tokenizer,
|
||||
#[cfg(feature = "wasm")]
|
||||
BuiltinTokenizer::__Invalid => panic!("Unexpected tokenizer type"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
26
src/tokenizer/character_tokenizer.rs
Normal file
26
src/tokenizer/character_tokenizer.rs
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
use super::token::Token;
|
||||
|
||||
/// Splits text into UTF-8 characters.
|
||||
///
|
||||
/// ```not_rust
|
||||
/// "Hey!" -> ["H", "e", "y", "!"]
|
||||
/// ```
|
||||
pub fn character_tokenizer(text: &str) -> Vec<Token<String>> {
|
||||
text.chars()
|
||||
.map(|char| Token::new(char.to_string(), char.to_string(), true, true))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use insta::assert_debug_snapshot;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_with_snapshots() {
|
||||
assert_debug_snapshot!(character_tokenizer(""));
|
||||
|
||||
assert_debug_snapshot!(character_tokenizer(" hello, \nwhere are you?"));
|
||||
}
|
||||
}
|
||||
70
src/tokenizer/line_tokenizer.rs
Normal file
70
src/tokenizer/line_tokenizer.rs
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
use super::token::Token;
|
||||
|
||||
/// Splits text into lines, preserving line endings as separate tokens.
|
||||
///
|
||||
/// ## Example
|
||||
///
|
||||
/// ```not_rust
|
||||
/// "Hello\nWorld!" -> ["Hello", "\n", "World!"]
|
||||
/// "Line 1\r\nLine 2" -> ["Line 1", "\r\n", "Line 2"]
|
||||
/// ```
|
||||
pub fn line_tokenizer(text: &str) -> Vec<Token<String>> {
|
||||
let mut result = Vec::new();
|
||||
let mut line_start = 0;
|
||||
|
||||
let mut chars = text.char_indices().peekable();
|
||||
while let Some((i, c)) = chars.next() {
|
||||
if c == '\n' {
|
||||
// Add line content if any
|
||||
if i > line_start {
|
||||
result.push(text[line_start..i].into());
|
||||
}
|
||||
// Add newline
|
||||
result.push("\n".into());
|
||||
line_start = i + 1;
|
||||
} else if c == '\r' && chars.peek() == Some(&(i + 1, '\n')) {
|
||||
// Handle \r\n
|
||||
if i > line_start {
|
||||
result.push(text[line_start..i].into());
|
||||
}
|
||||
chars.next(); // consume \n
|
||||
result.push("\r\n".into());
|
||||
line_start = i + 2;
|
||||
}
|
||||
}
|
||||
|
||||
// Add final line if any
|
||||
if line_start < text.len() {
|
||||
result.push(text[line_start..].into());
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use insta::assert_debug_snapshot;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_with_snapshots() {
|
||||
assert_debug_snapshot!(line_tokenizer(""));
|
||||
|
||||
assert_debug_snapshot!(line_tokenizer("Hello"));
|
||||
|
||||
assert_debug_snapshot!(line_tokenizer("Hello\nWorld"));
|
||||
|
||||
assert_debug_snapshot!(line_tokenizer("Hello\nWorld\n"));
|
||||
|
||||
assert_debug_snapshot!(line_tokenizer("Line 1\r\nLine 2"));
|
||||
|
||||
assert_debug_snapshot!(line_tokenizer("Multi\nLine\nText\nHere"));
|
||||
|
||||
assert_debug_snapshot!(line_tokenizer("\n"));
|
||||
|
||||
assert_debug_snapshot!(line_tokenizer("\n\n"));
|
||||
|
||||
assert_debug_snapshot!(line_tokenizer("Start\n\nEnd"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
---
|
||||
source: src/tokenizer/character_tokenizer.rs
|
||||
expression: "character_tokenizer(\" hello, \\nwhere are you?\")"
|
||||
---
|
||||
[
|
||||
Token {
|
||||
normalised: " ",
|
||||
original: " ",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
Token {
|
||||
normalised: "h",
|
||||
original: "h",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
Token {
|
||||
normalised: "e",
|
||||
original: "e",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
Token {
|
||||
normalised: "l",
|
||||
original: "l",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
Token {
|
||||
normalised: "l",
|
||||
original: "l",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
Token {
|
||||
normalised: "o",
|
||||
original: "o",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
Token {
|
||||
normalised: ",",
|
||||
original: ",",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
Token {
|
||||
normalised: " ",
|
||||
original: " ",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
Token {
|
||||
normalised: "\n",
|
||||
original: "\n",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
Token {
|
||||
normalised: "w",
|
||||
original: "w",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
Token {
|
||||
normalised: "h",
|
||||
original: "h",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
Token {
|
||||
normalised: "e",
|
||||
original: "e",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
Token {
|
||||
normalised: "r",
|
||||
original: "r",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
Token {
|
||||
normalised: "e",
|
||||
original: "e",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
Token {
|
||||
normalised: " ",
|
||||
original: " ",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
Token {
|
||||
normalised: "a",
|
||||
original: "a",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
Token {
|
||||
normalised: "r",
|
||||
original: "r",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
Token {
|
||||
normalised: "e",
|
||||
original: "e",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
Token {
|
||||
normalised: " ",
|
||||
original: " ",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
Token {
|
||||
normalised: "y",
|
||||
original: "y",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
Token {
|
||||
normalised: "o",
|
||||
original: "o",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
Token {
|
||||
normalised: "u",
|
||||
original: "u",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
Token {
|
||||
normalised: "?",
|
||||
original: "?",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
]
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
source: src/tokenizer/character_tokenizer.rs
|
||||
expression: "character_tokenizer(\"\")"
|
||||
---
|
||||
[]
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
---
|
||||
source: src/tokenizer/line_tokenizer.rs
|
||||
expression: "line_tokenizer(\"Hello\")"
|
||||
snapshot_kind: text
|
||||
---
|
||||
[
|
||||
Token {
|
||||
normalised: "Hello",
|
||||
original: "Hello",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
]
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
---
|
||||
source: src/tokenizer/line_tokenizer.rs
|
||||
expression: "line_tokenizer(\"Hello\\nWorld\")"
|
||||
snapshot_kind: text
|
||||
---
|
||||
[
|
||||
Token {
|
||||
normalised: "Hello",
|
||||
original: "Hello",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
Token {
|
||||
normalised: "\n",
|
||||
original: "\n",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
Token {
|
||||
normalised: "World",
|
||||
original: "World",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
]
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
---
|
||||
source: src/tokenizer/line_tokenizer.rs
|
||||
expression: "line_tokenizer(\"Hello\\nWorld\\n\")"
|
||||
snapshot_kind: text
|
||||
---
|
||||
[
|
||||
Token {
|
||||
normalised: "Hello",
|
||||
original: "Hello",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
Token {
|
||||
normalised: "\n",
|
||||
original: "\n",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
Token {
|
||||
normalised: "World",
|
||||
original: "World",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
Token {
|
||||
normalised: "\n",
|
||||
original: "\n",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
]
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
---
|
||||
source: src/tokenizer/line_tokenizer.rs
|
||||
expression: "line_tokenizer(\"Line 1\\r\\nLine 2\")"
|
||||
snapshot_kind: text
|
||||
---
|
||||
[
|
||||
Token {
|
||||
normalised: "Line 1",
|
||||
original: "Line 1",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
Token {
|
||||
normalised: "\r\n",
|
||||
original: "\r\n",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
Token {
|
||||
normalised: "Line 2",
|
||||
original: "Line 2",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
]
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
---
|
||||
source: src/tokenizer/line_tokenizer.rs
|
||||
expression: "line_tokenizer(\"Multi\\nLine\\nText\\nHere\")"
|
||||
snapshot_kind: text
|
||||
---
|
||||
[
|
||||
Token {
|
||||
normalised: "Multi",
|
||||
original: "Multi",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
Token {
|
||||
normalised: "\n",
|
||||
original: "\n",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
Token {
|
||||
normalised: "Line",
|
||||
original: "Line",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
Token {
|
||||
normalised: "\n",
|
||||
original: "\n",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
Token {
|
||||
normalised: "Text",
|
||||
original: "Text",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
Token {
|
||||
normalised: "\n",
|
||||
original: "\n",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
Token {
|
||||
normalised: "Here",
|
||||
original: "Here",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
]
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
---
|
||||
source: src/tokenizer/line_tokenizer.rs
|
||||
expression: "line_tokenizer(\"\\n\")"
|
||||
snapshot_kind: text
|
||||
---
|
||||
[
|
||||
Token {
|
||||
normalised: "\n",
|
||||
original: "\n",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
]
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
---
|
||||
source: src/tokenizer/line_tokenizer.rs
|
||||
expression: "line_tokenizer(\"\\n\\n\")"
|
||||
snapshot_kind: text
|
||||
---
|
||||
[
|
||||
Token {
|
||||
normalised: "\n",
|
||||
original: "\n",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
Token {
|
||||
normalised: "\n",
|
||||
original: "\n",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
]
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
---
|
||||
source: src/tokenizer/line_tokenizer.rs
|
||||
expression: "line_tokenizer(\"Start\\n\\nEnd\")"
|
||||
snapshot_kind: text
|
||||
---
|
||||
[
|
||||
Token {
|
||||
normalised: "Start",
|
||||
original: "Start",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
Token {
|
||||
normalised: "\n",
|
||||
original: "\n",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
Token {
|
||||
normalised: "\n",
|
||||
original: "\n",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
Token {
|
||||
normalised: "End",
|
||||
original: "End",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
]
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
source: src/tokenizer/line_tokenizer.rs
|
||||
expression: "line_tokenizer(\"\")"
|
||||
snapshot_kind: text
|
||||
---
|
||||
[]
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
use std::fmt::Debug;
|
||||
|
||||
#[cfg(feature = "serde")]
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
|
|
@ -12,7 +14,7 @@ use serde::{Deserialize, Serialize};
|
|||
#[derive(Debug, Clone)]
|
||||
pub struct Token<T>
|
||||
where
|
||||
T: PartialEq + Clone + std::fmt::Debug,
|
||||
T: PartialEq + Clone + Debug,
|
||||
{
|
||||
/// The normalised form of the token used deriving the diff.
|
||||
normalised: T,
|
||||
|
|
@ -35,7 +37,7 @@ impl From<&str> for Token<String> {
|
|||
|
||||
impl<T> Token<T>
|
||||
where
|
||||
T: PartialEq + Clone + std::fmt::Debug,
|
||||
T: PartialEq + Clone + Debug,
|
||||
{
|
||||
pub fn new(
|
||||
normalised: T,
|
||||
|
|
@ -62,7 +64,7 @@ where
|
|||
|
||||
impl<T> PartialEq for Token<T>
|
||||
where
|
||||
T: PartialEq + Clone + std::fmt::Debug,
|
||||
T: PartialEq + Clone + Debug,
|
||||
{
|
||||
fn eq(&self, other: &Self) -> bool { self.normalised == other.normalised }
|
||||
}
|
||||
|
|
|
|||
5
src/types.rs
Normal file
5
src/types.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
pub mod cursor_position;
|
||||
pub mod history;
|
||||
pub mod side;
|
||||
pub mod span_with_history;
|
||||
pub mod text_with_cursors;
|
||||
37
src/types/cursor_position.rs
Normal file
37
src/types/cursor_position.rs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
#[cfg(feature = "serde")]
|
||||
use serde::{Deserialize, Serialize};
|
||||
#[cfg(feature = "wasm")]
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
// CursorPosition represents the position of an identifiable cursor in a text
|
||||
// document based on its (UTF-8) character index.
|
||||
#[allow(clippy::unsafe_derive_deserialize)]
|
||||
#[cfg_attr(feature = "wasm", wasm_bindgen)]
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct CursorPosition {
|
||||
pub id: usize,
|
||||
pub char_index: usize,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "wasm", wasm_bindgen)]
|
||||
impl CursorPosition {
|
||||
#[cfg_attr(feature = "wasm", wasm_bindgen(constructor))]
|
||||
#[must_use]
|
||||
pub fn new(id: usize, char_index: usize) -> Self { Self { id, char_index } }
|
||||
|
||||
#[must_use]
|
||||
pub fn with_index(&self, index: usize) -> Self {
|
||||
CursorPosition {
|
||||
id: self.id,
|
||||
char_index: index,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn id(&self) -> usize { self.id }
|
||||
|
||||
#[cfg_attr(feature = "wasm", wasm_bindgen(js_name = characterIndex))]
|
||||
#[must_use]
|
||||
pub fn char_index(&self) -> usize { self.char_index }
|
||||
}
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
#[cfg(feature = "serde")]
|
||||
use serde::{Deserialize, Serialize};
|
||||
#[cfg(feature = "wasm")]
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[cfg_attr(feature = "wasm", wasm_bindgen)]
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg(feature = "wasm")]
|
||||
pub enum History {
|
||||
|
|
@ -12,8 +15,11 @@ pub enum History {
|
|||
RemovedFromRight = "RemovedFromRight",
|
||||
}
|
||||
|
||||
/// Simple enum for describing the result of `reconcile` in a flat list.
|
||||
/// When compiled to WASM, the enum values are the same as their names.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg(not(feature = "wasm"))]
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
pub enum History {
|
||||
Unchanged,
|
||||
AddedFromLeft,
|
||||
|
|
@ -3,6 +3,8 @@ use std::fmt::Display;
|
|||
#[cfg(feature = "serde")]
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Pretty-printable flag to tell which conflicting edit (side)
|
||||
/// an operation is associated with.
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Side {
|
||||
29
src/types/span_with_history.rs
Normal file
29
src/types/span_with_history.rs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
#[cfg(feature = "serde")]
|
||||
use serde::{Deserialize, Serialize};
|
||||
#[cfg(feature = "wasm")]
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::types::history::History;
|
||||
|
||||
/// Wrapper type for `(String, History)` where History describes the origin of
|
||||
/// `text`.
|
||||
#[allow(clippy::unsafe_derive_deserialize)]
|
||||
#[cfg_attr(feature = "wasm", wasm_bindgen)]
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct SpanWithHistory {
|
||||
text: String,
|
||||
history: History,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "wasm", wasm_bindgen)]
|
||||
impl SpanWithHistory {
|
||||
#[must_use]
|
||||
pub fn new(text: String, history: History) -> Self { SpanWithHistory { text, history } }
|
||||
|
||||
#[must_use]
|
||||
pub fn history(&self) -> History { self.history }
|
||||
|
||||
#[must_use]
|
||||
pub fn text(&self) -> String { self.text.clone() }
|
||||
}
|
||||
64
src/types/text_with_cursors.rs
Normal file
64
src/types/text_with_cursors.rs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
#[cfg(feature = "wasm")]
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::types::cursor_position::CursorPosition;
|
||||
|
||||
#[cfg_attr(feature = "wasm", wasm_bindgen)]
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct TextWithCursors {
|
||||
text: String, // wasm-pack doesn't support generics so we can't use Cow here
|
||||
cursors: Vec<CursorPosition>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "wasm", wasm_bindgen)]
|
||||
impl TextWithCursors {
|
||||
#[cfg_attr(feature = "wasm", wasm_bindgen(constructor))]
|
||||
#[must_use]
|
||||
pub fn new(text: String, cursors: Vec<CursorPosition>) -> Self {
|
||||
let length = text.chars().count();
|
||||
for cursor in &cursors {
|
||||
debug_assert!(
|
||||
cursor.char_index <= length,
|
||||
// cursor.char_index == length means that the cursor is at the end
|
||||
"Cursor positions ({}) must be contained within the text (of length {length}) or \
|
||||
just after the end",
|
||||
cursor.char_index
|
||||
);
|
||||
}
|
||||
|
||||
Self { text, cursors }
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn text(&self) -> String { self.text.to_string() }
|
||||
|
||||
#[must_use]
|
||||
pub fn cursors(&self) -> Vec<CursorPosition> { self.cursors.clone() }
|
||||
}
|
||||
|
||||
impl<'a> From<&'a str> for TextWithCursors {
|
||||
fn from(text: &'a str) -> Self {
|
||||
Self {
|
||||
text: text.into(),
|
||||
cursors: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&String> for TextWithCursors {
|
||||
fn from(text: &String) -> Self {
|
||||
Self {
|
||||
text: text.to_owned(),
|
||||
cursors: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for TextWithCursors {
|
||||
fn from(text: String) -> Self {
|
||||
Self {
|
||||
text,
|
||||
cursors: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
pub mod common_prefix_len;
|
||||
pub mod common_suffix_len;
|
||||
pub mod find_longest_prefix_contained_within;
|
||||
pub mod history;
|
||||
pub mod side;
|
||||
pub mod is_binary;
|
||||
pub mod myers_diff;
|
||||
pub mod string_builder;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
use std::fmt::Debug;
|
||||
|
||||
use crate::Token;
|
||||
|
||||
/// Given two lists of tokens, returns `length` where `old` list somewhere
|
||||
/// within contains the `length` prefix of the `new` list.
|
||||
/// Given two lists of tokens, returns `length` where the `old` list
|
||||
/// somewhere within contains the `length` prefix of the `new` list.
|
||||
///
|
||||
/// ## Example
|
||||
///
|
||||
|
|
@ -25,7 +27,7 @@ use crate::Token;
|
|||
/// > results in a length of 1
|
||||
pub fn find_longest_prefix_contained_within<T>(old: &[Token<T>], new: &[Token<T>]) -> usize
|
||||
where
|
||||
T: PartialEq + Clone + std::fmt::Debug,
|
||||
T: PartialEq + Clone + Debug,
|
||||
{
|
||||
let max_possible = new.len().min(old.len());
|
||||
|
||||
|
|
|
|||
24
src/utils/is_binary.rs
Normal file
24
src/utils/is_binary.rs
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
/// Heuristically determine if the given data is a binary or a text file's
|
||||
/// content.
|
||||
#[must_use]
|
||||
pub fn is_binary(data: &[u8]) -> bool {
|
||||
if data.contains(&0) {
|
||||
// Even though the NUL character is valid in UTF-8, it's highly suspicious in
|
||||
// human-readable text.
|
||||
return true;
|
||||
}
|
||||
|
||||
std::str::from_utf8(data).is_err()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_is_binary() {
|
||||
assert!(is_binary(&[0, 159, 146, 150]));
|
||||
assert!(is_binary(&[0, 12]));
|
||||
assert!(!is_binary(b"hello"));
|
||||
}
|
||||
}
|
||||
|
|
@ -20,12 +20,13 @@
|
|||
//! For potential improvements here see [similar#15](https://github.com/mitsuhiko/similar/issues/15).
|
||||
|
||||
use std::{
|
||||
fmt::Debug,
|
||||
ops::{Index, IndexMut, Range},
|
||||
vec,
|
||||
};
|
||||
|
||||
use super::raw_operation::RawOperation;
|
||||
use crate::{
|
||||
raw_operation::RawOperation,
|
||||
tokenizer::token::Token,
|
||||
utils::{common_prefix_len::common_prefix_len, common_suffix_len::common_suffix_len},
|
||||
};
|
||||
|
|
@ -35,10 +36,10 @@ use crate::{
|
|||
/// Diff `old`, between indices `old_range` and `new` between indices
|
||||
/// `new_range`.
|
||||
///
|
||||
/// The returned `RawOperations` all have a token count of 1.
|
||||
pub fn diff<T>(old: &[Token<T>], new: &[Token<T>]) -> Vec<RawOperation<T>>
|
||||
/// The returned `RawOperations` each wrap a single token.
|
||||
pub fn myers_diff<T>(old: &[Token<T>], new: &[Token<T>]) -> Vec<RawOperation<T>>
|
||||
where
|
||||
T: PartialEq + Clone + std::fmt::Debug,
|
||||
T: PartialEq + Clone + Debug,
|
||||
{
|
||||
let max_d = (old.len() + new.len()).div_ceil(2) + 1;
|
||||
let mut vb = V::new(max_d);
|
||||
|
|
@ -57,7 +58,7 @@ where
|
|||
|
||||
debug_assert!(
|
||||
result.iter().all(|op| op.tokens().len() == 1),
|
||||
"All operations should be of length 1"
|
||||
"All operations must be of length 1"
|
||||
);
|
||||
|
||||
result
|
||||
|
|
@ -80,13 +81,15 @@ where
|
|||
#[derive(Debug)]
|
||||
struct V {
|
||||
offset: isize,
|
||||
v: Vec<usize>, // Look into initializing this to -1 and storing isize
|
||||
v: Vec<usize>,
|
||||
}
|
||||
|
||||
impl V {
|
||||
fn new(max_d: usize) -> Self {
|
||||
// max_d should fit in isize for the algorithm to work correctly
|
||||
let offset = isize::try_from(max_d).unwrap_or(isize::MAX);
|
||||
Self {
|
||||
offset: max_d as isize,
|
||||
offset,
|
||||
v: vec![0; 2 * max_d],
|
||||
}
|
||||
}
|
||||
|
|
@ -97,12 +100,17 @@ impl V {
|
|||
impl Index<isize> for V {
|
||||
type Output = usize;
|
||||
|
||||
fn index(&self, index: isize) -> &Self::Output { &self.v[(index + self.offset) as usize] }
|
||||
fn index(&self, index: isize) -> &Self::Output {
|
||||
let idx = usize::try_from(index + self.offset).unwrap_or(usize::MAX);
|
||||
&self.v[idx.min(self.v.len().saturating_sub(1))]
|
||||
}
|
||||
}
|
||||
|
||||
impl IndexMut<isize> for V {
|
||||
fn index_mut(&mut self, index: isize) -> &mut Self::Output {
|
||||
&mut self.v[(index + self.offset) as usize]
|
||||
let idx = usize::try_from(index + self.offset).unwrap_or(usize::MAX);
|
||||
let len = self.v.len();
|
||||
&mut self.v[idx.min(len.saturating_sub(1))]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -130,14 +138,14 @@ fn find_middle_snake<T>(
|
|||
vb: &mut V,
|
||||
) -> Option<(usize, usize)>
|
||||
where
|
||||
T: PartialEq + Clone + std::fmt::Debug,
|
||||
T: PartialEq + Clone + Debug,
|
||||
{
|
||||
let n = old_range.len();
|
||||
let m = new_range.len();
|
||||
|
||||
// By Lemma 1 in the paper, the optimal edit script length is odd or even as
|
||||
// `delta` is odd or even.
|
||||
let delta = n as isize - m as isize;
|
||||
let delta = isize::try_from(n).unwrap_or(isize::MAX) - isize::try_from(m).unwrap_or(isize::MAX);
|
||||
let odd = delta & 1 == 1;
|
||||
|
||||
// The initial point at (0, -1)
|
||||
|
|
@ -149,7 +157,8 @@ where
|
|||
assert!(vf.len() >= d_max);
|
||||
assert!(vb.len() >= d_max);
|
||||
|
||||
for d in 0..d_max as isize {
|
||||
let d_max_isize = isize::try_from(d_max).unwrap_or(isize::MAX);
|
||||
for d in 0..d_max_isize {
|
||||
// Forward path
|
||||
for k in (-d..=d).rev().step_by(2) {
|
||||
let mut x = if k == -d || (k != d && vf[k - 1] < vf[k + 1]) {
|
||||
|
|
@ -157,7 +166,7 @@ where
|
|||
} else {
|
||||
vf[k - 1] + 1
|
||||
};
|
||||
let y = (x as isize - k) as usize;
|
||||
let y = usize::try_from(isize::try_from(x).unwrap_or(isize::MAX) - k).unwrap_or(0);
|
||||
|
||||
// The coordinate of the start of a snake
|
||||
let (x0, y0) = (x, y);
|
||||
|
|
@ -195,7 +204,7 @@ where
|
|||
} else {
|
||||
vb[k - 1] + 1
|
||||
};
|
||||
let mut y = (x as isize - k) as usize;
|
||||
let mut y = usize::try_from(isize::try_from(x).unwrap_or(isize::MAX) - k).unwrap_or(0);
|
||||
|
||||
// The coordinate of the start of a snake
|
||||
if x < n && y < m {
|
||||
|
|
@ -236,7 +245,7 @@ fn conquer<T>(
|
|||
vb: &mut V,
|
||||
result: &mut Vec<RawOperation<T>>,
|
||||
) where
|
||||
T: PartialEq + Clone + std::fmt::Debug,
|
||||
T: PartialEq + Clone + Debug,
|
||||
{
|
||||
// Check for common prefix
|
||||
let common_prefix_len = common_prefix_len(old, old_range.clone(), new, new_range.clone());
|
||||
|
|
@ -312,14 +321,14 @@ mod tests {
|
|||
fn test_empty_diff() {
|
||||
let old: Vec<Token<String>> = vec![];
|
||||
let new: Vec<Token<String>> = vec![];
|
||||
let result = diff(&old, &new);
|
||||
let result = myers_diff(&old, &new);
|
||||
assert_eq!(result.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_identical_content() {
|
||||
let content = vec!["a".into(), "b".into(), "c".into()];
|
||||
let result = diff(&content, &content);
|
||||
let result = myers_diff(&content, &content);
|
||||
assert_debug_snapshot!(result);
|
||||
}
|
||||
|
||||
|
|
@ -327,7 +336,7 @@ mod tests {
|
|||
fn test_insert_only() {
|
||||
let old: Vec<Token<String>> = vec![];
|
||||
let new: Vec<Token<String>> = vec!["a".into(), "b".into()];
|
||||
let result = diff(&old, &new);
|
||||
let result = myers_diff(&old, &new);
|
||||
assert_debug_snapshot!(result);
|
||||
}
|
||||
|
||||
|
|
@ -335,7 +344,7 @@ mod tests {
|
|||
fn test_delete_only() {
|
||||
let old = vec!["a".into(), "b".into()];
|
||||
let new: Vec<Token<String>> = vec![];
|
||||
let result = diff(&old, &new);
|
||||
let result = myers_diff(&old, &new);
|
||||
assert_debug_snapshot!(result);
|
||||
}
|
||||
|
||||
|
|
@ -343,7 +352,7 @@ mod tests {
|
|||
fn test_prefix_and_suffix() {
|
||||
let old = vec!["a".into(), "b".into(), "c".into(), "d".into()];
|
||||
let new = vec!["a".into(), "x".into(), "d".into()];
|
||||
let result = diff(&old, &new);
|
||||
let result = myers_diff(&old, &new);
|
||||
assert_debug_snapshot!(result);
|
||||
}
|
||||
|
||||
|
|
@ -351,7 +360,7 @@ mod tests {
|
|||
fn test_complex_diff() {
|
||||
let old = vec!["a".into(), "b".into(), "c".into(), "d".into()];
|
||||
let new = vec!["a".into(), "x".into(), "c".into(), "y".into()];
|
||||
let result = diff(&old, &new);
|
||||
let result = myers_diff(&old, &new);
|
||||
assert_debug_snapshot!(result);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
---
|
||||
source: reconcile/src/diffs/myers.rs
|
||||
source: src/utils/myers_diff.rs
|
||||
expression: result
|
||||
snapshot_kind: text
|
||||
---
|
||||
[
|
||||
Equal(
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
---
|
||||
source: reconcile/src/diffs/myers.rs
|
||||
source: src/utils/myers_diff.rs
|
||||
expression: result
|
||||
snapshot_kind: text
|
||||
---
|
||||
[
|
||||
Delete(
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
---
|
||||
source: reconcile/src/diffs/myers.rs
|
||||
source: src/utils/myers_diff.rs
|
||||
expression: result
|
||||
snapshot_kind: text
|
||||
---
|
||||
[
|
||||
Equal(
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
---
|
||||
source: reconcile/src/diffs/myers.rs
|
||||
source: src/utils/myers_diff.rs
|
||||
expression: result
|
||||
snapshot_kind: text
|
||||
---
|
||||
[
|
||||
Insert(
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
---
|
||||
source: reconcile/src/diffs/myers.rs
|
||||
source: src/utils/myers_diff.rs
|
||||
expression: result
|
||||
snapshot_kind: text
|
||||
---
|
||||
[
|
||||
Equal(
|
||||
|
|
@ -51,21 +51,13 @@ impl StringBuilder<'_> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Returns the currently built buffer and clears it.
|
||||
pub fn take(&mut self) -> String {
|
||||
let result = self.buffer.clone();
|
||||
self.buffer.clear();
|
||||
result
|
||||
}
|
||||
|
||||
/// Finish building the string after copying the remaining original string
|
||||
/// since the last insertion or deletion.
|
||||
pub fn build(self) -> String { self.buffer }
|
||||
/// Returns the currently built buffer and clears it to allow consuming
|
||||
/// the result incrementally.
|
||||
pub fn take(&mut self) -> String { std::mem::take(&mut self.buffer) }
|
||||
|
||||
/// Get a slice of the remaining original string. The slice starts from
|
||||
/// where the next delete/retain operation would start and is of length
|
||||
/// `length`. The implementation is quite suboptimal but it's only used
|
||||
/// for debugging.
|
||||
/// `length`.
|
||||
#[cfg(debug_assertions)]
|
||||
pub fn get_slice_from_remaining(&self, length: usize) -> String {
|
||||
let result = self.remaining.chars().take(length).collect::<String>();
|
||||
|
|
@ -92,7 +84,7 @@ mod tests {
|
|||
builder.retain(8);
|
||||
builder.insert(" eee");
|
||||
|
||||
assert_eq!(builder.build(), "ddd bbb ccc eee");
|
||||
assert_eq!(builder.take(), "ddd bbb ccc eee");
|
||||
|
||||
let original = "abcde";
|
||||
let mut builder = StringBuilder::new(original);
|
||||
|
|
@ -101,7 +93,7 @@ mod tests {
|
|||
builder.delete(3);
|
||||
builder.retain(1);
|
||||
|
||||
assert_eq!(builder.build(), "ae");
|
||||
assert_eq!(builder.take(), "ae");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -110,7 +102,7 @@ mod tests {
|
|||
let mut builder = StringBuilder::new(original);
|
||||
|
||||
builder.insert("test");
|
||||
assert_eq!(builder.build(), "test");
|
||||
assert_eq!(builder.take(), "test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -122,7 +114,7 @@ mod tests {
|
|||
builder.insert("世界, "); // Insert "World, "
|
||||
builder.retain(2);
|
||||
|
||||
assert_eq!(builder.build(), "こんに世界, ちは");
|
||||
assert_eq!(builder.take(), "こんに世界, ちは");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -145,7 +137,7 @@ mod tests {
|
|||
let mut builder = StringBuilder::new(original);
|
||||
|
||||
builder.retain(original.len());
|
||||
assert_eq!(builder.build(), original);
|
||||
assert_eq!(builder.take(), original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -155,6 +147,6 @@ mod tests {
|
|||
|
||||
builder.delete(original.len());
|
||||
builder.insert("Hi");
|
||||
assert_eq!(builder.build(), "Hi");
|
||||
assert_eq!(builder.take(), "Hi");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
129
src/wasm.rs
129
src/wasm.rs
|
|
@ -1,2 +1,127 @@
|
|||
pub mod lib;
|
||||
pub mod types;
|
||||
//! Expose the `reconcile` crate's functionality to WebAssembly.
|
||||
use core::str;
|
||||
|
||||
use cfg_if::cfg_if;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::{BuiltinTokenizer, CursorPosition, SpanWithHistory, TextWithCursors};
|
||||
cfg_if! {
|
||||
if #[cfg(feature = "wee_alloc")] {
|
||||
#[global_allocator]
|
||||
static ALLOC: wee_alloc::WeeAlloc<'_> = wee_alloc::WeeAlloc::INIT;
|
||||
}
|
||||
}
|
||||
|
||||
/// WASM wrapper around `crate::reconcile` for merging text.
|
||||
#[wasm_bindgen(js_name = reconcile)]
|
||||
#[must_use]
|
||||
pub fn reconcile(
|
||||
parent: &str,
|
||||
left: &TextWithCursors,
|
||||
right: &TextWithCursors,
|
||||
tokenizer: BuiltinTokenizer,
|
||||
) -> TextWithCursors {
|
||||
set_panic_hook();
|
||||
|
||||
crate::reconcile(parent, left, right, &*tokenizer).apply()
|
||||
}
|
||||
|
||||
/// WASM wrapper around `crate::reconcile` for merging text.
|
||||
#[wasm_bindgen(js_name = reconcileWithHistory)]
|
||||
#[must_use]
|
||||
pub fn reconcile_with_history(
|
||||
parent: &str,
|
||||
left: &TextWithCursors,
|
||||
right: &TextWithCursors,
|
||||
tokenizer: BuiltinTokenizer,
|
||||
) -> TextWithCursorsAndHistory {
|
||||
set_panic_hook();
|
||||
let reconciled = crate::reconcile(parent, left, right, &*tokenizer);
|
||||
let text_with_cursors = reconciled.apply();
|
||||
|
||||
TextWithCursorsAndHistory {
|
||||
text_with_cursors,
|
||||
history: reconciled.apply_with_history(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge two documents with a common parent. Relies on `reconcile::reconcile`
|
||||
/// for texts and returns the right document as-is if either of the updated
|
||||
/// documents is binary.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// - `parent`: The common parent document.
|
||||
/// - `left`: The left document updated by one user.
|
||||
/// - `right`: The right document updated by another user.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The merged document.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// If any of the input documents are not valid UTF-8 strings.
|
||||
#[wasm_bindgen(js_name = genericReconcile)]
|
||||
#[must_use]
|
||||
pub fn generic_reconcile(
|
||||
parent: &[u8],
|
||||
left: &[u8],
|
||||
right: &[u8],
|
||||
tokenizer: BuiltinTokenizer,
|
||||
) -> Vec<u8> {
|
||||
set_panic_hook();
|
||||
|
||||
if crate::is_binary(parent) || crate::is_binary(left) || crate::is_binary(right) {
|
||||
right.to_vec()
|
||||
} else {
|
||||
crate::reconcile(
|
||||
str::from_utf8(parent).expect("parent must be valid UTF-8 because it's not binary"),
|
||||
&str::from_utf8(left)
|
||||
.expect("left must be valid UTF-8 because it's not binary")
|
||||
.into(),
|
||||
&str::from_utf8(right)
|
||||
.expect("right must be valid UTF-8 because it's not binary")
|
||||
.into(),
|
||||
&*tokenizer,
|
||||
)
|
||||
.apply()
|
||||
.text()
|
||||
.into_bytes()
|
||||
}
|
||||
}
|
||||
|
||||
/// Heuristically determine if the given data is a binary or a text file's
|
||||
/// content.
|
||||
#[wasm_bindgen(js_name = isBinary)]
|
||||
#[must_use]
|
||||
pub fn is_binary(data: &[u8]) -> bool {
|
||||
set_panic_hook();
|
||||
crate::is_binary(data)
|
||||
}
|
||||
|
||||
fn set_panic_hook() {
|
||||
// https://github.com/rustwasm/console_error_panic_hook#readme
|
||||
#[cfg(feature = "console_error_panic_hook")]
|
||||
console_error_panic_hook::set_once();
|
||||
}
|
||||
|
||||
/// WASM wrapper type for the return value of `reconcile_with_history`
|
||||
#[wasm_bindgen]
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct TextWithCursorsAndHistory {
|
||||
text_with_cursors: TextWithCursors,
|
||||
history: Vec<SpanWithHistory>,
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl TextWithCursorsAndHistory {
|
||||
#[must_use]
|
||||
pub fn text(&self) -> String { self.text_with_cursors.text() }
|
||||
|
||||
#[must_use]
|
||||
pub fn cursors(&self) -> Vec<CursorPosition> { self.text_with_cursors.cursors() }
|
||||
|
||||
#[must_use]
|
||||
pub fn history(&self) -> Vec<SpanWithHistory> { self.history.clone() }
|
||||
}
|
||||
|
|
|
|||
117
src/wasm/lib.rs
117
src/wasm/lib.rs
|
|
@ -1,117 +0,0 @@
|
|||
//! This crate provides utilities for easily communicating between backend &
|
||||
//! frontend and ensuring the same logic for encoding and decoding binary data,
|
||||
//! and 3-way-merging documents in Rust and JavaScript.
|
||||
//!
|
||||
//! The crate is designed to be used as a Rust library and as a
|
||||
//! TypeScript/JavaScript package through WebAssembly (WASM).
|
||||
//!
|
||||
//! # Modules
|
||||
//!
|
||||
//! - `errors`: Contains error types used in this crate.
|
||||
|
||||
use core::str;
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::wasm::types::{JsTextWithCursors, JsTextWithHistory};
|
||||
|
||||
/// Merge two documents with a common parent. Relies on `reconcile::reconcile`
|
||||
/// for texts and returns the right document as-is if either of the updated
|
||||
/// documents is binary.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// - `parent`: The common parent document.
|
||||
/// - `left`: The left document updated by one user.
|
||||
/// - `right`: The right document updated by another user.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The merged document.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// If any of the input documents are not valid UTF-8 strings.
|
||||
#[wasm_bindgen]
|
||||
#[must_use]
|
||||
pub fn merge(parent: &[u8], left: &[u8], right: &[u8]) -> Vec<u8> {
|
||||
set_panic_hook();
|
||||
|
||||
if is_binary(parent) || is_binary(left) || is_binary(right) {
|
||||
right.to_vec()
|
||||
} else {
|
||||
crate::reconcile(
|
||||
str::from_utf8(parent).expect("parent must be valid UTF-8 because it's not binary"),
|
||||
str::from_utf8(left).expect("left must be valid UTF-8 because it's not binary"),
|
||||
str::from_utf8(right).expect("right must be valid UTF-8 because it's not binary"),
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
}
|
||||
|
||||
/// WASM wrapper around `crate::reconcile` for merging text.
|
||||
#[wasm_bindgen(js_name = mergeText)]
|
||||
#[must_use]
|
||||
pub fn merge_text(parent: &str, left: &str, right: &str) -> String {
|
||||
set_panic_hook();
|
||||
|
||||
crate::reconcile(parent, left, right)
|
||||
}
|
||||
|
||||
/// WASM wrapper around `crate::reconcile` for merging text.
|
||||
#[wasm_bindgen(js_name = mergeTextWithHistory)]
|
||||
#[must_use]
|
||||
pub fn merge_text_with_history(parent: &str, left: &str, right: &str) -> Vec<JsTextWithHistory> {
|
||||
set_panic_hook();
|
||||
|
||||
crate::reconcile_with_history(parent, left, right)
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// WASM wrapper around `reconcile::reconcile_with_cursors` for merging text.
|
||||
#[wasm_bindgen(js_name = mergeTextWithCursors)]
|
||||
#[must_use]
|
||||
pub fn merge_text_with_cursors(
|
||||
parent: &str,
|
||||
left: JsTextWithCursors,
|
||||
right: JsTextWithCursors,
|
||||
) -> JsTextWithCursors {
|
||||
set_panic_hook();
|
||||
|
||||
crate::reconcile_with_cursors(parent, left.into(), right.into()).into()
|
||||
}
|
||||
|
||||
/// Heuristically determine if the given data is a binary or a text file's
|
||||
/// content.
|
||||
#[wasm_bindgen(js_name = isBinary)]
|
||||
#[must_use]
|
||||
pub fn is_binary(data: &[u8]) -> bool {
|
||||
set_panic_hook();
|
||||
|
||||
if data.contains(&0) {
|
||||
// Even though the NUL character is valid in UTF-8, it's highly suspicious in
|
||||
// human-readable text.
|
||||
return true;
|
||||
}
|
||||
|
||||
std::str::from_utf8(data).is_err()
|
||||
}
|
||||
|
||||
/// We don't want to support merging structured data like JSON, YAML, etc.
|
||||
#[wasm_bindgen(js_name = isFileTypeMergable)]
|
||||
#[must_use]
|
||||
pub fn is_file_type_mergable(path_or_file_name: &str) -> bool {
|
||||
set_panic_hook();
|
||||
|
||||
let file_extension = path_or_file_name.split('.').next_back().unwrap_or_default();
|
||||
|
||||
matches!(file_extension.to_lowercase().as_str(), "md" | "txt")
|
||||
}
|
||||
|
||||
fn set_panic_hook() {
|
||||
// https://github.com/rustwasm/console_error_panic_hook#readme
|
||||
#[cfg(feature = "console_error_panic_hook")]
|
||||
console_error_panic_hook::set_once();
|
||||
}
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::History;
|
||||
|
||||
/// Wrapper type to expose `TextWithCursors` to JS.
|
||||
#[wasm_bindgen]
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct JsTextWithCursors {
|
||||
text: String,
|
||||
cursors: Vec<JsCursorPosition>,
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl JsTextWithCursors {
|
||||
#[wasm_bindgen(constructor)]
|
||||
#[must_use]
|
||||
pub fn new(text: String, cursors: Vec<JsCursorPosition>) -> Self { Self { text, cursors } }
|
||||
|
||||
#[must_use]
|
||||
pub fn text(&self) -> String { self.text.clone() }
|
||||
|
||||
#[must_use]
|
||||
pub fn cursors(&self) -> Vec<JsCursorPosition> { self.cursors.clone() }
|
||||
}
|
||||
|
||||
impl From<JsTextWithCursors> for crate::TextWithCursors<'_> {
|
||||
fn from(owned: JsTextWithCursors) -> Self {
|
||||
crate::TextWithCursors::new_owned(
|
||||
owned.text.to_string(),
|
||||
owned
|
||||
.cursors
|
||||
.into_iter()
|
||||
.map(std::convert::Into::into)
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crate::TextWithCursors<'_>> for JsTextWithCursors {
|
||||
fn from(text_with_cursors: crate::TextWithCursors<'_>) -> Self {
|
||||
JsTextWithCursors {
|
||||
text: text_with_cursors.text.into_owned(),
|
||||
cursors: text_with_cursors
|
||||
.cursors
|
||||
.into_iter()
|
||||
.map(std::convert::Into::into)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrapper type to expose `CursorPosition` to JS.
|
||||
#[wasm_bindgen]
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct JsCursorPosition {
|
||||
id: usize,
|
||||
char_index: usize,
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl JsCursorPosition {
|
||||
#[wasm_bindgen(constructor)]
|
||||
#[must_use]
|
||||
pub fn new(id: usize, char_index: usize) -> Self { Self { id, char_index } }
|
||||
|
||||
#[must_use]
|
||||
pub fn id(&self) -> usize { self.id }
|
||||
|
||||
#[wasm_bindgen(js_name = characterPosition)]
|
||||
#[must_use]
|
||||
pub fn char_index(&self) -> usize { self.char_index }
|
||||
}
|
||||
|
||||
impl From<JsCursorPosition> for crate::CursorPosition {
|
||||
fn from(owned: JsCursorPosition) -> Self {
|
||||
crate::CursorPosition {
|
||||
id: owned.id,
|
||||
char_index: owned.char_index,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crate::CursorPosition> for JsCursorPosition {
|
||||
fn from(cursor: crate::CursorPosition) -> Self {
|
||||
JsCursorPosition {
|
||||
id: cursor.id,
|
||||
char_index: cursor.char_index,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrapper type to expose `(History, String)` to JS.
|
||||
#[wasm_bindgen]
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct JsTextWithHistory {
|
||||
history: History,
|
||||
text: String,
|
||||
}
|
||||
|
||||
impl From<(History, String)> for JsTextWithHistory {
|
||||
fn from((history, text): (History, String)) -> Self { JsTextWithHistory { history, text } }
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl JsTextWithHistory {
|
||||
#[must_use]
|
||||
pub fn history(&self) -> History { self.history }
|
||||
|
||||
#[must_use]
|
||||
pub fn text(&self) -> String { self.text.clone() }
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
use pretty_assertions::assert_eq;
|
||||
use reconcile::{CursorPosition, TextWithCursors};
|
||||
use reconcile::{CursorPosition, EditedText, TextWithCursors};
|
||||
use serde::Deserialize;
|
||||
|
||||
/// `ExampleDocument` represents a test case for the reconciliation process.
|
||||
|
|
@ -21,12 +21,12 @@ impl ExampleDocument {
|
|||
pub fn parent(&self) -> String { self.parent.clone() }
|
||||
|
||||
#[must_use]
|
||||
pub fn left(&self) -> TextWithCursors<'static> {
|
||||
pub fn left(&self) -> TextWithCursors {
|
||||
ExampleDocument::string_to_text_with_cursors(&self.left)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn right(&self) -> TextWithCursors<'static> {
|
||||
pub fn right(&self) -> TextWithCursors {
|
||||
ExampleDocument::string_to_text_with_cursors(&self.right)
|
||||
}
|
||||
|
||||
|
|
@ -37,7 +37,7 @@ impl ExampleDocument {
|
|||
///
|
||||
/// If the result string does not match the expected string, the program
|
||||
/// will panic.
|
||||
pub fn assert_eq(&self, result: &TextWithCursors<'static>) {
|
||||
pub fn assert_eq(&self, result: &EditedText<'_, String>) {
|
||||
let result_str = ExampleDocument::text_with_cursors_to_string(result);
|
||||
assert_eq!(
|
||||
self.expected, result_str,
|
||||
|
|
@ -53,21 +53,23 @@ impl ExampleDocument {
|
|||
/// If the result string does not match the expected string, the program
|
||||
/// will panic.
|
||||
pub fn assert_eq_without_cursors(&self, result: &str) {
|
||||
let expected = ExampleDocument::string_to_text_with_cursors(&self.expected).text;
|
||||
let expected = ExampleDocument::string_to_text_with_cursors(&self.expected).text();
|
||||
assert_eq!(
|
||||
expected, result,
|
||||
"Left (expected) isn't equal to right (actual), Actual: ```\n{result}```",
|
||||
);
|
||||
}
|
||||
|
||||
fn text_with_cursors_to_string(text: &TextWithCursors<'_>) -> String {
|
||||
let mut result = text.text.clone().into_owned();
|
||||
for (i, cursor) in text.cursors.iter().enumerate() {
|
||||
fn text_with_cursors_to_string(document: &EditedText<'_, String>) -> String {
|
||||
let merged = document.apply();
|
||||
let mut result = merged.text();
|
||||
for (i, cursor) in merged.cursors().iter().enumerate() {
|
||||
assert!(
|
||||
cursor.char_index <= result.len(), // equals in case of insert at the end
|
||||
"Cursor index out of bounds: {} > {} when testing for '{result}'",
|
||||
"Cursor index out of bounds: {} > {} when testing for '{}.'",
|
||||
cursor.char_index,
|
||||
result.len()
|
||||
result.len(),
|
||||
result
|
||||
);
|
||||
|
||||
result.insert(
|
||||
|
|
@ -82,10 +84,10 @@ impl ExampleDocument {
|
|||
result
|
||||
}
|
||||
|
||||
fn string_to_text_with_cursors(text: &str) -> TextWithCursors<'static> {
|
||||
fn string_to_text_with_cursors(text: &str) -> TextWithCursors {
|
||||
let cursors = Self::parse_cursors(text);
|
||||
let text = text.replace('|', "");
|
||||
TextWithCursors::new_owned(text, cursors)
|
||||
TextWithCursors::new(text, cursors)
|
||||
}
|
||||
|
||||
fn parse_cursors(text: &str) -> Vec<CursorPosition> {
|
||||
|
|
|
|||
|
|
@ -1 +1,7 @@
|
|||
The `|` characters denote cursor positions which are stripped before the actual reconcile logic is run
|
||||
# Test Examples
|
||||
|
||||
This directory contains YAML test cases that demonstrate various reconcile scenarios.
|
||||
|
||||
## Cursor Position Notation
|
||||
|
||||
In some test cases, the `|` character is used to denote cursor positions within the text. These characters are stripped before the actual reconcile logic is run, making it easier to visualize where cursors should be positioned.
|
||||
|
|
|
|||
|
|
@ -9,6 +9,12 @@ left: Party C shall pay Party B
|
|||
right: Party A shall receive from Party B
|
||||
expected: Party C shall receive from Party B
|
||||
|
||||
---
|
||||
parent: hello
|
||||
left: hel|lo
|
||||
right: hi
|
||||
expected: "|hi"
|
||||
|
||||
---
|
||||
parent:
|
||||
left: hi my friend|
|
||||
|
|
|
|||
|
|
@ -3,27 +3,33 @@ mod example_document;
|
|||
use std::{fs, path::Path};
|
||||
|
||||
use example_document::ExampleDocument;
|
||||
use reconcile::{reconcile, reconcile_with_cursors};
|
||||
use reconcile::{BuiltinTokenizer, reconcile};
|
||||
use serde::Deserialize;
|
||||
|
||||
#[test]
|
||||
fn test_document_one_way_without_cursors() {
|
||||
for doc in &get_all_documents() {
|
||||
doc.assert_eq_without_cursors(&reconcile(
|
||||
&doc.parent(),
|
||||
&doc.left().text,
|
||||
&doc.right().text,
|
||||
));
|
||||
doc.assert_eq_without_cursors(
|
||||
&reconcile(
|
||||
&doc.parent(),
|
||||
&doc.left().text().into(),
|
||||
&doc.right().text().into(),
|
||||
&*BuiltinTokenizer::Word,
|
||||
)
|
||||
.apply()
|
||||
.text(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_document_one_way_with_cursors() {
|
||||
for doc in &get_all_documents() {
|
||||
doc.assert_eq(&reconcile_with_cursors(
|
||||
doc.assert_eq(&reconcile(
|
||||
&doc.parent(),
|
||||
doc.left(),
|
||||
doc.right(),
|
||||
&doc.left(),
|
||||
&doc.right(),
|
||||
&*BuiltinTokenizer::Word,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -31,21 +37,27 @@ fn test_document_one_way_with_cursors() {
|
|||
#[test]
|
||||
fn test_document_inverse_way_without_cursors() {
|
||||
for doc in &get_all_documents() {
|
||||
doc.assert_eq_without_cursors(&reconcile(
|
||||
&doc.parent(),
|
||||
&doc.right().text,
|
||||
&doc.left().text,
|
||||
));
|
||||
doc.assert_eq_without_cursors(
|
||||
&reconcile(
|
||||
&doc.parent(),
|
||||
&doc.right().text().into(),
|
||||
&doc.left().text().into(),
|
||||
&*BuiltinTokenizer::Word,
|
||||
)
|
||||
.apply()
|
||||
.text(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_document_inverse_way_with_cursors() {
|
||||
for doc in &get_all_documents() {
|
||||
doc.assert_eq(&reconcile_with_cursors(
|
||||
doc.assert_eq(&reconcile(
|
||||
&doc.parent(),
|
||||
doc.right(),
|
||||
doc.left(),
|
||||
&doc.right(),
|
||||
&doc.left(),
|
||||
&*BuiltinTokenizer::Word,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
68
tests/wasm.rs
Normal file
68
tests/wasm.rs
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
#![cfg(feature = "wasm")]
|
||||
|
||||
use reconcile::{BuiltinTokenizer, CursorPosition, TextWithCursors, wasm::*};
|
||||
use wasm_bindgen_test::*;
|
||||
|
||||
#[wasm_bindgen_test(unsupported = test)]
|
||||
fn test_merge() {
|
||||
let left = b"hello ";
|
||||
let right = b"world";
|
||||
let result = generic_reconcile(b"", left, right, BuiltinTokenizer::Word);
|
||||
assert_eq!(result, b"hello world");
|
||||
|
||||
let left = b"\0binary";
|
||||
let right = b"other";
|
||||
let result = generic_reconcile(b"", left, right, BuiltinTokenizer::Word);
|
||||
assert_eq!(result, right);
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test(unsupported = test)]
|
||||
fn test_merge_text() {
|
||||
let left = "hello ";
|
||||
let right = "world";
|
||||
let result = reconcile("", &left.into(), &right.into(), BuiltinTokenizer::Word).text();
|
||||
assert_eq!(result, "hello world");
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test(unsupported = test)]
|
||||
fn test_merge_text_with_cursors() {
|
||||
let result = reconcile(
|
||||
"hi",
|
||||
&TextWithCursors::new("hi world".to_owned(), vec![]),
|
||||
&TextWithCursors::new(
|
||||
"hi".to_owned(),
|
||||
vec![CursorPosition::new(0, 1), CursorPosition::new(1, 2)],
|
||||
),
|
||||
BuiltinTokenizer::Word,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
result,
|
||||
TextWithCursors::new(
|
||||
"hi world".to_owned(),
|
||||
vec![CursorPosition::new(0, 1), CursorPosition::new(1, 2)]
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test(unsupported = test)]
|
||||
fn merge_binary() {
|
||||
let left = [0, 1, 2];
|
||||
let right = [3, 4, 5];
|
||||
assert_eq!(
|
||||
generic_reconcile(b"", &left, &right, BuiltinTokenizer::Word),
|
||||
right
|
||||
);
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test(unsupported = test)]
|
||||
fn test_is_binary() {
|
||||
assert!(is_binary(&[0, 159, 146, 150]));
|
||||
assert!(is_binary(&[0, 12]));
|
||||
assert!(!is_binary(b"hello"));
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test(unsupported = test)]
|
||||
fn test_is_binary_empty() {
|
||||
assert!(!is_binary(b""));
|
||||
}
|
||||
80
tests/web.rs
80
tests/web.rs
|
|
@ -1,80 +0,0 @@
|
|||
#![cfg(feature = "wasm")]
|
||||
|
||||
use reconcile::wasm::{
|
||||
lib::{is_binary, is_file_type_mergable, merge, merge_text, merge_text_with_cursors},
|
||||
types::{JsCursorPosition, JsTextWithCursors},
|
||||
};
|
||||
use wasm_bindgen_test::*;
|
||||
|
||||
#[wasm_bindgen_test(unsupported = test)]
|
||||
fn test_merge() {
|
||||
let left = b"hello ";
|
||||
let right = b"world";
|
||||
let result = merge(b"", left, right);
|
||||
assert_eq!(result, b"hello world");
|
||||
|
||||
let left = b"\0binary";
|
||||
let right = b"other";
|
||||
let result = merge(b"", left, right);
|
||||
assert_eq!(result, right);
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test(unsupported = test)]
|
||||
fn test_merge_text() {
|
||||
let left = "hello ";
|
||||
let right = "world";
|
||||
let result = merge_text("", left, right);
|
||||
assert_eq!(result, "hello world");
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test(unsupported = test)]
|
||||
fn test_merge_text_with_cursors() {
|
||||
let result = merge_text_with_cursors(
|
||||
"hi",
|
||||
JsTextWithCursors::new("hi world".to_owned(), vec![]),
|
||||
JsTextWithCursors::new(
|
||||
"hi".to_owned(),
|
||||
vec![JsCursorPosition::new(0, 1), JsCursorPosition::new(1, 2)],
|
||||
),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
result,
|
||||
JsTextWithCursors::new(
|
||||
"hi world".to_owned(),
|
||||
vec![JsCursorPosition::new(0, 1), JsCursorPosition::new(1, 2)]
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test(unsupported = test)]
|
||||
fn merge_binary() {
|
||||
let left = [0, 1, 2];
|
||||
let right = [3, 4, 5];
|
||||
assert_eq!(merge(b"", &left, &right), right);
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test(unsupported = test)]
|
||||
fn test_is_binary() {
|
||||
assert!(is_binary(&[0, 159, 146, 150]));
|
||||
assert!(is_binary(&[0, 12]));
|
||||
assert!(!is_binary(b"hello"));
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test(unsupported = test)]
|
||||
fn test_is_binary_empty() {
|
||||
assert!(!is_binary(b""));
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test(unsupported = test)]
|
||||
fn test_is_file_type_mergable() {
|
||||
assert!(is_file_type_mergable(".md"));
|
||||
assert!(is_file_type_mergable("hi.md"));
|
||||
assert!(is_file_type_mergable("my/path/to/my/document.md"));
|
||||
assert!(is_file_type_mergable("hi.MD"));
|
||||
assert!(is_file_type_mergable("my/path/to/my/DOCUMENT.MD"));
|
||||
|
||||
assert!(!is_file_type_mergable(".json"));
|
||||
assert!(!is_file_type_mergable("HELLO.JSON"));
|
||||
assert!(!is_file_type_mergable("my/config.yml"));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue