├── .formatter.exs ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── config └── config.exs ├── lib ├── text_delta.ex └── text_delta │ ├── application.ex │ ├── attributes.ex │ ├── backwards_compatibility_with_1.0.ex │ ├── composition.ex │ ├── configurable_string.ex │ ├── difference.ex │ ├── document.ex │ ├── iterator.ex │ ├── operation.ex │ └── transformation.ex ├── mix.exs ├── mix.lock └── test ├── example └── compose.exs ├── support └── generators.exs ├── test_helper.exs ├── text_delta ├── application_test.exs ├── attributes_test.exs ├── backwards_compatibility_with_1.0_test.exs ├── composition_test.exs ├── difference_test.exs ├── document_test.exs ├── iterator_test.exs ├── operation_test.exs └── transformation_test.exs └── text_delta_test.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | inputs: [ 3 | "{lib,test}/**/*.{ex,exs}", 4 | "mix.exs", 5 | ".formatter.exs" 6 | ], 7 | line_length: 80 8 | ] 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # QuickCheck artefacts 23 | .eqc-info 24 | current_counterexample.eqc 25 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | sudo: false 3 | 4 | elixir: 5 | - 1.6 6 | 7 | otp_release: 8 | - 19.3 9 | - 20.1 10 | 11 | cache: 12 | directories: 13 | - $HOME/.mix 14 | 15 | before_script: 16 | - mix eqc.install --mini || true 17 | 18 | script: 19 | - mix format --check-formatted 20 | - mix test 21 | - mix credo --strict 22 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/) 5 | and this project adheres to [Semantic Versioning](http://semver.org/). 6 | 7 | ## [Unreleased] 8 | 9 | ## [1.4.0] - 2018-06-09 10 | ### Added 11 | - Introduced experimental support for disabling unicode support through 12 | `:text_delta.support_unicode` config. This is useful, because disabling 13 | grapheme handling drastically speeds up all string operations (used heavily). 14 | If you implementation does not need support of unicode, using this config 15 | can greatly improve performance of the library. 16 | 17 | ### Fixed 18 | - Small performance optimisations by avoiding unnecessary `String.length` 19 | calls 20 | 21 | ## [1.3.0] - 2017-12-29 22 | ### Added 23 | - `&TextDelta.lines/1` and `&TextDelta.lines!/1` 24 | - `&TextDelta.diff/2` and `&TextDelta.diff!/2` 25 | 26 | ## [1.2.0] - 2017-05-29 27 | ### Added 28 | - `&TextDelta.apply/2` and `&TextDelta.apply!/2` 29 | 30 | ### Changed 31 | - Moved repository under `deltadoc` organisation. 32 | - Text state is now represented with `TextDelta.state` type rather than 33 | `TextDelta.document` throughout the codebase. `TextDelta.document` is still 34 | there in form of an alias for `TextDelta.state`. 35 | 36 | ## [1.1.0] - 2017-05-02 37 | ### Added 38 | - Property-based tests for composition, transformation and compaction 39 | 40 | ### Fixed 41 | - Insert duplication bug during delta compaction 42 | - Delete/Delete misbehaviour bug during composition 43 | 44 | ### Changed 45 | - `TextDelta.Delta` is now just `TextDelta` 46 | - `TextDelta.Delta.*` modules moved into `TextDelta.*` 47 | - `TextDelta` now generates and operates on `%TextDelta{}` struct 48 | - `TextDelta.Delta` is still there and works like before in form of a BC 49 | layer, so your existing code would still work while you upgrade. To be 50 | removed in 2.x 51 | - Slightly improved documentation across modules 52 | 53 | ## [1.0.2] - 2017-03-29 54 | ### Fixed 55 | - Bug when composition of delete with larger retain resulted in broken delta 56 | 57 | ### Removed 58 | - Config 59 | 60 | ## [1.0.1] - 2017-03-23 61 | ### Added 62 | - Test cases for string-keyed maps as attributes 63 | - More context and information to Readme 64 | - Changelog 65 | 66 | ### Changed 67 | - Improved documentation across modules 68 | - Cleaned up code to follow [Elixir Style Guide](https://github.com/christopheradams/elixir_style_guide) 69 | 70 | ## [1.0.0] - 2017-03-18 71 | ### Added 72 | - Delta construction and manipulation logic 73 | - Attributes support in `insert` and `retain` 74 | - Delta composition and transformation with attributes supported 75 | 76 | [Unreleased]: https://github.com/everzet/text_delta/compare/v1.4.0...HEAD 77 | [1.4.0]: https://github.com/everzet/text_delta/compare/v1.3.0...v1.4.0 78 | [1.3.0]: https://github.com/everzet/text_delta/compare/v1.2.0...v1.3.0 79 | [1.2.0]: https://github.com/everzet/text_delta/compare/v1.1.0...v1.2.0 80 | [1.1.0]: https://github.com/everzet/text_delta/compare/v1.0.2...v1.1.0 81 | [1.0.2]: https://github.com/everzet/text_delta/compare/v1.0.1...v1.0.2 82 | [1.0.1]: https://github.com/everzet/text_delta/compare/v1.0.0...v1.0.1 83 | [1.0.0]: https://github.com/everzet/text_delta/compare/cdaf5769ba3abb36aa6a6e2431662164a5a30945...v1.0.0 84 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Konstantin Kudryashov 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TextDelta 2 | 3 | [![Build Status](https://travis-ci.org/deltadoc/text_delta.svg?branch=master)](https://travis-ci.org/deltadoc/text_delta) 4 | 5 | Elixir counter-part for the Quill.js [Delta](https://quilljs.com/docs/delta/) 6 | library. It provides a baseline for [Operational 7 | Transformation](https://en.wikipedia.org/wiki/Operational_transformation) of 8 | rich text. 9 | 10 | Here's Delta pitch from the [Delta.js repository](https://github.com/quilljs/delta): 11 | 12 | > Deltas are a simple, yet expressive format that can be used to describe contents and changes. The format is JSON based, and is human readable, yet easily parsible by machines. Deltas can describe any rich text document, includes all text and formatting information, without the ambiguity and complexity of HTML. 13 | > 14 | > A Delta is made up of an Array of Operations, which describe changes to a document. They can be an insert, delete or retain. Note operations do not take an index. They always describe the change at the current index. Use retains to "keep" or "skip" certain parts of the document. 15 | > 16 | > Don’t be confused by its name Delta—Deltas represents both documents and changes to documents. If you think of Deltas as the instructions from going from one document to another, the way Deltas represent a document is by expressing the instructions starting from an empty document. 17 | 18 | More information on original Delta format can be found 19 | [here](https://quilljs.com/docs/delta/). The best starting point for learning 20 | Operational Transformation is likely [this 21 | article](http://www.codecommit.com/blog/java/understanding-and-applying-operational-transformation). 22 | 23 | ## Acknowledments 24 | 25 | This library is heavily influenced by two other libraries and wouldn't be 26 | possible without them: 27 | 28 | 1. [`quilljs/delta`](https://github.com/quilljs/delta) - original JS library for 29 | rich text OT. Entire public API of `text_delta` is based upod it. 30 | 2. [`jclem/ot_ex`](https://github.com/jclem/ot_ex) - implementation of this 31 | library is heavily influenced by `ot_ex`. Though this library pursues 32 | slightly different design goals than `ot_ex`, it wouldn't be possible without 33 | it. 34 | 35 | ## Differences from `ot_ex` 36 | 37 | If you are searching for a library matching Quill's Delta format on the server 38 | side, this library is pretty much a direct match. If, however, you're looking 39 | for a more general Operational Transformation library, you should 40 | consider both `text_delta` and `ot_ex`. Here are the key differences from 41 | `ot_ex` that might help you make the decision: 42 | 43 | 1. `text_delta` is heavily based on Quill Delta. That includes the public API 44 | and the delta format itself. This results in a more verbose format than 45 | `ot_ex`. 46 | 2. `ot_ex` uses fully reversible operations format, while `text_delta` is a 47 | one-way. If reversibility is a must, `ot_ex` is your only option. 48 | 3. `text_delta` allows arbitrary attributes to be attached to `insert` or 49 | `retain` operations. This allows you to transform rich text alongside plain. 50 | With `ot_ex` you pretty much stuck with plain text format, which might not be 51 | a big deal if your format of choice is something like Markdown. 52 | 53 | ## Installation 54 | 55 | TextDelta can be installed by adding `text_delta` to your list of dependencies 56 | in `mix.exs`: 57 | 58 | ```elixir 59 | def deps do 60 | [{:text_delta, "~> 1.1.0"}] 61 | end 62 | ``` 63 | 64 | ## Documentation 65 | 66 | Documentation can be found at [https://hexdocs.pm/text_delta](https://hexdocs.pm/text_delta). 67 | 68 | ## Testing & Linting 69 | 70 | This library is test-driven. In order to run tests, execute: 71 | 72 | ```bash 73 | $> mix test 74 | ``` 75 | 76 | If this command fails, it is most likely due to that you don't have 77 | [QuickCheck](http://www.quviq.com/downloads/) installed. If so, simply try: 78 | 79 | ```bash 80 | $> mix eqc.install --mini 81 | ``` 82 | 83 | TextDelta uses property tests to validate that composition, transformation and 84 | compaction work as expected. 85 | 86 | The library also uses [Credo](http://credo-ci.org) and 87 | [Dialyzer](http://erlang.org/doc/man/dialyzer.html). To run both, execute: 88 | 89 | ```bash 90 | $> mix lint 91 | ``` 92 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :text_delta, 4 | support_unicode: true 5 | -------------------------------------------------------------------------------- /lib/text_delta.ex: -------------------------------------------------------------------------------- 1 | defmodule TextDelta do 2 | @moduledoc """ 3 | Delta is a format used to describe text states and changes. 4 | 5 | Delta can describe any rich text changes or a rich text itself, preserving all 6 | the formatting. 7 | 8 | At the baseline level, delta is an array of operations (constructed via 9 | `TextDelta.Operation`). Operations can be either 10 | `t:TextDelta.Operation.insert/0`, `t:TextDelta.Operation.retain/0` or 11 | `t:TextDelta.Operation.delete/0`. None of the operations contain index, 12 | meaning that delta aways describes text or a change staring from the very 13 | beginning. 14 | 15 | Delta can describe both changes to and text states themselves. We can think of 16 | a document as an artefact of all the changes applied to it. This way, newly 17 | imported documents can be thinked of as a sequence of `insert`s applied to an 18 | empty text. 19 | 20 | Deltas are composable. This means that a text delta can be composed with 21 | another delta for that text, resulting in a shorter, optimized version. 22 | 23 | Deltas are also transformable. This attribute of deltas is what enables 24 | [Operational Transformation][ot] - a way to transform one operation against 25 | the context of another one. Operational Transformation allows us to build 26 | optimistic, non-locking collaborative editors. 27 | 28 | The format for deltas was deliberately copied from [Quill][quill] - a rich 29 | text editor for web. This library aims to be an Elixir counter-part for Quill, 30 | enabling us to build matching backends for the editor. 31 | 32 | ## Example 33 | 34 | iex> delta = TextDelta.insert(TextDelta.new(), "Gandalf", %{bold: true}) 35 | %TextDelta{ops: [ 36 | %{insert: "Gandalf", attributes: %{bold: true}}]} 37 | iex> delta = TextDelta.insert(delta, " the ") 38 | %TextDelta{ops: [ 39 | %{insert: "Gandalf", attributes: %{bold: true}}, 40 | %{insert: " the "}]} 41 | iex> TextDelta.insert(delta, "Grey", %{color: "#ccc"}) 42 | %TextDelta{ops: [ 43 | %{insert: "Gandalf", attributes: %{bold: true}}, 44 | %{insert: " the "}, 45 | %{insert: "Grey", attributes: %{color: "#ccc"}}]} 46 | 47 | [ot]: https://en.wikipedia.org/wiki/Operational_transformation 48 | [quill]: https://quilljs.com 49 | """ 50 | 51 | alias TextDelta.{ 52 | Operation, 53 | Attributes, 54 | Composition, 55 | Transformation, 56 | Application, 57 | Document, 58 | Difference 59 | } 60 | 61 | defstruct ops: [] 62 | 63 | @typedoc """ 64 | Delta is a set of `t:TextDelta.Operation.retain/0`, 65 | `t:TextDelta.Operation.insert/0`, or `t:TextDelta.Operation.delete/0` 66 | operations. 67 | """ 68 | @type t :: %TextDelta{ops: [Operation.t()]} 69 | 70 | @typedoc """ 71 | A text state represented as delta. Any text state can be represented as a set 72 | of `t:TextDelta.Operation.insert/0` operations. 73 | """ 74 | @type state :: %TextDelta{ops: [Operation.insert()]} 75 | 76 | @typedoc """ 77 | Alias to `t:TextDelta.state/0`. 78 | """ 79 | @type document :: state 80 | 81 | @doc """ 82 | Creates new delta. 83 | 84 | ## Examples 85 | 86 | iex> TextDelta.new() 87 | %TextDelta{ops: []} 88 | 89 | You can also pass set of operations using optional argument: 90 | 91 | iex> TextDelta.new([TextDelta.Operation.insert("hello")]) 92 | %TextDelta{ops: [%{insert: "hello"}]} 93 | """ 94 | @spec new([Operation.t()]) :: t 95 | def new(ops \\ []) 96 | def new([]), do: %TextDelta{} 97 | def new(ops), do: Enum.reduce(ops, new(), &append(&2, &1)) 98 | 99 | @doc """ 100 | Creates and appends new `insert` operation to the delta. 101 | 102 | Same as with underlying `TextDelta.Operation.insert/2` function, attributes 103 | are optional. 104 | 105 | `TextDelta.append/2` is used undert the hood to add operation to the delta 106 | after construction. So all `append` rules apply. 107 | 108 | ## Example 109 | 110 | iex> TextDelta.insert(TextDelta.new(), "hello", %{bold: true}) 111 | %TextDelta{ops: [%{insert: "hello", attributes: %{bold: true}}]} 112 | """ 113 | @spec insert(t, Operation.element(), Attributes.t()) :: t 114 | def insert(delta, el, attrs \\ %{}) do 115 | append(delta, Operation.insert(el, attrs)) 116 | end 117 | 118 | @doc """ 119 | Creates and appends new `retain` operation to the delta. 120 | 121 | Same as with underlying `TextDelta.Operation.retain/2` function, attributes 122 | are optional. 123 | 124 | `TextDelta.append/2` is used undert the hood to add operation to the delta 125 | after construction. So all `append` rules apply. 126 | 127 | ## Example 128 | 129 | iex> TextDelta.retain(TextDelta.new(), 5, %{italic: true}) 130 | %TextDelta{ops: [%{retain: 5, attributes: %{italic: true}}]} 131 | """ 132 | @spec retain(t, non_neg_integer, Attributes.t()) :: t 133 | def retain(delta, len, attrs \\ %{}) do 134 | append(delta, Operation.retain(len, attrs)) 135 | end 136 | 137 | @doc """ 138 | Creates and appends new `delete` operation to the delta. 139 | 140 | `TextDelta.append/2` is used undert the hood to add operation to the delta 141 | after construction. So all `append` rules apply. 142 | 143 | ## Example 144 | 145 | iex> TextDelta.delete(TextDelta.new(), 3) 146 | %TextDelta{ops: [%{delete: 3}]} 147 | """ 148 | @spec delete(t, non_neg_integer) :: t 149 | def delete(delta, len) do 150 | append(delta, Operation.delete(len)) 151 | end 152 | 153 | @doc """ 154 | Appends given operation to the delta. 155 | 156 | Before adding operation to the delta, this function attempts to compact it by 157 | applying 2 simple rules: 158 | 159 | 1. Delete followed by insert is swapped to ensure that insert goes first. 160 | 2. Same operations with the same attributes are merged. 161 | 162 | These two rules ensure that our deltas are always as short as possible and 163 | canonical, making it easier to compare, compose and transform them. 164 | 165 | ## Example 166 | 167 | iex> operation = TextDelta.Operation.insert("hello") 168 | iex> TextDelta.append(TextDelta.new(), operation) 169 | %TextDelta{ops: [%{insert: "hello"}]} 170 | """ 171 | @spec append(t, Operation.t()) :: t 172 | def append(delta, op) do 173 | delta.ops 174 | |> Enum.reverse() 175 | |> compact(op) 176 | |> Enum.reverse() 177 | |> wrap() 178 | end 179 | 180 | defdelegate compose(first, second), to: Composition 181 | defdelegate transform(left, right, priority), to: Transformation 182 | defdelegate apply(state, delta), to: Application 183 | defdelegate apply!(state, delta), to: Application 184 | defdelegate lines(delta), to: Document 185 | defdelegate lines!(delta), to: Document 186 | defdelegate diff(first, second), to: Difference 187 | defdelegate diff!(first, second), to: Difference 188 | 189 | @doc """ 190 | Trims trailing retains from the end of a given delta. 191 | 192 | ## Example 193 | 194 | iex> TextDelta.trim(TextDelta.new([%{insert: "hello"}, %{retain: 5}])) 195 | %TextDelta{ops: [%{insert: "hello"}]} 196 | """ 197 | @spec trim(t) :: t 198 | def trim(delta) 199 | def trim(%TextDelta{ops: []} = empty), do: empty 200 | 201 | def trim(delta) do 202 | last_operation = List.last(delta.ops) 203 | 204 | case Operation.trimmable?(last_operation) do 205 | true -> 206 | delta.ops 207 | |> Enum.slice(0..-2) 208 | |> wrap() 209 | |> trim() 210 | 211 | false -> 212 | delta 213 | end 214 | end 215 | 216 | @doc """ 217 | Calculates the length of a given delta. 218 | 219 | Length of delta is a sum of its operations length. 220 | 221 | ## Example 222 | 223 | iex> TextDelta.length(TextDelta.new([%{insert: "hello"}, %{retain: 5}])) 224 | 10 225 | 226 | The function also allows to select which types of operations we include in the 227 | summary with optional second argument: 228 | 229 | iex> TextDelta.length(TextDelta.new([%{insert: "hi"}]), [:retain]) 230 | 0 231 | """ 232 | @spec length(t, [Operation.type()]) :: non_neg_integer 233 | def length(delta, op_types \\ [:insert, :retain, :delete]) do 234 | delta.ops 235 | |> Enum.filter(&(Operation.type(&1) in op_types)) 236 | |> Enum.map(&Operation.length/1) 237 | |> Enum.sum() 238 | end 239 | 240 | @doc """ 241 | Returns set of operations for a given delta. 242 | 243 | ## Example 244 | 245 | iex> TextDelta.operations(TextDelta.new([%{delete: 5}, %{retain: 3}])) 246 | [%{delete: 5}, %{retain: 3}] 247 | """ 248 | @spec operations(t) :: [Operation.t()] 249 | def operations(delta), do: delta.ops 250 | 251 | defp compact(ops, %{insert: ""}), do: ops 252 | defp compact(ops, %{retain: 0}), do: ops 253 | defp compact(ops, %{delete: 0}), do: ops 254 | defp compact(ops, []), do: ops 255 | defp compact(ops, nil), do: ops 256 | defp compact([], new_op), do: [new_op] 257 | 258 | defp compact([%{delete: _} = del | ops_remainder], %{insert: _} = ins) do 259 | ops_remainder 260 | |> compact(ins) 261 | |> compact(del) 262 | end 263 | 264 | defp compact([last_op | ops_remainder], new_op) do 265 | last_op 266 | |> Operation.compact(new_op) 267 | |> Enum.reverse() 268 | |> Kernel.++(ops_remainder) 269 | end 270 | 271 | defp wrap(ops), do: %TextDelta{ops: ops} 272 | end 273 | -------------------------------------------------------------------------------- /lib/text_delta/application.ex: -------------------------------------------------------------------------------- 1 | defmodule TextDelta.Application do 2 | @moduledoc """ 3 | The application of a delta onto a text state. 4 | 5 | Text state is always represented as a set of `t:TextDelta.Operation.insert/0` 6 | operations. This means that any application should always result in a set of 7 | `insert` operations or produce an error tuple. 8 | 9 | In simpler terms this means that it is not possible to apply delta, which 10 | combined length of `retain` and `delete` operations is longer than the length 11 | of original text. This situation will always result in `:length_mismatch` 12 | error. 13 | """ 14 | 15 | @typedoc """ 16 | Reason for an application error. 17 | """ 18 | @type error_reason :: :length_mismatch 19 | 20 | @typedoc """ 21 | Result of an application. 22 | 23 | An ok/error tuple. Represents either a successful application in form of 24 | `{:ok, new_state}` or an error in form of `{:error, reason}`. 25 | """ 26 | @type result :: 27 | {:ok, TextDelta.state()} 28 | | {:error, error_reason} 29 | 30 | @doc """ 31 | Applies given delta to a particular text state, resulting in a new state. 32 | 33 | Text state is a set of `t:TextDelta.Operation.insert/0` operations. If 34 | applying delta results in anything but a set of `insert` operations, `:error` 35 | tuple is returned instead. 36 | 37 | ## Examples 38 | 39 | successful application: 40 | 41 | iex> doc = TextDelta.insert(TextDelta.new(), "hi") 42 | %TextDelta{ops: [%{insert: "hi"}]} 43 | iex> TextDelta.apply(doc, TextDelta.insert(TextDelta.new(), "oh, ")) 44 | {:ok, %TextDelta{ops: [%{insert: "oh, hi"}]}} 45 | 46 | error handling: 47 | 48 | iex> doc = TextDelta.insert(TextDelta.new(), "hi") 49 | %TextDelta{ops: [%{insert: "hi"}]} 50 | iex> TextDelta.apply(doc, TextDelta.delete(TextDelta.new(), 5)) 51 | {:error, :length_mismatch} 52 | """ 53 | @spec apply(TextDelta.state(), TextDelta.t()) :: result 54 | def apply(state, delta) do 55 | case delta_within_text_length?(delta, state) do 56 | true -> 57 | {:ok, TextDelta.compose(state, delta)} 58 | 59 | false -> 60 | {:error, :length_mismatch} 61 | end 62 | end 63 | 64 | @doc """ 65 | Applies given delta to a particular text state, resulting in a new state. 66 | 67 | Equivalent to `&TextDelta.Application.apply/2`, but instead of returning 68 | ok/error tuples returns a new state or raises a `RuntimeError`. 69 | """ 70 | @spec apply!(TextDelta.state(), TextDelta.t()) :: 71 | TextDelta.state() | no_return 72 | def apply!(state, delta) do 73 | case __MODULE__.apply(state, delta) do 74 | {:ok, new_state} -> 75 | new_state 76 | 77 | {:error, reason} -> 78 | raise "Can not apply delta to state: #{Atom.to_string(reason)}" 79 | end 80 | end 81 | 82 | defp delta_within_text_length?(delta, state) do 83 | TextDelta.length(state) >= TextDelta.length(delta, [:retain, :delete]) 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/text_delta/attributes.ex: -------------------------------------------------------------------------------- 1 | defmodule TextDelta.Attributes do 2 | @moduledoc """ 3 | Attributes represent format associated with `t:TextDelta.Operation.insert/0` 4 | or `t:TextDelta.Operation.retain/0` operations. This library uses maps to 5 | represent attributes. 6 | 7 | Same as `TextDelta`, attributes are composable and transformable. This library 8 | does not make any assumptions about attribute types, values or composition. 9 | """ 10 | 11 | @typedoc """ 12 | A set of attributes applicable to an operation. 13 | """ 14 | @type t :: map 15 | 16 | @typedoc """ 17 | Atom representing transformation priority. Should we prioritise left or right 18 | side? 19 | """ 20 | @type priority :: :left | :right 21 | 22 | @doc """ 23 | Composes two sets of attributes into one. 24 | 25 | Simplest way to think about composing arguments is two maps being merged (in 26 | fact, that's exactly how it is implemented at the moment). 27 | 28 | The only thing that makes it different from standard map merge is an optional 29 | `keep_nils` flag. This flag controls if we want to cleanup all the `null` 30 | attributes before returning. 31 | 32 | This function is used by `TextDelta.compose/2`. 33 | 34 | ## Examples 35 | 36 | iex> TextDelta.Attributes.compose(%{color: "blue"}, %{italic: true}) 37 | %{color: "blue", italic: true} 38 | 39 | iex> TextDelta.Attributes.compose(%{bold: true}, %{bold: nil}, true) 40 | %{bold: nil} 41 | 42 | iex> TextDelta.Attributes.compose(%{bold: true}, %{bold: nil}, false) 43 | %{} 44 | """ 45 | @spec compose(t, t, boolean) :: t 46 | def compose(first, second, keep_nils \\ false) 47 | 48 | def compose(nil, second, keep_nils) do 49 | compose(%{}, second, keep_nils) 50 | end 51 | 52 | def compose(first, nil, keep_nils) do 53 | compose(first, %{}, keep_nils) 54 | end 55 | 56 | def compose(first, second, true) do 57 | Map.merge(first, second) 58 | end 59 | 60 | def compose(first, second, false) do 61 | first 62 | |> Map.merge(second) 63 | |> remove_nils() 64 | end 65 | 66 | @doc """ 67 | Calculates and returns difference between two sets of attributes. 68 | 69 | Given an initial set of attributes and the final one, this function will 70 | generate an attribute set that is when composed with original one would yield 71 | the final result. 72 | 73 | ## Examples 74 | 75 | iex> TextDelta.Attributes.diff(%{font: "arial", color: "blue"}, 76 | iex> %{color: "red"}) 77 | %{font: nil, color: "red"} 78 | """ 79 | @spec diff(t, t) :: t 80 | def diff(attrs_a, attrs_b) 81 | 82 | def diff(nil, attrs_b), do: diff(%{}, attrs_b) 83 | def diff(attrs_a, nil), do: diff(attrs_a, %{}) 84 | 85 | def diff(attrs_a, attrs_b) do 86 | %{} 87 | |> add_changes(attrs_a, attrs_b) 88 | |> add_deletions(attrs_a, attrs_b) 89 | end 90 | 91 | @doc """ 92 | Transforms `right` attribute set against the `left` one. 93 | 94 | The function also takes a third `t:TextDelta.Attributes.priority/0` 95 | argument that indicates which set came first. 96 | 97 | This function is used by `TextDelta.transform/3`. 98 | 99 | ## Example 100 | 101 | iex> TextDelta.Attributes.transform(%{italic: true}, 102 | iex> %{bold: true}, :left) 103 | %{bold: true} 104 | """ 105 | @spec transform(t, t, priority) :: t 106 | def transform(left, right, priority) 107 | 108 | def transform(nil, right, priority) do 109 | transform(%{}, right, priority) 110 | end 111 | 112 | def transform(left, nil, priority) do 113 | transform(left, %{}, priority) 114 | end 115 | 116 | def transform(_, right, :right) do 117 | right 118 | end 119 | 120 | def transform(left, right, :left) do 121 | remove_duplicates(right, left) 122 | end 123 | 124 | defp add_changes(result, from, to) do 125 | to 126 | |> Enum.filter(fn {key, val} -> Map.get(from, key) != val end) 127 | |> Enum.into(%{}) 128 | |> Map.merge(result) 129 | end 130 | 131 | defp add_deletions(result, from, to) do 132 | from 133 | |> Enum.filter(fn {key, _} -> not Map.has_key?(to, key) end) 134 | |> Enum.map(fn {key, _} -> {key, nil} end) 135 | |> Enum.into(%{}) 136 | |> Map.merge(result) 137 | end 138 | 139 | defp remove_nils(result) do 140 | result 141 | |> Enum.filter(fn {_, v} -> not is_nil(v) end) 142 | |> Enum.into(%{}) 143 | end 144 | 145 | defp remove_duplicates(attrs_a, attrs_b) do 146 | attrs_a 147 | |> Enum.filter(fn {key, _} -> not Map.has_key?(attrs_b, key) end) 148 | |> Enum.into(%{}) 149 | end 150 | end 151 | -------------------------------------------------------------------------------- /lib/text_delta/backwards_compatibility_with_1.0.ex: -------------------------------------------------------------------------------- 1 | # All modules here are only for bacwards compatibility and will be removed in 2 | # next major bump - 2.0. Please upgrade to the new public API ASAP. 3 | 4 | defmodule TextDelta.Delta do 5 | # Deprecated and to be removed in 2.0 6 | @moduledoc false 7 | 8 | alias TextDelta.Delta.{Transformation, Composition} 9 | 10 | @doc false 11 | def new(ops \\ []) do 12 | ops 13 | |> TextDelta.new() 14 | |> unwrap() 15 | end 16 | 17 | @doc false 18 | def insert(delta, el, attrs \\ %{}) do 19 | delta 20 | |> wrap() 21 | |> TextDelta.insert(el, attrs) 22 | |> unwrap() 23 | end 24 | 25 | @doc false 26 | def retain(delta, len, attrs \\ %{}) do 27 | delta 28 | |> wrap() 29 | |> TextDelta.retain(len, attrs) 30 | |> unwrap() 31 | end 32 | 33 | @doc false 34 | def delete(delta, len) do 35 | delta 36 | |> wrap() 37 | |> TextDelta.delete(len) 38 | |> unwrap() 39 | end 40 | 41 | @doc false 42 | def append(nil, op), do: append(new(), op) 43 | 44 | def append(delta, op) do 45 | delta 46 | |> wrap() 47 | |> TextDelta.append(op) 48 | |> unwrap() 49 | end 50 | 51 | defdelegate compose(delta_a, delta_b), to: Composition 52 | defdelegate transform(delta_a, delta_b, priority), to: Transformation 53 | 54 | @doc false 55 | def trim(delta) do 56 | delta 57 | |> wrap() 58 | |> TextDelta.trim() 59 | |> unwrap() 60 | end 61 | 62 | @doc false 63 | def length(delta, included_ops \\ [:insert, :retain, :delete]) do 64 | delta 65 | |> wrap() 66 | |> TextDelta.length(included_ops) 67 | end 68 | 69 | @doc false 70 | def wrap(ops), do: TextDelta.new(ops) 71 | 72 | @doc false 73 | def unwrap(delta), do: TextDelta.operations(delta) 74 | end 75 | 76 | defmodule TextDelta.Delta.Composition do 77 | # Deprecated and to be removed in 2.0 78 | @moduledoc false 79 | 80 | alias TextDelta.Delta 81 | 82 | @doc false 83 | def compose(delta_a, delta_b) do 84 | delta_a 85 | |> Delta.wrap() 86 | |> TextDelta.compose(Delta.wrap(delta_b)) 87 | |> Delta.unwrap() 88 | end 89 | end 90 | 91 | defmodule TextDelta.Delta.Transformation do 92 | # Deprecated and to be removed in 2.0 93 | @moduledoc false 94 | 95 | alias TextDelta.Delta 96 | 97 | @doc false 98 | def transform(delta_a, delta_b, priority) do 99 | delta_a 100 | |> Delta.wrap() 101 | |> TextDelta.transform(Delta.wrap(delta_b), priority) 102 | |> Delta.unwrap() 103 | end 104 | end 105 | 106 | defmodule TextDelta.Delta.Iterator do 107 | # Deprecated and to be removed in 2.0 108 | @moduledoc false 109 | 110 | defdelegate next(deltas, skip_type \\ nil), to: TextDelta.Iterator 111 | end 112 | -------------------------------------------------------------------------------- /lib/text_delta/composition.ex: -------------------------------------------------------------------------------- 1 | defmodule TextDelta.Composition do 2 | @moduledoc """ 3 | The composition of two non-concurrent deltas into a single delta. 4 | 5 | The deltas are composed in such a way that the resulting delta has the same 6 | effect on text state as applying one delta and then the other: 7 | 8 | S ○ compose(Oa, Ob) = S ○ Oa ○ Ob 9 | 10 | In more simple terms, composition allows you to take many deltas and transform 11 | them into one of equal effect. When used together with Operational 12 | Transformation that allows to reduce system overhead when tracking non-synced 13 | changes. 14 | """ 15 | 16 | alias TextDelta.{Operation, Attributes, Iterator} 17 | 18 | @doc """ 19 | Composes two deltas into a single equivalent delta. 20 | 21 | ## Example 22 | 23 | iex> foo = TextDelta.insert(TextDelta.new(), "Foo") 24 | %TextDelta{ops: [%{insert: "Foo"}]} 25 | iex> bar = TextDelta.insert(TextDelta.new(), "Bar") 26 | %TextDelta{ops: [%{insert: "Bar"}]} 27 | iex> TextDelta.compose(bar, foo) 28 | %TextDelta{ops: [%{insert: "FooBar"}]} 29 | """ 30 | @spec compose(TextDelta.t(), TextDelta.t()) :: TextDelta.t() 31 | def compose(first, second) do 32 | {TextDelta.operations(first), TextDelta.operations(second)} 33 | |> iterate() 34 | |> do_compose(TextDelta.new()) 35 | |> TextDelta.trim() 36 | end 37 | 38 | defp do_compose({{nil, _}, {nil, _}}, result) do 39 | result 40 | end 41 | 42 | defp do_compose({{nil, _}, {op_b, remainder_b}}, result) do 43 | List.foldl([op_b | remainder_b], result, &TextDelta.append(&2, &1)) 44 | end 45 | 46 | defp do_compose({{op_a, remainder_a}, {nil, _}}, result) do 47 | List.foldl([op_a | remainder_a], result, &TextDelta.append(&2, &1)) 48 | end 49 | 50 | defp do_compose( 51 | {{%{insert: _} = ins_a, remainder_a}, 52 | {%{insert: _} = ins_b, remainder_b}}, 53 | result 54 | ) do 55 | {[ins_a | remainder_a], remainder_b} 56 | |> iterate() 57 | |> do_compose(TextDelta.append(result, ins_b)) 58 | end 59 | 60 | defp do_compose( 61 | {{%{insert: el_a} = ins, remainder_a}, 62 | {%{retain: _} = ret, remainder_b}}, 63 | result 64 | ) do 65 | insert = Operation.insert(el_a, compose_attributes(ins, ret)) 66 | 67 | {remainder_a, remainder_b} 68 | |> iterate() 69 | |> do_compose(TextDelta.append(result, insert)) 70 | end 71 | 72 | defp do_compose( 73 | {{%{insert: _}, remainder_a}, {%{delete: _}, remainder_b}}, 74 | result 75 | ) do 76 | {remainder_a, remainder_b} 77 | |> iterate() 78 | |> do_compose(result) 79 | end 80 | 81 | defp do_compose( 82 | {{%{delete: _} = del, remainder_a}, {%{insert: _} = ins, remainder_b}}, 83 | result 84 | ) do 85 | {[del | remainder_a], remainder_b} 86 | |> iterate() 87 | |> do_compose(TextDelta.append(result, ins)) 88 | end 89 | 90 | defp do_compose( 91 | {{%{delete: _} = del, remainder_a}, {%{retain: _} = ret, remainder_b}}, 92 | result 93 | ) do 94 | {remainder_a, [ret | remainder_b]} 95 | |> iterate() 96 | |> do_compose(TextDelta.append(result, del)) 97 | end 98 | 99 | defp do_compose( 100 | {{%{delete: _} = del_a, remainder_a}, 101 | {%{delete: _} = del_b, remainder_b}}, 102 | result 103 | ) do 104 | {remainder_a, [del_b | remainder_b]} 105 | |> iterate() 106 | |> do_compose(TextDelta.append(result, del_a)) 107 | end 108 | 109 | defp do_compose( 110 | {{%{retain: _} = ret, remainder_a}, {%{insert: _} = ins, remainder_b}}, 111 | result 112 | ) do 113 | {[ret | remainder_a], remainder_b} 114 | |> iterate() 115 | |> do_compose(TextDelta.append(result, ins)) 116 | end 117 | 118 | defp do_compose( 119 | {{%{retain: len} = ret_a, remainder_a}, 120 | {%{retain: _} = ret_b, remainder_b}}, 121 | result 122 | ) do 123 | retain = Operation.retain(len, compose_attributes(ret_a, ret_b, true)) 124 | 125 | {remainder_a, remainder_b} 126 | |> iterate() 127 | |> do_compose(TextDelta.append(result, retain)) 128 | end 129 | 130 | defp do_compose( 131 | {{%{retain: _}, remainder_a}, {%{delete: _} = del, remainder_b}}, 132 | result 133 | ) do 134 | {remainder_a, remainder_b} 135 | |> iterate() 136 | |> do_compose(TextDelta.append(result, del)) 137 | end 138 | 139 | defp iterate(stream), do: Iterator.next(stream, :delete) 140 | 141 | defp compose_attributes(op_a, op_b, keep_nil \\ false) do 142 | attrs_a = Map.get(op_a, :attributes) 143 | attrs_b = Map.get(op_b, :attributes) 144 | Attributes.compose(attrs_a, attrs_b, keep_nil) 145 | end 146 | end 147 | -------------------------------------------------------------------------------- /lib/text_delta/configurable_string.ex: -------------------------------------------------------------------------------- 1 | defmodule TextDelta.ConfigurableString do 2 | @moduledoc """ 3 | String configurable to support unicode or not. 4 | """ 5 | 6 | @doc "Calculates the length of a given string" 7 | @spec length(String.t()) :: non_neg_integer 8 | def length(str) do 9 | if Application.get_env(:text_delta, :support_unicode, true) do 10 | String.length(str) 11 | else 12 | byte_size(str) 13 | end 14 | end 15 | 16 | @doc "Splits given string at the index" 17 | @spec split_at(String.t(), non_neg_integer) :: {String.t(), String.t()} 18 | def split_at(str, idx) do 19 | if Application.get_env(:text_delta, :support_unicode, true) do 20 | String.split_at(str, idx) 21 | else 22 | {binary_part(str, 0, idx), binary_part(str, idx, byte_size(str) - idx)} 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/text_delta/difference.ex: -------------------------------------------------------------------------------- 1 | defmodule TextDelta.Difference do 2 | @moduledoc """ 3 | Document diffing. 4 | 5 | Given valid document states A and B, generate a delta that when applied to A 6 | will result in B. 7 | """ 8 | 9 | alias TextDelta.{Operation, Attributes, ConfigurableString} 10 | 11 | @typedoc """ 12 | Reason for an error. 13 | """ 14 | @type error_reason :: :bad_document 15 | 16 | @typedoc """ 17 | Result of getting a diff. 18 | 19 | An ok/error tuple. Represents either a successful diffing in form of 20 | `{:ok, delta}` or an error in form of `{:error, reason}`. 21 | """ 22 | @type result :: 23 | {:ok, TextDelta.t()} 24 | | {:error, error_reason} 25 | 26 | @doc """ 27 | Calculates a difference between two documents in form of new delta. 28 | 29 | ## Examples 30 | 31 | successful application: 32 | 33 | iex> doc_a = 34 | iex> TextDelta.new() 35 | iex> |> TextDelta.insert("hello") 36 | iex> doc_b = 37 | iex> TextDelta.new() 38 | iex> |> TextDelta.insert("goodbye") 39 | iex> TextDelta.diff(doc_a, doc_b) 40 | {:ok, %TextDelta{ops: [ 41 | %{insert: "g"}, 42 | %{delete: 4}, 43 | %{retain: 1}, 44 | %{insert: "odbye"}]}} 45 | 46 | error handling: 47 | 48 | iex> doc = TextDelta.retain(TextDelta.new(), 3) 49 | iex> TextDelta.diff(doc, doc) 50 | {:error, :bad_document} 51 | """ 52 | @spec diff(TextDelta.state(), TextDelta.state()) :: result 53 | def diff(first, second) do 54 | case valid_document?(first) and valid_document?(second) do 55 | true -> 56 | fst_ops = TextDelta.operations(first) 57 | snd_ops = TextDelta.operations(second) 58 | 59 | result = 60 | fst_ops 61 | |> string_from_ops() 62 | |> String.myers_difference(string_from_ops(snd_ops)) 63 | |> mdiff_to_delta(fst_ops, snd_ops, TextDelta.new()) 64 | |> TextDelta.trim() 65 | 66 | {:ok, result} 67 | 68 | false -> 69 | {:error, :bad_document} 70 | end 71 | end 72 | 73 | @doc """ 74 | Calculates a difference between two documents in form of new delta. 75 | 76 | Equivalent to `&TextDelta.Difference.diff/2`, but instead of returning 77 | ok/error tuples raises a `RuntimeError`. 78 | """ 79 | @spec diff!(TextDelta.state(), TextDelta.state()) :: TextDelta.t() | no_return 80 | def diff!(first, second) do 81 | case diff(first, second) do 82 | {:ok, delta} -> 83 | delta 84 | 85 | {:error, reason} -> 86 | raise "Can not diff documents: #{Atom.to_string(reason)}" 87 | end 88 | end 89 | 90 | defp string_from_ops(ops) do 91 | ops 92 | |> Enum.map(&string_from_op/1) 93 | |> Enum.join() 94 | end 95 | 96 | defp string_from_op(%{insert: str}) when is_bitstring(str), do: str 97 | defp string_from_op(%{insert: _}), do: List.to_string([0]) 98 | 99 | defp mdiff_to_delta([], _, _, delta), do: delta 100 | 101 | defp mdiff_to_delta([{_, ""} | rest], fst, snd, delta) do 102 | mdiff_to_delta(rest, fst, snd, delta) 103 | end 104 | 105 | defp mdiff_to_delta([{type, str} | rest], fst, snd, delta) do 106 | str_len = ConfigurableString.length(str) 107 | 108 | case type do 109 | :ins -> 110 | {op, new_snd} = next_op_no_longer_than(snd, str_len) 111 | op_len = Operation.length(op) 112 | {_, substr} = ConfigurableString.split_at(str, op_len) 113 | new_delta = TextDelta.append(delta, op) 114 | mdiff_to_delta([{:ins, substr} | rest], fst, new_snd, new_delta) 115 | 116 | :del -> 117 | {op, new_fst} = next_op_no_longer_than(fst, str_len) 118 | op_len = Operation.length(op) 119 | {_, substr} = ConfigurableString.split_at(str, op_len) 120 | new_delta = TextDelta.append(delta, Operation.delete(op_len)) 121 | mdiff_to_delta([{:del, substr} | rest], new_fst, snd, new_delta) 122 | 123 | :eq -> 124 | {{op1, new_fst}, {op2, new_snd}} = 125 | next_op_no_longer_than(fst, snd, str_len) 126 | 127 | op_len = Operation.length(op1) 128 | {_, substr} = ConfigurableString.split_at(str, op_len) 129 | 130 | if op1.insert == op2.insert do 131 | attrs = 132 | op1 133 | |> Map.get(:attributes, %{}) 134 | |> Attributes.diff(Map.get(op2, :attributes, %{})) 135 | 136 | new_delta = TextDelta.retain(delta, op_len, attrs) 137 | mdiff_to_delta([{:eq, substr} | rest], new_fst, new_snd, new_delta) 138 | else 139 | new_delta = 140 | delta 141 | |> TextDelta.append(op2) 142 | |> TextDelta.append(Operation.delete(op_len)) 143 | 144 | mdiff_to_delta([{:eq, substr} | rest], new_fst, new_snd, new_delta) 145 | end 146 | end 147 | end 148 | 149 | defp next_op_no_longer_than([op | rest], max_len) do 150 | op_len = Operation.length(op) 151 | 152 | if op_len <= max_len do 153 | {op, rest} 154 | else 155 | {op1, op2} = Operation.slice(op, max_len) 156 | {op1, [op2 | rest]} 157 | end 158 | end 159 | 160 | defp next_op_no_longer_than([op1 | rest1], [op2 | rest2], max_len) do 161 | len = Enum.min([Operation.length(op1), Operation.length(op2), max_len]) 162 | 163 | {next_op_no_longer_than([op1 | rest1], len), 164 | next_op_no_longer_than([op2 | rest2], len)} 165 | end 166 | 167 | defp valid_document?(document) do 168 | TextDelta.length(document) == TextDelta.length(document, [:insert]) 169 | end 170 | end 171 | -------------------------------------------------------------------------------- /lib/text_delta/document.ex: -------------------------------------------------------------------------------- 1 | defmodule TextDelta.Document do 2 | @moduledoc """ 3 | Document-related logic like splitting it into lines etc. 4 | """ 5 | 6 | alias TextDelta.Operation 7 | 8 | @typedoc """ 9 | Reason for an error. 10 | """ 11 | @type error_reason :: :bad_document 12 | 13 | @typedoc """ 14 | Line segments. 15 | 16 | Each line has a delta of the content on that line (minus `\n`) and a set of 17 | attributes applied to the entire block. 18 | """ 19 | @type line_segments :: [{TextDelta.state(), TextDelta.Attributes.t()}] 20 | 21 | @typedoc """ 22 | Result of getting document lines. 23 | 24 | An ok/error tuple. Represents either a successful retrieval in form of 25 | `{:ok, [line]}` or an error in form of `{:error, reason}`. 26 | """ 27 | @type lines_result :: 28 | {:ok, line_segments} 29 | | {:error, error_reason} 30 | 31 | @doc """ 32 | Breaks document into multiple line segments. 33 | 34 | Given document will be split according to newline characters (`\n`). 35 | 36 | ## Examples 37 | 38 | successful application: 39 | 40 | iex> doc = 41 | iex> TextDelta.new() 42 | iex> |> TextDelta.insert("hi\\nworld") 43 | iex> |> TextDelta.insert("\\n", %{header: 1}) 44 | iex> TextDelta.lines(doc) 45 | {:ok, [ {%TextDelta{ops: [%{insert: "hi"}]}, %{}}, 46 | {%TextDelta{ops: [%{insert: "world"}]}, %{header: 1}} ]} 47 | 48 | error handling: 49 | 50 | iex> doc = TextDelta.retain(TextDelta.new(), 3) 51 | iex> TextDelta.lines(doc) 52 | {:error, :bad_document} 53 | """ 54 | @spec lines(TextDelta.state()) :: lines_result 55 | def lines(doc) do 56 | case valid_document?(doc) do 57 | true -> {:ok, op_lines(TextDelta.operations(doc), TextDelta.new())} 58 | false -> {:error, :bad_document} 59 | end 60 | end 61 | 62 | @doc """ 63 | Breaks document into multiple line segments. 64 | 65 | Equivalent to `&TextDelta.Document.lines/1`, but instead of returning 66 | ok/error tuples raises a `RuntimeError`. 67 | """ 68 | @spec lines!(TextDelta.state()) :: line_segments | no_return 69 | def lines!(doc) do 70 | case lines(doc) do 71 | {:ok, lines} -> 72 | lines 73 | 74 | {:error, reason} -> 75 | raise "Can not get lines from document: #{Atom.to_string(reason)}" 76 | end 77 | end 78 | 79 | defp op_lines([%{insert: ins} = op | rest], delta) when ins == "\n" do 80 | [{delta, Map.get(op, :attributes, %{})} | op_lines(rest, TextDelta.new())] 81 | end 82 | 83 | defp op_lines([%{insert: ins} = op | rest], delta) 84 | when not is_bitstring(ins) do 85 | op_lines(rest, TextDelta.append(delta, op)) 86 | end 87 | 88 | defp op_lines([%{insert: ins} = op | rest], delta) do 89 | op_from_split_string = fn 90 | "\n" -> Operation.insert("\n") 91 | othr -> Operation.insert(othr, Map.get(op, :attributes, %{})) 92 | end 93 | 94 | case String.split(ins, ~r/\n/, include_captures: true, trim: true) do 95 | [_] -> 96 | op_lines(rest, TextDelta.append(delta, op)) 97 | 98 | mul -> 99 | mul 100 | |> Enum.map(op_from_split_string) 101 | |> Kernel.++(rest) 102 | |> op_lines(delta) 103 | end 104 | end 105 | 106 | defp op_lines([], delta) do 107 | case Kernel.length(TextDelta.operations(delta)) do 108 | 0 -> [] 109 | _ -> [{delta, %{}}] 110 | end 111 | end 112 | 113 | defp valid_document?(document) do 114 | TextDelta.length(document) == TextDelta.length(document, [:insert]) 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /lib/text_delta/iterator.ex: -------------------------------------------------------------------------------- 1 | defmodule TextDelta.Iterator do 2 | @moduledoc """ 3 | Iterator iterates over two sets of operations at the same time, ensuring next 4 | elements in the resulting stream are of equal length. 5 | """ 6 | 7 | alias TextDelta.Operation 8 | 9 | @typedoc """ 10 | Individual set of operations. 11 | """ 12 | @type set :: [Operation.t()] 13 | 14 | @typedoc """ 15 | Two sets of operations to iterate. 16 | """ 17 | @type sets :: {set, set} 18 | 19 | @typedoc """ 20 | A type which is not to be sliced when iterating. Can be `:insert`, `:delete` 21 | or nil 22 | """ 23 | @type skip_type :: :insert | :delete | nil 24 | 25 | @typedoc """ 26 | A tuple representing the new head and tail operations of the two operation 27 | sets being iterated over. 28 | """ 29 | @type cycle :: {set_split, set_split} 30 | 31 | @typedoc """ 32 | A set's next scanned full or partial operation, and its resulting tail set. 33 | """ 34 | @type set_split :: {Operation.t() | nil, set} 35 | 36 | @doc """ 37 | Generates next cycle by iterating over given sets of operations. 38 | """ 39 | @spec next(sets, skip_type) :: cycle 40 | def next(sets, skip_type \\ nil) 41 | 42 | def next({[], []}, _) do 43 | {{nil, []}, {nil, []}} 44 | end 45 | 46 | def next({[], [head_b | tail_b]}, _) do 47 | {{nil, []}, {head_b, tail_b}} 48 | end 49 | 50 | def next({[head_a | tail_a], []}, _) do 51 | {{head_a, tail_a}, {nil, []}} 52 | end 53 | 54 | def next({[head_a | _], [head_b | _]} = sets, skip_type) do 55 | skip = Operation.type(head_a) == skip_type 56 | len_a = Operation.length(head_a) 57 | len_b = Operation.length(head_b) 58 | 59 | cond do 60 | len_a > len_b -> do_next(sets, :gt, len_b, skip) 61 | len_a < len_b -> do_next(sets, :lt, len_a, skip) 62 | true -> do_next(sets, :eq, 0, skip) 63 | end 64 | end 65 | 66 | defp do_next({[head_a | tail_a], [head_b | tail_b]}, :gt, len, false) do 67 | {head_a, remainder_a} = Operation.slice(head_a, len) 68 | {{head_a, [remainder_a | tail_a]}, {head_b, tail_b}} 69 | end 70 | 71 | defp do_next({[head_a | tail_a], [head_b | tail_b]}, :lt, len, _) do 72 | {head_b, remainder_b} = Operation.slice(head_b, len) 73 | {{head_a, tail_a}, {head_b, [remainder_b | tail_b]}} 74 | end 75 | 76 | defp do_next({[head_a | tail_a], [head_b | tail_b]}, _, _, _) do 77 | {{head_a, tail_a}, {head_b, tail_b}} 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /lib/text_delta/operation.ex: -------------------------------------------------------------------------------- 1 | defmodule TextDelta.Operation do 2 | @moduledoc """ 3 | Operations represent a smallest possible change applicable to a text. 4 | 5 | In case of text, there are exactly 3 possible operations we might want to 6 | perform: 7 | 8 | - `t:TextDelta.Operation.insert/0`: insert a new piece of text or an embedded 9 | element 10 | - `t:TextDelta.Operation.retain/0`: preserve given number of characters in 11 | sequence 12 | - `t:TextDelta.Operation.delete/0`: delete given number of characters in 13 | sequence 14 | 15 | `insert` and `retain` operations can also have optional 16 | `t:TextDelta.Attributes.t/0` attached to them. This is how Delta manages rich 17 | text formatting without breaking the [Operational Transformation][ot] 18 | paradigm. 19 | 20 | [ot]: https://en.wikipedia.org/wiki/Operational_transformation 21 | """ 22 | 23 | alias TextDelta.{Attributes, ConfigurableString} 24 | 25 | @typedoc """ 26 | Insert operation represents an intention to add a text or an embedded element 27 | to a text state. Text additions are represented with binary strings and 28 | embedded elements are represented with either an integer or an object. 29 | 30 | Insert also allows us to attach attributes to the element being inserted. 31 | """ 32 | @type insert :: 33 | %{insert: element} 34 | | %{insert: element, attributes: Attributes.t()} 35 | 36 | @typedoc """ 37 | Retain operation represents an intention to keep a sequence of characters 38 | unchanged in the text. It is always a number and it is always positive. 39 | 40 | In addition to indicating preservation of existing text, retain also allows us 41 | to change formatting of retained text or element via optional attributes. 42 | """ 43 | @type retain :: 44 | %{retain: non_neg_integer} 45 | | %{retain: non_neg_integer, attributes: Attributes.t()} 46 | 47 | @typedoc """ 48 | Delete operation represents an intention to delete a sequence of characters 49 | from the text. It is always a number and it is always positive. 50 | """ 51 | @type delete :: %{delete: non_neg_integer} 52 | 53 | @typedoc """ 54 | An operation. Either `insert`, `retain` or `delete`. 55 | """ 56 | @type t :: insert | retain | delete 57 | 58 | @typedoc """ 59 | Atom representing type of operation. 60 | """ 61 | @type type :: :insert | :retain | :delete 62 | 63 | @typedoc """ 64 | The result of comparison operation. 65 | """ 66 | @type comparison :: :eq | :gt | :lt 67 | 68 | @typedoc """ 69 | An insertable rich text element. Either a piece of text, a number or an embed. 70 | """ 71 | @type element :: String.t() | integer | map 72 | 73 | @doc """ 74 | Creates a new insert operation. 75 | 76 | Attributes are optional and are ignored if empty map or `nil` is provided. 77 | 78 | ## Examples 79 | 80 | To indicate that we need to insert a text "hello" into the text, we can 81 | use following insert: 82 | 83 | iex> TextDelta.Operation.insert("hello") 84 | %{insert: "hello"} 85 | 86 | In addition, we can indicate that "hello" should be inserted with specific 87 | attributes: 88 | 89 | iex> TextDelta.Operation.insert("hello", %{bold: true, color: "magenta"}) 90 | %{insert: "hello", attributes: %{bold: true, color: "magenta"}} 91 | 92 | We can also insert non-text objects, such as an image: 93 | 94 | iex> TextDelta.Operation.insert(%{img: "me.png"}, %{alt: "My photo"}) 95 | %{insert: %{img: "me.png"}, attributes: %{alt: "My photo"}} 96 | """ 97 | @spec insert(element, Attributes.t()) :: insert 98 | def insert(el, attrs \\ %{}) 99 | def insert(el, nil), do: %{insert: el} 100 | def insert(el, attrs) when map_size(attrs) == 0, do: %{insert: el} 101 | def insert(el, attrs), do: %{insert: el, attributes: attrs} 102 | 103 | @doc """ 104 | Creates a new retain operation. 105 | 106 | Attributes are optional and are ignored if empty map or `nil` is provided. 107 | 108 | ## Examples 109 | 110 | To keep 5 next characters inside the text, we can use the following retain: 111 | 112 | iex> TextDelta.Operation.retain(5) 113 | %{retain: 5} 114 | 115 | To make those exact 5 characters bold, while keeping them, we can use 116 | attributes: 117 | 118 | iex> TextDelta.Operation.retain(5, %{bold: true}) 119 | %{retain: 5, attributes: %{bold: true}} 120 | """ 121 | @spec retain(non_neg_integer, Attributes.t()) :: retain 122 | def retain(len, attrs \\ %{}) 123 | def retain(len, nil), do: %{retain: len} 124 | def retain(len, attrs) when map_size(attrs) == 0, do: %{retain: len} 125 | def retain(len, attrs), do: %{retain: len, attributes: attrs} 126 | 127 | @doc """ 128 | Creates a new delete operation. 129 | 130 | ## Example 131 | 132 | To delete 3 next characters from the text, we can create a following 133 | operation: 134 | 135 | iex> TextDelta.Operation.delete(3) 136 | %{delete: 3} 137 | """ 138 | @spec delete(non_neg_integer) :: delete 139 | def delete(len) 140 | def delete(len), do: %{delete: len} 141 | 142 | @doc """ 143 | Returns atom representing type of the given operation. 144 | 145 | ## Example 146 | 147 | iex> TextDelta.Operation.type(%{retain: 5, attributes: %{bold: true}}) 148 | :retain 149 | """ 150 | @spec type(t) :: type 151 | def type(op) 152 | def type(%{insert: _}), do: :insert 153 | def type(%{retain: _}), do: :retain 154 | def type(%{delete: _}), do: :delete 155 | 156 | @doc """ 157 | Returns length of text affected by a given operation. 158 | 159 | Length for `insert` operations is calculated by counting the length of text 160 | itself being inserted, length for `retain` or `delete` operations is a length 161 | of sequence itself. Attributes have no effect over the length. 162 | 163 | ## Examples 164 | 165 | For text inserts it is a length of text itself: 166 | 167 | iex> TextDelta.Operation.length(%{insert: "hello!"}) 168 | 6 169 | 170 | For embed inserts, however, length is always 1: 171 | 172 | iex> TextDelta.Operation.length(%{insert: 3}) 173 | 1 174 | 175 | For retain and deletes, the number itself is the length: 176 | 177 | iex> TextDelta.Operation.length(%{retain: 4}) 178 | 4 179 | """ 180 | @spec length(t) :: non_neg_integer 181 | def length(op) 182 | def length(%{insert: el}) when not is_bitstring(el), do: 1 183 | def length(%{insert: str}), do: ConfigurableString.length(str) 184 | def length(%{retain: len}), do: len 185 | def length(%{delete: len}), do: len 186 | 187 | @doc """ 188 | Compares the length of two operations. 189 | 190 | ## Example 191 | 192 | iex> TextDelta.Operation.compare(%{insert: "hello!"}, %{delete: 3}) 193 | :gt 194 | """ 195 | @spec compare(t, t) :: comparison 196 | def compare(op_a, op_b) do 197 | len_a = __MODULE__.length(op_a) 198 | len_b = __MODULE__.length(op_b) 199 | 200 | cond do 201 | len_a > len_b -> :gt 202 | len_a < len_b -> :lt 203 | true -> :eq 204 | end 205 | end 206 | 207 | @doc """ 208 | Splits operations into two halves around the given index. 209 | 210 | Text `insert` is split via slicing the text itself, `retain` or `delete` is 211 | split by subtracting the sequence number. Attributes are preserved during 212 | splitting. This is mostly used for normalisation of deltas during iteration. 213 | 214 | ## Examples 215 | 216 | Text `inserts` are split via slicing the text itself: 217 | 218 | iex> TextDelta.Operation.slice(%{insert: "hello"}, 3) 219 | {%{insert: "hel"}, %{insert: "lo"}} 220 | 221 | `retain` and `delete` are split by subtracting the sequence number: 222 | 223 | iex> TextDelta.Operation.slice(%{retain: 5}, 2) 224 | {%{retain: 2}, %{retain: 3}} 225 | """ 226 | @spec slice(t, non_neg_integer) :: {t, t} 227 | def slice(op, idx) 228 | 229 | def slice(%{insert: str} = op, idx) when is_bitstring(str) do 230 | {part_one, part_two} = ConfigurableString.split_at(str, idx) 231 | {Map.put(op, :insert, part_one), Map.put(op, :insert, part_two)} 232 | end 233 | 234 | def slice(%{insert: _} = op, _) do 235 | {op, %{insert: ""}} 236 | end 237 | 238 | def slice(%{retain: op_len} = op, idx) do 239 | {Map.put(op, :retain, idx), Map.put(op, :retain, op_len - idx)} 240 | end 241 | 242 | def slice(%{delete: op_len} = op, idx) do 243 | {Map.put(op, :delete, idx), Map.put(op, :delete, op_len - idx)} 244 | end 245 | 246 | @doc """ 247 | Attempts to compact two given operations into one. 248 | 249 | If successful, will return a list with just a single, compacted operation. In 250 | any other case both operations will be returned back unchanged. 251 | 252 | Compacting works by combining same operations with the same attributes 253 | together. Easiest way to think about this function is that it produces an 254 | exact opposite effect of `TextDelta.Operation.slice/2`. 255 | 256 | Text `insert` is compacted by concatenating strings, `retain` or `delete` is 257 | compacted by adding the sequence numbers. Only operations with the same 258 | attribute set are compacted. This is mostly used to keep deltas short and 259 | canonical. 260 | 261 | ## Examples 262 | 263 | Text inserts are compacted into a single insert: 264 | 265 | iex> TextDelta.Operation.compact(%{insert: "hel"}, %{insert: "lo"}) 266 | [%{insert: "hello"}] 267 | 268 | Retains and deletes are compacted by adding their sequence numbers: 269 | 270 | iex> TextDelta.Operation.compact(%{retain: 2}, %{retain: 3}) 271 | [%{retain: 5}] 272 | """ 273 | @spec compact(t, t) :: [t] 274 | def compact(op_a, op_b) 275 | 276 | def compact(%{retain: len_a, attributes: attrs_a}, %{ 277 | retain: len_b, 278 | attributes: attrs_b 279 | }) 280 | when attrs_a == attrs_b do 281 | [retain(len_a + len_b, attrs_a)] 282 | end 283 | 284 | def compact(%{retain: len_a} = a, %{retain: len_b} = b) 285 | when map_size(a) == 1 and map_size(b) == 1 do 286 | [retain(len_a + len_b)] 287 | end 288 | 289 | def compact(%{insert: el_a} = op_a, %{insert: _} = op_b) 290 | when not is_bitstring(el_a) do 291 | [op_a, op_b] 292 | end 293 | 294 | def compact(%{insert: _} = op_a, %{insert: el_b} = op_b) 295 | when not is_bitstring(el_b) do 296 | [op_a, op_b] 297 | end 298 | 299 | def compact(%{insert: str_a, attributes: attrs_a}, %{ 300 | insert: str_b, 301 | attributes: attrs_b 302 | }) 303 | when attrs_a == attrs_b do 304 | [insert(str_a <> str_b, attrs_a)] 305 | end 306 | 307 | def compact(%{insert: str_a} = op_a, %{insert: str_b} = op_b) 308 | when map_size(op_a) == 1 and map_size(op_b) == 1 do 309 | [insert(str_a <> str_b)] 310 | end 311 | 312 | def compact(%{delete: len_a}, %{delete: len_b}) do 313 | [delete(len_a + len_b)] 314 | end 315 | 316 | def compact(op_a, op_b), do: [op_a, op_b] 317 | 318 | @doc """ 319 | Checks if given operation is trimmable. 320 | 321 | Technically only `retain` operations are trimmable, but the creator of this 322 | library didn't feel comfortable exposing that knowledge outside of this 323 | module. 324 | 325 | ## Example 326 | 327 | iex> TextDelta.Operation.trimmable?(%{retain: 3}) 328 | true 329 | """ 330 | @spec trimmable?(t) :: boolean 331 | def trimmable?(op) do 332 | Map.has_key?(op, :retain) and !Map.has_key?(op, :attributes) 333 | end 334 | end 335 | -------------------------------------------------------------------------------- /lib/text_delta/transformation.ex: -------------------------------------------------------------------------------- 1 | defmodule TextDelta.Transformation do 2 | @moduledoc """ 3 | The transformation of two concurrent deltas such that they satisfy the 4 | convergence properties of Operational Transformation. 5 | 6 | Transformation allows optimistic conflict resolution in concurrent editing. 7 | Given a delta A that occurred at the same time as delta B against the same 8 | text state, we can transform the operations of delta A such that the state 9 | of the text after applying delta A and then delta B is the same as after 10 | applying delta B and then the transformation of delta A against delta B: 11 | 12 | S ○ Oa ○ transform(Ob, Oa) = S ○ Ob ○ transform(Oa, Ob) 13 | 14 | There is a great article written on [Operational Transformation][ot1] that 15 | author of this library used. It is called [Understanding and Applying 16 | Operational Transformation][ot2]. 17 | 18 | [tp1]: https://en.wikipedia.org/wiki/Operational_transformation#Convergence_properties 19 | [ot1]: https://en.wikipedia.org/wiki/Operational_transformation 20 | [ot2]: http://www.codecommit.com/blog/java/understanding-and-applying-operational-transformation 21 | """ 22 | 23 | alias TextDelta.{Operation, Attributes, Iterator} 24 | 25 | @typedoc """ 26 | Atom representing transformation priority. Which delta came first? 27 | """ 28 | @type priority :: :left | :right 29 | 30 | @doc """ 31 | Transforms `right` delta against the `left` one. 32 | 33 | The function also takes a third `t:TextDelta.Transformation.priority/0` 34 | argument that indicates which delta came first. This is important when 35 | doing conflict resolution. 36 | """ 37 | @spec transform(TextDelta.t(), TextDelta.t(), priority) :: TextDelta.t() 38 | def transform(left, right, priority) do 39 | {TextDelta.operations(left), TextDelta.operations(right)} 40 | |> iterate() 41 | |> do_transform(priority, TextDelta.new()) 42 | |> TextDelta.trim() 43 | end 44 | 45 | defp do_transform({{_, _}, {nil, _}}, _, result) do 46 | result 47 | end 48 | 49 | defp do_transform({{nil, _}, {op_b, remainder_b}}, _, result) do 50 | List.foldl([op_b | remainder_b], result, &TextDelta.append(&2, &1)) 51 | end 52 | 53 | defp do_transform( 54 | {{%{insert: _} = ins_a, remainder_a}, 55 | {%{insert: _} = ins_b, remainder_b}}, 56 | :left, 57 | result 58 | ) do 59 | retain = make_retain(ins_a) 60 | 61 | {remainder_a, [ins_b | remainder_b]} 62 | |> iterate() 63 | |> do_transform(:left, TextDelta.append(result, retain)) 64 | end 65 | 66 | defp do_transform( 67 | {{%{insert: _} = ins_a, remainder_a}, 68 | {%{insert: _} = ins_b, remainder_b}}, 69 | :right, 70 | result 71 | ) do 72 | {[ins_a | remainder_a], remainder_b} 73 | |> iterate() 74 | |> do_transform(:right, TextDelta.append(result, ins_b)) 75 | end 76 | 77 | defp do_transform( 78 | {{%{insert: _} = ins, remainder_a}, {%{retain: _} = ret, remainder_b}}, 79 | priority, 80 | result 81 | ) do 82 | retain = make_retain(ins) 83 | 84 | {remainder_a, [ret | remainder_b]} 85 | |> iterate() 86 | |> do_transform(priority, TextDelta.append(result, retain)) 87 | end 88 | 89 | defp do_transform( 90 | {{%{insert: _} = ins, remainder_a}, {%{delete: _} = del, remainder_b}}, 91 | priority, 92 | result 93 | ) do 94 | retain = make_retain(ins) 95 | 96 | {remainder_a, [del | remainder_b]} 97 | |> iterate() 98 | |> do_transform(priority, TextDelta.append(result, retain)) 99 | end 100 | 101 | defp do_transform( 102 | {{%{delete: _} = del, remainder_a}, {%{insert: _} = ins, remainder_b}}, 103 | priority, 104 | result 105 | ) do 106 | {[del | remainder_a], remainder_b} 107 | |> iterate() 108 | |> do_transform(priority, TextDelta.append(result, ins)) 109 | end 110 | 111 | defp do_transform( 112 | {{%{delete: _}, remainder_a}, {%{retain: _}, remainder_b}}, 113 | priority, 114 | result 115 | ) do 116 | {remainder_a, remainder_b} 117 | |> iterate() 118 | |> do_transform(priority, result) 119 | end 120 | 121 | defp do_transform( 122 | {{%{delete: _}, remainder_a}, {%{delete: _}, remainder_b}}, 123 | priority, 124 | result 125 | ) do 126 | {remainder_a, remainder_b} 127 | |> iterate() 128 | |> do_transform(priority, result) 129 | end 130 | 131 | defp do_transform( 132 | {{%{retain: _} = ret, remainder_a}, {%{insert: _} = ins, remainder_b}}, 133 | priority, 134 | result 135 | ) do 136 | {[ret | remainder_a], remainder_b} 137 | |> iterate() 138 | |> do_transform(priority, TextDelta.append(result, ins)) 139 | end 140 | 141 | defp do_transform( 142 | {{%{retain: _} = ret_a, remainder_a}, 143 | {%{retain: _} = ret_b, remainder_b}}, 144 | priority, 145 | result 146 | ) do 147 | retain = make_retain(ret_a, transform_attributes(ret_a, ret_b, priority)) 148 | 149 | {remainder_a, remainder_b} 150 | |> iterate() 151 | |> do_transform(priority, TextDelta.append(result, retain)) 152 | end 153 | 154 | defp do_transform( 155 | {{%{retain: _}, remainder_a}, {%{delete: _} = del, remainder_b}}, 156 | priority, 157 | result 158 | ) do 159 | {remainder_a, remainder_b} 160 | |> iterate() 161 | |> do_transform(priority, TextDelta.append(result, del)) 162 | end 163 | 164 | defp iterate(stream), do: Iterator.next(stream, :insert) 165 | 166 | defp make_retain(op, attrs \\ %{}) do 167 | op 168 | |> Operation.length() 169 | |> Operation.retain(attrs) 170 | end 171 | 172 | defp transform_attributes(op_a, op_b, priority) do 173 | attrs_a = Map.get(op_a, :attributes) 174 | attrs_b = Map.get(op_b, :attributes) 175 | Attributes.transform(attrs_a, attrs_b, priority) 176 | end 177 | end 178 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule TextDelta.Mixfile do 2 | use Mix.Project 3 | 4 | @version "1.4.0" 5 | @github_url "https://github.com/deltadoc/text_delta" 6 | 7 | def project do 8 | [ 9 | app: :text_delta, 10 | version: @version, 11 | description: description(), 12 | package: package(), 13 | elixir: "~> 1.6", 14 | build_embedded: Mix.env() == :prod, 15 | start_permanent: Mix.env() == :prod, 16 | deps: deps(), 17 | aliases: aliases(), 18 | dialyzer: [flags: ~w(-Werror_handling 19 | -Wrace_conditions 20 | -Wunderspecs 21 | -Wunmatched_returns)], 22 | homepage_url: @github_url, 23 | source_url: @github_url, 24 | docs: docs() 25 | ] 26 | end 27 | 28 | def application, do: [] 29 | 30 | defp aliases do 31 | [ 32 | lint: ["credo --strict", "dialyzer --halt-exit-status"] 33 | ] 34 | end 35 | 36 | defp description do 37 | """ 38 | Elixir counter-part for the Quill.js Delta library. It provides a baseline 39 | for Operational Transformation of rich text. 40 | """ 41 | end 42 | 43 | defp package do 44 | [ 45 | maintainers: ["Konstantin Kudryashov "], 46 | licenses: ["MIT"], 47 | links: %{"GitHub" => @github_url} 48 | ] 49 | end 50 | 51 | defp docs do 52 | [ 53 | source_ref: "v#{@version}", 54 | extras: [ 55 | "README.md": [filename: "README.md", title: "Readme"], 56 | "CHANGELOG.md": [filename: "CHANGELOG.md", title: "Changelog"], 57 | "LICENSE.md": [filename: "LICENSE.md", title: "License"] 58 | ] 59 | ] 60 | end 61 | 62 | defp deps do 63 | [ 64 | {:ex_doc, "~> 0.15", only: [:dev], runtime: false}, 65 | {:dialyxir, "~> 0.5", only: [:dev], runtime: false}, 66 | {:credo, "~> 0.7", only: [:dev, :test], runtime: false}, 67 | {:eqc_ex, "~> 1.4", only: [:dev, :test], runtime: false} 68 | ] 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm"}, 3 | "credo": {:hex, :credo, "0.9.2", "841d316612f568beb22ba310d816353dddf31c2d94aa488ae5a27bb53760d0bf", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:poison, ">= 0.0.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"}, 4 | "dialyxir": {:hex, :dialyxir, "0.5.1", "b331b091720fd93e878137add264bac4f644e1ddae07a70bf7062c7862c4b952", [:mix], [], "hexpm"}, 5 | "earmark": {:hex, :earmark, "1.2.5", "4d21980d5d2862a2e13ec3c49ad9ad783ffc7ca5769cf6ff891a4553fbaae761", [:mix], [], "hexpm"}, 6 | "eqc_ex": {:hex, :eqc_ex, "1.4.2", "c89322cf8fbd4f9ddcb18141fb162a871afd357c55c8c0198441ce95ffe2e105", [:mix], [], "hexpm"}, 7 | "ex_doc": {:hex, :ex_doc, "0.18.3", "f4b0e4a2ec6f333dccf761838a4b253d75e11f714b85ae271c9ae361367897b7", [:mix], [{:earmark, "~> 1.1", [hex: :earmark, repo: "hexpm", optional: false]}], "hexpm"}, 8 | "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm"}, 9 | } 10 | -------------------------------------------------------------------------------- /test/example/compose.exs: -------------------------------------------------------------------------------- 1 | delta_a = 2 | TextDelta.new() 3 | |> TextDelta.insert( 4 | "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book.", 5 | %{"bold" => true} 6 | ) 7 | |> TextDelta.retain(3) 8 | |> TextDelta.delete(2) 9 | |> TextDelta.insert( 10 | "Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots in a piece of classical Latin literature from 45 BC, making it over 2000 years old." 11 | ) 12 | 13 | delta_b = 14 | TextDelta.new() 15 | |> TextDelta.insert( 16 | "There are many variations of passages of Lorem Ipsum available, " 17 | ) 18 | |> TextDelta.delete(4) 19 | |> TextDelta.insert( 20 | "All the Lorem Ipsum generators on the Internet tend to repeat predefined chunks", 21 | %{"font" => "Arial"} 22 | ) 23 | 24 | TextDelta.compose(delta_a, delta_b) 25 | -------------------------------------------------------------------------------- /test/support/generators.exs: -------------------------------------------------------------------------------- 1 | defmodule TextDelta.Generators do 2 | use EQC.ExUnit 3 | 4 | alias TextDelta.Operation 5 | 6 | @max_operation_length 100 7 | @max_string_length 100 8 | @max_text_length 500 9 | 10 | def document do 11 | let text <- text() do 12 | TextDelta.insert(TextDelta.new(), text) 13 | end 14 | end 15 | 16 | def delta do 17 | let ops <- list(operation()) do 18 | TextDelta.new(ops) 19 | end 20 | end 21 | 22 | def document_delta(doc) do 23 | such_that delta <- delta() do 24 | TextDelta.length(doc) >= TextDelta.length(delta, [:retain, :delete]) 25 | end 26 | end 27 | 28 | def operation do 29 | oneof([insert(), retain(), delete()]) 30 | end 31 | 32 | def insert do 33 | let [el <- element(), attrs <- attributes()] do 34 | Operation.insert(el, attrs) 35 | end 36 | end 37 | 38 | def bitstring_insert do 39 | let [str <- string(), attrs <- attributes()] do 40 | Operation.insert(str, attrs) 41 | end 42 | end 43 | 44 | def retain do 45 | let [len <- operation_length(), attrs <- attributes()] do 46 | Operation.retain(len, attrs) 47 | end 48 | end 49 | 50 | def delete do 51 | let len <- operation_length() do 52 | Operation.delete(len) 53 | end 54 | end 55 | 56 | def element do 57 | oneof([string(), int(), map(string(), string())]) 58 | end 59 | 60 | def attributes do 61 | let attrs <- list(attribute()) do 62 | Map.new(attrs) 63 | end 64 | end 65 | 66 | def attribute do 67 | oneof([ 68 | {oneof([non_empty_string()]), oneof([non_empty_string(), bool(), int()])}, 69 | {oneof([:font, :style]), non_empty_string()}, 70 | {oneof([:bold, :italic]), bool()} 71 | ]) 72 | end 73 | 74 | def text do 75 | let length <- text_length() do 76 | random_string(length) 77 | end 78 | end 79 | 80 | def string do 81 | let length <- string_length() do 82 | random_string(length) 83 | end 84 | end 85 | 86 | def non_empty_string do 87 | non_empty(string()) 88 | end 89 | 90 | def priority_side do 91 | oneof([:left, :right]) 92 | end 93 | 94 | def text_length do 95 | choose(0, @max_text_length) 96 | end 97 | 98 | def string_length do 99 | choose(0, @max_string_length) 100 | end 101 | 102 | def operation_length do 103 | choose(0, @max_operation_length) 104 | end 105 | 106 | def opposite(:left), do: :right 107 | def opposite(:right), do: :left 108 | 109 | defp random_string(length) do 110 | length 111 | |> :crypto.strong_rand_bytes() 112 | |> Base.url_encode64() 113 | |> String.slice(0, length) 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | 3 | "./test/support" 4 | |> File.ls!() 5 | |> Enum.each(&Code.require_file("support/#{&1}", __DIR__)) 6 | -------------------------------------------------------------------------------- /test/text_delta/application_test.exs: -------------------------------------------------------------------------------- 1 | defmodule TextDelta.ApplicationTest do 2 | use ExUnit.Case 3 | use EQC.ExUnit 4 | import TextDelta.Generators 5 | 6 | doctest TextDelta.Application 7 | 8 | property "state modifications always result in a valid document state" do 9 | forall document <- document() do 10 | forall delta <- document_delta(document) do 11 | new_document = TextDelta.apply!(document, delta) 12 | 13 | ensure( 14 | TextDelta.length(new_document) == 15 | TextDelta.length(new_document, [:insert]) 16 | ) 17 | end 18 | end 19 | end 20 | 21 | @state TextDelta.insert(TextDelta.new(), "test") 22 | 23 | describe "apply" do 24 | test "insert delta" do 25 | delta = 26 | TextDelta.new() 27 | |> TextDelta.insert("hi") 28 | 29 | assert TextDelta.apply(@state, delta) == 30 | {:ok, TextDelta.compose(@state, delta)} 31 | end 32 | 33 | test "insert delta outside original text length" do 34 | delta = 35 | TextDelta.new() 36 | |> TextDelta.insert("this is a ") 37 | 38 | assert TextDelta.apply(@state, delta) == 39 | {:ok, TextDelta.compose(@state, delta)} 40 | end 41 | 42 | test "remove delta within original text length" do 43 | delta = 44 | TextDelta.new() 45 | |> TextDelta.delete(3) 46 | 47 | assert TextDelta.apply(@state, delta) == 48 | {:ok, TextDelta.compose(@state, delta)} 49 | end 50 | 51 | test "remove delta outside original text length" do 52 | delta = 53 | TextDelta.new() 54 | |> TextDelta.delete(5) 55 | 56 | assert TextDelta.apply(@state, delta) == {:error, :length_mismatch} 57 | end 58 | 59 | test "retain delta within original text length" do 60 | delta = 61 | TextDelta.new() 62 | |> TextDelta.retain(3) 63 | 64 | assert TextDelta.apply(@state, delta) == 65 | {:ok, TextDelta.compose(@state, delta)} 66 | end 67 | 68 | test "retain delta outside original text length" do 69 | delta = 70 | TextDelta.new() 71 | |> TextDelta.retain(5) 72 | 73 | assert TextDelta.apply(@state, delta) == {:error, :length_mismatch} 74 | end 75 | end 76 | 77 | describe "apply!" do 78 | test "insert delta" do 79 | delta = 80 | TextDelta.new() 81 | |> TextDelta.insert("hi") 82 | 83 | assert TextDelta.apply!(@state, delta) == TextDelta.compose(@state, delta) 84 | end 85 | 86 | test "retain delta outside original text length" do 87 | delta = 88 | TextDelta.new() 89 | |> TextDelta.retain(5) 90 | 91 | assert_raise RuntimeError, fn -> 92 | TextDelta.apply!(@state, delta) 93 | end 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /test/text_delta/attributes_test.exs: -------------------------------------------------------------------------------- 1 | defmodule TextDelta.AttributesTest do 2 | use ExUnit.Case 3 | alias TextDelta.Attributes 4 | 5 | doctest TextDelta.Attributes 6 | 7 | describe "compose" do 8 | @attributes %{bold: true, color: "red"} 9 | 10 | test "from nothing" do 11 | assert Attributes.compose(%{}, @attributes) == @attributes 12 | end 13 | 14 | test "to nothing" do 15 | assert Attributes.compose(@attributes, %{}) == @attributes 16 | end 17 | 18 | test "nothing with nothing" do 19 | assert Attributes.compose(%{}, %{}) == %{} 20 | end 21 | 22 | test "with new attribute" do 23 | assert Attributes.compose(@attributes, %{italic: true}) == %{ 24 | bold: true, 25 | italic: true, 26 | color: "red" 27 | } 28 | end 29 | 30 | test "with overwriten attribute" do 31 | assert Attributes.compose(@attributes, %{bold: false, color: "blue"}) == 32 | %{ 33 | bold: false, 34 | color: "blue" 35 | } 36 | end 37 | 38 | test "with attribute removed" do 39 | assert Attributes.compose(@attributes, %{bold: nil}) == %{color: "red"} 40 | end 41 | 42 | test "with all attributes removed" do 43 | assert Attributes.compose(@attributes, %{bold: nil, color: nil}) == %{} 44 | end 45 | 46 | test "with removal of inexistent element" do 47 | assert Attributes.compose(@attributes, %{italic: nil}) == @attributes 48 | end 49 | 50 | test "string-keyed attributes" do 51 | attrs_a = %{"bold" => true, "color" => "red"} 52 | attrs_b = %{"italic" => true, "color" => "blue"} 53 | composed = %{"bold" => true, "color" => "blue", "italic" => true} 54 | assert Attributes.compose(attrs_a, attrs_b) == composed 55 | end 56 | end 57 | 58 | describe "transform" do 59 | @lft %{bold: true, color: "red", font: nil} 60 | @rgt %{color: "blue", font: "serif", italic: true} 61 | 62 | test "from nothing" do 63 | assert Attributes.transform(%{}, @rgt, :right) == @rgt 64 | end 65 | 66 | test "to nothing" do 67 | assert Attributes.transform(@lft, %{}, :right) == %{} 68 | end 69 | 70 | test "nothing to nothing" do 71 | assert Attributes.transform(%{}, %{}, :right) == %{} 72 | end 73 | 74 | test "left to right with priority" do 75 | assert Attributes.transform(@lft, @rgt, :left) == %{italic: true} 76 | end 77 | 78 | test "left to right without priority" do 79 | assert Attributes.transform(@lft, @rgt, :right) == @rgt 80 | end 81 | 82 | test "string-keyed attributes" do 83 | attrs_a = %{"bold" => true, "color" => "red", "font" => nil} 84 | attrs_b = %{"color" => "blue", "font" => "serif", "italic" => true} 85 | 86 | assert Attributes.transform(attrs_a, attrs_b, :left) == %{ 87 | "italic" => true 88 | } 89 | 90 | assert Attributes.transform(attrs_a, attrs_b, :right) == attrs_b 91 | end 92 | end 93 | 94 | describe "diff" do 95 | @attributes %{bold: true, color: "red"} 96 | 97 | test "nothing with attributes" do 98 | assert Attributes.diff(%{}, @attributes) == @attributes 99 | end 100 | 101 | test "attributes with nothing" do 102 | assert Attributes.diff(@attributes, %{}) == %{bold: nil, color: nil} 103 | end 104 | 105 | test "same attributes" do 106 | assert Attributes.diff(@attributes, @attributes) == %{} 107 | end 108 | 109 | test "with added attribute" do 110 | assert Attributes.diff(@attributes, %{ 111 | bold: true, 112 | color: "red", 113 | italic: true 114 | }) == %{ 115 | italic: true 116 | } 117 | end 118 | 119 | test "with removed attribute" do 120 | assert Attributes.diff(@attributes, %{bold: true}) == %{color: nil} 121 | end 122 | 123 | test "with overwriten attribute" do 124 | assert Attributes.diff(@attributes, %{bold: true, color: "blue"}) == %{ 125 | color: "blue" 126 | } 127 | end 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /test/text_delta/backwards_compatibility_with_1.0_test.exs: -------------------------------------------------------------------------------- 1 | # Backwards compatibility layer tests. To be removed in 2.0. 2 | 3 | defmodule TextDelta.BCTest do 4 | use ExUnit.Case 5 | 6 | alias TextDelta.{Delta, Operation} 7 | alias TextDelta.Delta.{Iterator} 8 | 9 | describe "create" do 10 | test "empty delta" do 11 | assert Delta.new() == [] 12 | end 13 | 14 | test "empty operations" do 15 | delta = 16 | Delta.new() 17 | |> Delta.insert("") 18 | |> Delta.delete(0) 19 | |> Delta.retain(0) 20 | 21 | assert delta == [] 22 | end 23 | end 24 | 25 | describe "insert" do 26 | test "text" do 27 | delta = Delta.new() |> Delta.insert("test") 28 | assert delta == [%{insert: "test"}] 29 | end 30 | 31 | test "after delete" do 32 | delta = 33 | Delta.new() 34 | |> Delta.delete(1) 35 | |> Delta.insert("a") 36 | 37 | assert delta == [%{insert: "a"}, %{delete: 1}] 38 | end 39 | 40 | test "after delete with merge" do 41 | delta = 42 | Delta.new() 43 | |> Delta.insert("a") 44 | |> Delta.delete(1) 45 | |> Delta.insert("b") 46 | 47 | assert delta == [%{insert: "ab"}, %{delete: 1}] 48 | end 49 | 50 | test "after delete without merge" do 51 | delta = 52 | Delta.new() 53 | |> Delta.insert(1) 54 | |> Delta.delete(1) 55 | |> Delta.insert("a") 56 | 57 | assert delta == [%{insert: 1}, %{insert: "a"}, %{delete: 1}] 58 | end 59 | end 60 | 61 | describe "delete" do 62 | test "0" do 63 | delta = Delta.new() |> Delta.delete(0) 64 | assert delta == [] 65 | end 66 | 67 | test "positive" do 68 | delta = Delta.new() |> Delta.delete(3) 69 | assert delta == [%{delete: 3}] 70 | end 71 | end 72 | 73 | describe "retain" do 74 | test "0" do 75 | delta = Delta.new() |> Delta.retain(0) 76 | assert delta == [] 77 | end 78 | 79 | test "positive" do 80 | delta = Delta.new() |> Delta.retain(3) 81 | assert delta == [%{retain: 3}] 82 | end 83 | end 84 | 85 | describe "append" do 86 | test "to empty delta" do 87 | op = Operation.insert("a") 88 | assert Delta.append([], op) == [%{insert: "a"}] 89 | assert Delta.append(nil, op) == [%{insert: "a"}] 90 | end 91 | 92 | test "no operation" do 93 | delta = Delta.new() 94 | assert Delta.append(delta, nil) == [] 95 | assert Delta.append(delta, []) == [] 96 | end 97 | 98 | test "consecutive deletes" do 99 | delta = Delta.new() |> Delta.delete(3) 100 | op = Operation.delete(3) 101 | assert Delta.append(delta, op) == [%{delete: 6}] 102 | end 103 | 104 | test "consecutive inserts" do 105 | delta = Delta.new() |> Delta.insert("a") 106 | op = Operation.insert("c") 107 | assert Delta.append(delta, op) == [%{insert: "ac"}] 108 | end 109 | 110 | test "consecutive inserts with same attributes" do 111 | delta = Delta.new() |> Delta.insert("a", %{bold: true}) 112 | op = Operation.insert("c", %{bold: true}) 113 | 114 | assert Delta.append(delta, op) == [ 115 | %{insert: "ac", attributes: %{bold: true}} 116 | ] 117 | end 118 | 119 | test "consecutive embed inserts with same attributes" do 120 | delta = Delta.new() |> Delta.insert(1, %{bold: true}) 121 | op = Operation.insert(1, %{bold: true}) 122 | 123 | assert Delta.append(delta, op) == [ 124 | %{insert: 1, attributes: %{bold: true}}, 125 | %{insert: 1, attributes: %{bold: true}} 126 | ] 127 | end 128 | 129 | test "consecutive embed inserts with different attributes" do 130 | delta = Delta.new() |> Delta.insert("a", %{bold: true}) 131 | op = Operation.insert("c", %{italic: true}) 132 | 133 | assert Delta.append(delta, op) == [ 134 | %{insert: "a", attributes: %{bold: true}}, 135 | %{insert: "c", attributes: %{italic: true}} 136 | ] 137 | end 138 | 139 | test "consecutive retains" do 140 | delta = Delta.new() |> Delta.retain(3) 141 | op = Operation.retain(3) 142 | assert Delta.append(delta, op) == [%{retain: 6}] 143 | end 144 | 145 | test "consecutive retains with same attributes" do 146 | delta = Delta.new() |> Delta.retain(3, %{color: "red"}) 147 | op = Operation.retain(3, %{color: "red"}) 148 | 149 | assert Delta.append(delta, op) == [ 150 | %{retain: 6, attributes: %{color: "red"}} 151 | ] 152 | end 153 | 154 | test "consecutive retains with different attributes" do 155 | delta = Delta.new() |> Delta.retain(3, %{color: "red"}) 156 | op = Operation.retain(2, %{color: "blue"}) 157 | 158 | assert Delta.append(delta, op) == [ 159 | %{retain: 3, attributes: %{color: "red"}}, 160 | %{retain: 2, attributes: %{color: "blue"}} 161 | ] 162 | end 163 | 164 | test "an edge-case with potential duplication of inserts" do 165 | delta = 166 | Delta.new() 167 | |> Delta.insert("collaborative") 168 | |> Delta.retain(1) 169 | |> Delta.delete(1) 170 | |> Delta.insert("a") 171 | 172 | assert delta == [ 173 | %{insert: "collaborative"}, 174 | %{retain: 1}, 175 | %{insert: "a"}, 176 | %{delete: 1} 177 | ] 178 | end 179 | end 180 | 181 | describe "trim" do 182 | test "delta with no retains at the end" do 183 | delta = Delta.new() |> Delta.insert("a") 184 | assert Delta.trim(delta) == [%{insert: "a"}] 185 | end 186 | 187 | test "delta with a retain at the end" do 188 | delta = 189 | Delta.new() 190 | |> Delta.insert("a") 191 | |> Delta.retain(3) 192 | 193 | assert Delta.trim(delta) == [%{insert: "a"}] 194 | end 195 | 196 | test "delta with a retain at the beginning" do 197 | delta = 198 | Delta.new() 199 | |> Delta.retain(3) 200 | |> Delta.insert("a") 201 | 202 | assert Delta.trim(delta) == [%{retain: 3}, %{insert: "a"}] 203 | end 204 | end 205 | 206 | describe "compose" do 207 | test "insert with insert" do 208 | a = 209 | Delta.new() 210 | |> Delta.insert("A") 211 | 212 | b = 213 | Delta.new() 214 | |> Delta.insert("B") 215 | 216 | composition = 217 | Delta.new() 218 | |> Delta.insert("B") 219 | |> Delta.insert("A") 220 | 221 | assert Delta.compose(a, b) == composition 222 | end 223 | 224 | test "insert with retain" do 225 | a = 226 | Delta.new() 227 | |> Delta.insert("A") 228 | 229 | b = 230 | Delta.new() 231 | |> Delta.retain(1, %{bold: true, color: "red", font: nil}) 232 | 233 | composition = 234 | Delta.new() 235 | |> Delta.insert("A", %{bold: true, color: "red"}) 236 | 237 | assert Delta.compose(a, b) == composition 238 | end 239 | 240 | test "insert with delete" do 241 | a = 242 | Delta.new() 243 | |> Delta.insert("A") 244 | 245 | b = 246 | Delta.new() 247 | |> Delta.delete(1) 248 | 249 | composition = Delta.new() 250 | assert Delta.compose(a, b) == composition 251 | end 252 | 253 | test "delete with insert" do 254 | a = 255 | Delta.new() 256 | |> Delta.delete(1) 257 | 258 | b = 259 | Delta.new() 260 | |> Delta.insert("B") 261 | 262 | composition = 263 | Delta.new() 264 | |> Delta.insert("B") 265 | |> Delta.delete(1) 266 | 267 | assert Delta.compose(a, b) == composition 268 | end 269 | 270 | test "delete with retain" do 271 | a = 272 | Delta.new() 273 | |> Delta.delete(1) 274 | 275 | b = 276 | Delta.new() 277 | |> Delta.retain(1, %{bold: true, color: "red"}) 278 | 279 | composition = 280 | Delta.new() 281 | |> Delta.delete(1) 282 | |> Delta.retain(1, %{bold: true, color: "red"}) 283 | 284 | assert Delta.compose(a, b) == composition 285 | end 286 | 287 | test "delete with larger retain" do 288 | a = 289 | Delta.new() 290 | |> Delta.delete(1) 291 | 292 | b = 293 | Delta.new() 294 | |> Delta.retain(2) 295 | 296 | composition = 297 | Delta.new() 298 | |> Delta.delete(1) 299 | 300 | assert Delta.compose(a, b) == composition 301 | end 302 | 303 | test "delete with delete" do 304 | a = 305 | Delta.new() 306 | |> Delta.delete(1) 307 | 308 | b = 309 | Delta.new() 310 | |> Delta.delete(1) 311 | 312 | composition = 313 | Delta.new() 314 | |> Delta.delete(2) 315 | 316 | assert Delta.compose(a, b) == composition 317 | end 318 | 319 | test "retain with insert" do 320 | a = 321 | Delta.new() 322 | |> Delta.retain(1, %{color: "blue"}) 323 | 324 | b = 325 | Delta.new() 326 | |> Delta.insert("B") 327 | 328 | composition = 329 | Delta.new() 330 | |> Delta.insert("B") 331 | |> Delta.retain(1, %{color: "blue"}) 332 | 333 | assert Delta.compose(a, b) == composition 334 | end 335 | 336 | test "retain with retain" do 337 | a = 338 | Delta.new() 339 | |> Delta.retain(1, %{color: "blue"}) 340 | 341 | b = 342 | Delta.new() 343 | |> Delta.retain(1, %{bold: true, color: "red", font: nil}) 344 | 345 | composition = 346 | Delta.new() 347 | |> Delta.retain(1, %{bold: true, color: "red", font: nil}) 348 | 349 | assert Delta.compose(a, b) == composition 350 | end 351 | 352 | test "retain with delete" do 353 | a = 354 | Delta.new() 355 | |> Delta.retain(1, %{color: "blue"}) 356 | 357 | b = 358 | Delta.new() 359 | |> Delta.delete(1) 360 | 361 | composition = 362 | Delta.new() 363 | |> Delta.delete(1) 364 | 365 | assert Delta.compose(a, b) == composition 366 | end 367 | 368 | test "insertion in the middle of a text" do 369 | a = 370 | Delta.new() 371 | |> Delta.insert("Hello") 372 | 373 | b = 374 | Delta.new() 375 | |> Delta.retain(3) 376 | |> Delta.insert("X") 377 | 378 | composition = Delta.new() |> Delta.insert("HelXlo") 379 | assert Delta.compose(a, b) == composition 380 | end 381 | 382 | test "insert and delete with different ordering" do 383 | initial = 384 | Delta.new() 385 | |> Delta.insert("Hello") 386 | 387 | insert_first = 388 | Delta.new() 389 | |> Delta.retain(3) 390 | |> Delta.insert("X") 391 | |> Delta.delete(1) 392 | 393 | delete_first = 394 | Delta.new() 395 | |> Delta.retain(3) 396 | |> Delta.delete(1) 397 | |> Delta.insert("X") 398 | 399 | composition = 400 | Delta.new() 401 | |> Delta.insert("HelXo") 402 | 403 | assert Delta.compose(initial, insert_first) == composition 404 | assert Delta.compose(initial, delete_first) == composition 405 | end 406 | 407 | test "insert embed" do 408 | a = 409 | Delta.new() 410 | |> Delta.insert(1, %{src: "img.png"}) 411 | 412 | b = 413 | Delta.new() 414 | |> Delta.retain(1, %{alt: "logo"}) 415 | 416 | composition = 417 | Delta.new() 418 | |> Delta.insert(1, %{src: "img.png", alt: "logo"}) 419 | 420 | assert Delta.compose(a, b) == composition 421 | end 422 | 423 | test "insert half of and delete entirety of text" do 424 | a = 425 | Delta.new() 426 | |> Delta.retain(4) 427 | |> Delta.insert("Hello") 428 | 429 | b = 430 | Delta.new() 431 | |> Delta.delete(9) 432 | 433 | composition = 434 | Delta.new() 435 | |> Delta.delete(4) 436 | 437 | assert Delta.compose(a, b) == composition 438 | end 439 | 440 | test "retain more than the length of text" do 441 | a = 442 | Delta.new() 443 | |> Delta.insert("Hello") 444 | 445 | b = 446 | Delta.new() 447 | |> Delta.retain(10) 448 | 449 | composition = 450 | Delta.new() 451 | |> Delta.insert("Hello") 452 | 453 | assert Delta.compose(a, b) == composition 454 | end 455 | 456 | test "retain empty embed" do 457 | a = 458 | Delta.new() 459 | |> Delta.insert(1) 460 | 461 | b = 462 | Delta.new() 463 | |> Delta.retain(1) 464 | 465 | composition = 466 | Delta.new() 467 | |> Delta.insert(1) 468 | 469 | assert Delta.compose(a, b) == composition 470 | end 471 | 472 | test "remove attribute" do 473 | a = 474 | Delta.new() 475 | |> Delta.insert("A", %{bold: true}) 476 | 477 | b = 478 | Delta.new() 479 | |> Delta.retain(1, %{bold: nil}) 480 | 481 | composition = 482 | Delta.new() 483 | |> Delta.insert("A") 484 | 485 | assert Delta.compose(a, b) == composition 486 | end 487 | 488 | test "remove embed attribute" do 489 | a = 490 | Delta.new() 491 | |> Delta.insert(2, %{bold: true}) 492 | 493 | b = 494 | Delta.new() 495 | |> Delta.retain(1, %{bold: nil}) 496 | 497 | composition = 498 | Delta.new() 499 | |> Delta.insert(2) 500 | 501 | assert Delta.compose(a, b) == composition 502 | end 503 | 504 | test "change attributes and delete parts of text" do 505 | a = 506 | Delta.new() 507 | |> Delta.insert("Test", %{bold: true}) 508 | 509 | b = 510 | Delta.new() 511 | |> Delta.retain(1, %{color: "red"}) 512 | |> Delta.delete(2) 513 | 514 | composition = 515 | Delta.new() 516 | |> Delta.insert("T", %{color: "red", bold: true}) 517 | |> Delta.insert("t", %{bold: true}) 518 | 519 | assert Delta.compose(a, b) == composition 520 | end 521 | 522 | test "delete+retain with delete" do 523 | a = 524 | Delta.new() 525 | |> Delta.delete(1) 526 | |> Delta.retain(1, %{style: "P"}) 527 | 528 | b = 529 | Delta.new() 530 | |> Delta.delete(1) 531 | 532 | composition = 533 | Delta.new() 534 | |> Delta.delete(2) 535 | 536 | assert Delta.compose(a, b) == composition 537 | end 538 | end 539 | 540 | describe "transform" do 541 | test "insert against insert" do 542 | first = 543 | Delta.new() 544 | |> Delta.insert("A") 545 | 546 | second = 547 | Delta.new() 548 | |> Delta.insert("B") 549 | 550 | transformed_left = 551 | Delta.new() 552 | |> Delta.retain(1) 553 | |> Delta.insert("B") 554 | 555 | transformed_right = 556 | Delta.new() 557 | |> Delta.insert("B") 558 | 559 | assert Delta.transform(first, second, :left) == transformed_left 560 | assert Delta.transform(first, second, :right) == transformed_right 561 | end 562 | 563 | test "retain against insert" do 564 | first = 565 | Delta.new() 566 | |> Delta.insert("A") 567 | 568 | second = 569 | Delta.new() 570 | |> Delta.retain(1, %{bold: true, color: "red"}) 571 | 572 | transformed = 573 | Delta.new() 574 | |> Delta.retain(1) 575 | |> Delta.retain(1, %{bold: true, color: "red"}) 576 | 577 | assert Delta.transform(first, second, :left) == transformed 578 | end 579 | 580 | test "delete against insert" do 581 | first = 582 | Delta.new() 583 | |> Delta.insert("A") 584 | 585 | second = 586 | Delta.new() 587 | |> Delta.delete(1) 588 | 589 | transformed = 590 | Delta.new() 591 | |> Delta.retain(1) 592 | |> Delta.delete(1) 593 | 594 | assert Delta.transform(first, second, :left) == transformed 595 | end 596 | 597 | test "insert against delete" do 598 | first = 599 | Delta.new() 600 | |> Delta.delete(1) 601 | 602 | second = 603 | Delta.new() 604 | |> Delta.insert("B") 605 | 606 | transformed = 607 | Delta.new() 608 | |> Delta.insert("B") 609 | 610 | assert Delta.transform(first, second, :left) == transformed 611 | end 612 | 613 | test "retain against delete" do 614 | first = 615 | Delta.new() 616 | |> Delta.delete(1) 617 | 618 | second = 619 | Delta.new() 620 | |> Delta.retain(1, %{bold: true, color: "red"}) 621 | 622 | transformed = Delta.new() 623 | assert Delta.transform(first, second, :left) == transformed 624 | end 625 | 626 | test "delete against delete" do 627 | first = 628 | Delta.new() 629 | |> Delta.delete(1) 630 | 631 | second = 632 | Delta.new() 633 | |> Delta.delete(1) 634 | 635 | transformed = Delta.new() 636 | assert Delta.transform(first, second, :left) == transformed 637 | end 638 | 639 | test "insert against retain" do 640 | first = 641 | Delta.new() 642 | |> Delta.retain(1, %{color: "blue"}) 643 | 644 | second = 645 | Delta.new() 646 | |> Delta.insert("B") 647 | 648 | transformed = 649 | Delta.new() 650 | |> Delta.insert("B") 651 | 652 | assert Delta.transform(first, second, :left) == transformed 653 | end 654 | 655 | test "retain against retain" do 656 | first = 657 | Delta.new() 658 | |> Delta.retain(1, %{color: "blue"}) 659 | 660 | second = 661 | Delta.new() 662 | |> Delta.retain(1, %{bold: true, color: "red"}) 663 | 664 | transformed_second = 665 | Delta.new() 666 | |> Delta.retain(1, %{bold: true}) 667 | 668 | transformed_first = Delta.new() 669 | assert Delta.transform(first, second, :left) == transformed_second 670 | assert Delta.transform(second, first, :left) == transformed_first 671 | end 672 | 673 | test "retain against retain with right as priority" do 674 | first = 675 | Delta.new() 676 | |> Delta.retain(1, %{color: "blue"}) 677 | 678 | second = 679 | Delta.new() 680 | |> Delta.retain(1, %{bold: true, color: "red"}) 681 | 682 | transformed_second = 683 | Delta.new() 684 | |> Delta.retain(1, %{bold: true, color: "red"}) 685 | 686 | transformed_first = 687 | Delta.new() 688 | |> Delta.retain(1, %{color: "blue"}) 689 | 690 | assert Delta.transform(first, second, :right) == transformed_second 691 | assert Delta.transform(second, first, :right) == transformed_first 692 | end 693 | 694 | test "delete against retain" do 695 | first = 696 | Delta.new() 697 | |> Delta.retain(1, %{color: "blue"}) 698 | 699 | second = 700 | Delta.new() 701 | |> Delta.delete(1) 702 | 703 | transformed = 704 | Delta.new() 705 | |> Delta.delete(1) 706 | 707 | assert Delta.transform(first, second, :left) == transformed 708 | end 709 | 710 | test "alternating edits" do 711 | first = 712 | Delta.new() 713 | |> Delta.retain(2) 714 | |> Delta.insert("si") 715 | |> Delta.delete(5) 716 | 717 | second = 718 | Delta.new() 719 | |> Delta.retain(1) 720 | |> Delta.insert("e") 721 | |> Delta.delete(5) 722 | |> Delta.retain(1) 723 | |> Delta.insert("ow") 724 | 725 | transformed_second = 726 | Delta.new() 727 | |> Delta.retain(1) 728 | |> Delta.insert("e") 729 | |> Delta.delete(1) 730 | |> Delta.retain(2) 731 | |> Delta.insert("ow") 732 | 733 | transformed_first = 734 | Delta.new() 735 | |> Delta.retain(2) 736 | |> Delta.insert("si") 737 | |> Delta.delete(1) 738 | 739 | assert Delta.transform(first, second, :right) == transformed_second 740 | assert Delta.transform(second, first, :right) == transformed_first 741 | end 742 | 743 | test "conflicting appends" do 744 | first = 745 | Delta.new() 746 | |> Delta.retain(3) 747 | |> Delta.insert("aa") 748 | 749 | second = 750 | Delta.new() 751 | |> Delta.retain(3) 752 | |> Delta.insert("bb") 753 | 754 | transformed_second_with_left_priority = 755 | Delta.new() 756 | |> Delta.retain(5) 757 | |> Delta.insert("bb") 758 | 759 | transformed_first_with_right_priority = 760 | Delta.new() 761 | |> Delta.retain(3) 762 | |> Delta.insert("aa") 763 | 764 | assert Delta.transform(first, second, :left) == 765 | transformed_second_with_left_priority 766 | 767 | assert Delta.transform(second, first, :right) == 768 | transformed_first_with_right_priority 769 | end 770 | 771 | test "prepend and append" do 772 | first = 773 | Delta.new() 774 | |> Delta.insert("aa") 775 | 776 | second = 777 | Delta.new() 778 | |> Delta.retain(3) 779 | |> Delta.insert("bb") 780 | 781 | transformed_second = 782 | Delta.new() 783 | |> Delta.retain(5) 784 | |> Delta.insert("bb") 785 | 786 | transformed_first = 787 | Delta.new() 788 | |> Delta.insert("aa") 789 | 790 | assert Delta.transform(first, second, :right) == transformed_second 791 | assert Delta.transform(second, first, :right) == transformed_first 792 | end 793 | 794 | test "trailing deletes with differing lengths" do 795 | first = 796 | Delta.new() 797 | |> Delta.retain(2) 798 | |> Delta.delete(1) 799 | 800 | second = 801 | Delta.new() 802 | |> Delta.delete(3) 803 | 804 | transformed_second = 805 | Delta.new() 806 | |> Delta.delete(2) 807 | 808 | transformed_first = Delta.new() 809 | assert Delta.transform(first, second, :right) == transformed_second 810 | assert Delta.transform(second, first, :right) == transformed_first 811 | end 812 | end 813 | 814 | describe "Iterator.next" do 815 | test "of empty deltas" do 816 | assert Iterator.next({[], []}) == {{nil, []}, {nil, []}} 817 | end 818 | 819 | test "of an empty delta" do 820 | delta = Delta.new() |> Delta.insert("test") 821 | assert Iterator.next({[], delta}) == {{nil, []}, {%{insert: "test"}, []}} 822 | assert Iterator.next({delta, []}) == {{%{insert: "test"}, []}, {nil, []}} 823 | end 824 | 825 | test "operations of equal length" do 826 | delta_a = Delta.new() |> Delta.insert("test") 827 | delta_b = Delta.new() |> Delta.retain(4) 828 | 829 | assert Iterator.next({delta_a, delta_b}) == { 830 | {%{insert: "test"}, []}, 831 | {%{retain: 4}, []} 832 | } 833 | end 834 | 835 | test "operations of different length (>)" do 836 | delta_a = Delta.new() |> Delta.insert("test") 837 | delta_b = Delta.new() |> Delta.retain(2) 838 | 839 | assert Iterator.next({delta_a, delta_b}) == { 840 | {%{insert: "te"}, [%{insert: "st"}]}, 841 | {%{retain: 2}, []} 842 | } 843 | end 844 | 845 | test "operations of different length (>) with skip" do 846 | delta_a = Delta.new() |> Delta.insert("test") 847 | delta_b = Delta.new() |> Delta.retain(2) 848 | 849 | assert Iterator.next({delta_a, delta_b}, :insert) == { 850 | {%{insert: "test"}, []}, 851 | {%{retain: 2}, []} 852 | } 853 | end 854 | 855 | test "operations of different length (<)" do 856 | delta_a = Delta.new() |> Delta.insert("test") 857 | delta_b = Delta.new() |> Delta.retain(6) 858 | 859 | assert Iterator.next({delta_a, delta_b}) == { 860 | {%{insert: "test"}, []}, 861 | {%{retain: 4}, [%{retain: 2}]} 862 | } 863 | end 864 | end 865 | end 866 | -------------------------------------------------------------------------------- /test/text_delta/composition_test.exs: -------------------------------------------------------------------------------- 1 | defmodule TextDelta.CompositionTest do 2 | use ExUnit.Case 3 | use EQC.ExUnit 4 | import TextDelta.Generators 5 | 6 | doctest TextDelta.Composition 7 | 8 | property "(a + b) + c = a + (b + c)" do 9 | forall doc <- document() do 10 | forall delta_a <- document_delta(doc) do 11 | doc_a = TextDelta.compose(doc, delta_a) 12 | 13 | forall delta_b <- document_delta(doc_a) do 14 | doc_b = TextDelta.compose(doc_a, delta_b) 15 | 16 | delta_c = TextDelta.compose(delta_a, delta_b) 17 | doc_c = TextDelta.compose(doc, delta_c) 18 | 19 | ensure(doc_b == doc_c) 20 | end 21 | end 22 | end 23 | end 24 | 25 | describe "compose" do 26 | test "insert with insert" do 27 | a = 28 | TextDelta.new() 29 | |> TextDelta.insert("A") 30 | 31 | b = 32 | TextDelta.new() 33 | |> TextDelta.insert("B") 34 | 35 | composition = 36 | TextDelta.new() 37 | |> TextDelta.insert("B") 38 | |> TextDelta.insert("A") 39 | 40 | assert TextDelta.compose(a, b) == composition 41 | end 42 | 43 | test "insert with retain" do 44 | a = 45 | TextDelta.new() 46 | |> TextDelta.insert("A") 47 | 48 | b = 49 | TextDelta.new() 50 | |> TextDelta.retain(1, %{bold: true, color: "red", font: nil}) 51 | 52 | composition = 53 | TextDelta.new() 54 | |> TextDelta.insert("A", %{bold: true, color: "red"}) 55 | 56 | assert TextDelta.compose(a, b) == composition 57 | end 58 | 59 | test "insert with delete" do 60 | a = 61 | TextDelta.new() 62 | |> TextDelta.insert("A") 63 | 64 | b = 65 | TextDelta.new() 66 | |> TextDelta.delete(1) 67 | 68 | composition = TextDelta.new() 69 | assert TextDelta.compose(a, b) == composition 70 | end 71 | 72 | test "delete with insert" do 73 | a = 74 | TextDelta.new() 75 | |> TextDelta.delete(1) 76 | 77 | b = 78 | TextDelta.new() 79 | |> TextDelta.insert("B") 80 | 81 | composition = 82 | TextDelta.new() 83 | |> TextDelta.insert("B") 84 | |> TextDelta.delete(1) 85 | 86 | assert TextDelta.compose(a, b) == composition 87 | end 88 | 89 | test "delete with retain" do 90 | a = 91 | TextDelta.new() 92 | |> TextDelta.delete(1) 93 | 94 | b = 95 | TextDelta.new() 96 | |> TextDelta.retain(1, %{bold: true, color: "red"}) 97 | 98 | composition = 99 | TextDelta.new() 100 | |> TextDelta.delete(1) 101 | |> TextDelta.retain(1, %{bold: true, color: "red"}) 102 | 103 | assert TextDelta.compose(a, b) == composition 104 | end 105 | 106 | test "delete with larger retain" do 107 | a = 108 | TextDelta.new() 109 | |> TextDelta.delete(1) 110 | 111 | b = 112 | TextDelta.new() 113 | |> TextDelta.retain(2) 114 | 115 | composition = 116 | TextDelta.new() 117 | |> TextDelta.delete(1) 118 | 119 | assert TextDelta.compose(a, b) == composition 120 | end 121 | 122 | test "delete with delete" do 123 | a = 124 | TextDelta.new() 125 | |> TextDelta.delete(1) 126 | 127 | b = 128 | TextDelta.new() 129 | |> TextDelta.delete(1) 130 | 131 | composition = 132 | TextDelta.new() 133 | |> TextDelta.delete(2) 134 | 135 | assert TextDelta.compose(a, b) == composition 136 | end 137 | 138 | test "retain with insert" do 139 | a = 140 | TextDelta.new() 141 | |> TextDelta.retain(1, %{color: "blue"}) 142 | 143 | b = 144 | TextDelta.new() 145 | |> TextDelta.insert("B") 146 | 147 | composition = 148 | TextDelta.new() 149 | |> TextDelta.insert("B") 150 | |> TextDelta.retain(1, %{color: "blue"}) 151 | 152 | assert TextDelta.compose(a, b) == composition 153 | end 154 | 155 | test "retain with retain" do 156 | a = 157 | TextDelta.new() 158 | |> TextDelta.retain(1, %{color: "blue"}) 159 | 160 | b = 161 | TextDelta.new() 162 | |> TextDelta.retain(1, %{bold: true, color: "red", font: nil}) 163 | 164 | composition = 165 | TextDelta.new() 166 | |> TextDelta.retain(1, %{bold: true, color: "red", font: nil}) 167 | 168 | assert TextDelta.compose(a, b) == composition 169 | end 170 | 171 | test "retain with delete" do 172 | a = 173 | TextDelta.new() 174 | |> TextDelta.retain(1, %{color: "blue"}) 175 | 176 | b = 177 | TextDelta.new() 178 | |> TextDelta.delete(1) 179 | 180 | composition = 181 | TextDelta.new() 182 | |> TextDelta.delete(1) 183 | 184 | assert TextDelta.compose(a, b) == composition 185 | end 186 | 187 | test "insertion in the middle of a text" do 188 | a = 189 | TextDelta.new() 190 | |> TextDelta.insert("Hello") 191 | 192 | b = 193 | TextDelta.new() 194 | |> TextDelta.retain(3) 195 | |> TextDelta.insert("X") 196 | 197 | composition = TextDelta.new() |> TextDelta.insert("HelXlo") 198 | assert TextDelta.compose(a, b) == composition 199 | end 200 | 201 | test "insert and delete with different ordering" do 202 | initial = 203 | TextDelta.new() 204 | |> TextDelta.insert("Hello") 205 | 206 | insert_first = 207 | TextDelta.new() 208 | |> TextDelta.retain(3) 209 | |> TextDelta.insert("X") 210 | |> TextDelta.delete(1) 211 | 212 | delete_first = 213 | TextDelta.new() 214 | |> TextDelta.retain(3) 215 | |> TextDelta.delete(1) 216 | |> TextDelta.insert("X") 217 | 218 | composition = 219 | TextDelta.new() 220 | |> TextDelta.insert("HelXo") 221 | 222 | assert TextDelta.compose(initial, insert_first) == composition 223 | assert TextDelta.compose(initial, delete_first) == composition 224 | end 225 | 226 | test "insert embed" do 227 | a = 228 | TextDelta.new() 229 | |> TextDelta.insert(1, %{src: "img.png"}) 230 | 231 | b = 232 | TextDelta.new() 233 | |> TextDelta.retain(1, %{alt: "logo"}) 234 | 235 | composition = 236 | TextDelta.new() 237 | |> TextDelta.insert(1, %{src: "img.png", alt: "logo"}) 238 | 239 | assert TextDelta.compose(a, b) == composition 240 | end 241 | 242 | test "insert half of and delete entirety of text" do 243 | a = 244 | TextDelta.new() 245 | |> TextDelta.retain(4) 246 | |> TextDelta.insert("Hello") 247 | 248 | b = 249 | TextDelta.new() 250 | |> TextDelta.delete(9) 251 | 252 | composition = 253 | TextDelta.new() 254 | |> TextDelta.delete(4) 255 | 256 | assert TextDelta.compose(a, b) == composition 257 | end 258 | 259 | test "retain more than the length of text" do 260 | a = 261 | TextDelta.new() 262 | |> TextDelta.insert("Hello") 263 | 264 | b = 265 | TextDelta.new() 266 | |> TextDelta.retain(10) 267 | 268 | composition = 269 | TextDelta.new() 270 | |> TextDelta.insert("Hello") 271 | 272 | assert TextDelta.compose(a, b) == composition 273 | end 274 | 275 | test "retain empty embed" do 276 | a = 277 | TextDelta.new() 278 | |> TextDelta.insert(1) 279 | 280 | b = 281 | TextDelta.new() 282 | |> TextDelta.retain(1) 283 | 284 | composition = 285 | TextDelta.new() 286 | |> TextDelta.insert(1) 287 | 288 | assert TextDelta.compose(a, b) == composition 289 | end 290 | 291 | test "remove attribute" do 292 | a = 293 | TextDelta.new() 294 | |> TextDelta.insert("A", %{bold: true}) 295 | 296 | b = 297 | TextDelta.new() 298 | |> TextDelta.retain(1, %{bold: nil}) 299 | 300 | composition = 301 | TextDelta.new() 302 | |> TextDelta.insert("A") 303 | 304 | assert TextDelta.compose(a, b) == composition 305 | end 306 | 307 | test "remove embed attribute" do 308 | a = 309 | TextDelta.new() 310 | |> TextDelta.insert(2, %{bold: true}) 311 | 312 | b = 313 | TextDelta.new() 314 | |> TextDelta.retain(1, %{bold: nil}) 315 | 316 | composition = 317 | TextDelta.new() 318 | |> TextDelta.insert(2) 319 | 320 | assert TextDelta.compose(a, b) == composition 321 | end 322 | 323 | test "change attributes and delete parts of text" do 324 | a = 325 | TextDelta.new() 326 | |> TextDelta.insert("Test", %{bold: true}) 327 | 328 | b = 329 | TextDelta.new() 330 | |> TextDelta.retain(1, %{color: "red"}) 331 | |> TextDelta.delete(2) 332 | 333 | composition = 334 | TextDelta.new() 335 | |> TextDelta.insert("T", %{color: "red", bold: true}) 336 | |> TextDelta.insert("t", %{bold: true}) 337 | 338 | assert TextDelta.compose(a, b) == composition 339 | end 340 | 341 | test "delete+retain with delete" do 342 | a = 343 | TextDelta.new() 344 | |> TextDelta.delete(1) 345 | |> TextDelta.retain(1, %{style: "P"}) 346 | 347 | b = 348 | TextDelta.new() 349 | |> TextDelta.delete(1) 350 | 351 | composition = 352 | TextDelta.new() 353 | |> TextDelta.delete(2) 354 | 355 | assert TextDelta.compose(a, b) == composition 356 | end 357 | end 358 | end 359 | -------------------------------------------------------------------------------- /test/text_delta/difference_test.exs: -------------------------------------------------------------------------------- 1 | defmodule TextDelta.DifferenceTest do 2 | use ExUnit.Case 3 | use EQC.ExUnit 4 | import TextDelta.Generators 5 | 6 | doctest TextDelta.Difference 7 | 8 | property "applying diff always results in expected document" do 9 | forall document_a <- document() do 10 | forall document_b <- document() do 11 | diff = TextDelta.diff!(document_a, document_b) 12 | ensure(TextDelta.apply!(document_a, diff) == document_b) 13 | end 14 | end 15 | end 16 | 17 | describe "diff" do 18 | test "invalid documents" do 19 | bad_a = TextDelta.retain(TextDelta.new(), 5) 20 | bad_b = TextDelta.delete(TextDelta.new(), 3) 21 | good = TextDelta.insert(TextDelta.new(), "A") 22 | assert {:error, :bad_document} = TextDelta.diff(bad_a, bad_b) 23 | assert {:error, :bad_document} = TextDelta.diff(good, bad_b) 24 | assert {:error, :bad_document} = TextDelta.diff(bad_a, good) 25 | end 26 | 27 | test "insert" do 28 | a = TextDelta.insert(TextDelta.new(), "A") 29 | b = TextDelta.insert(TextDelta.new(), "AB") 30 | 31 | delta = 32 | TextDelta.new() 33 | |> TextDelta.retain(1) 34 | |> TextDelta.insert("B") 35 | 36 | assert {:ok, result} = TextDelta.diff(a, b) 37 | assert result == delta 38 | end 39 | 40 | test "delete" do 41 | a = TextDelta.insert(TextDelta.new(), "AB") 42 | b = TextDelta.insert(TextDelta.new(), "A") 43 | 44 | delta = 45 | TextDelta.new() 46 | |> TextDelta.retain(1) 47 | |> TextDelta.delete(1) 48 | 49 | assert {:ok, result} = TextDelta.diff(a, b) 50 | assert result == delta 51 | end 52 | 53 | test "retain" do 54 | a = TextDelta.insert(TextDelta.new(), "A") 55 | b = TextDelta.insert(TextDelta.new(), "A") 56 | delta = TextDelta.new() 57 | assert {:ok, result} = TextDelta.diff(a, b) 58 | assert result == delta 59 | end 60 | 61 | test "format" do 62 | a = TextDelta.insert(TextDelta.new(), "A") 63 | b = TextDelta.insert(TextDelta.new(), "A", %{bold: true}) 64 | delta = TextDelta.retain(TextDelta.new(), 1, %{bold: true}) 65 | assert {:ok, result} = TextDelta.diff(a, b) 66 | assert result == delta 67 | end 68 | 69 | test "object attributes" do 70 | a = 71 | TextDelta.insert(TextDelta.new(), "A", %{ 72 | font: %{family: "Helvetica", size: "15px"} 73 | }) 74 | 75 | b = 76 | TextDelta.insert(TextDelta.new(), "A", %{ 77 | font: %{family: "Helvetica", size: "15px"} 78 | }) 79 | 80 | delta = TextDelta.new() 81 | assert {:ok, result} = TextDelta.diff(a, b) 82 | assert result == delta 83 | end 84 | 85 | test "embed integer match" do 86 | a = TextDelta.insert(TextDelta.new(), 1) 87 | b = TextDelta.insert(TextDelta.new(), 1) 88 | delta = TextDelta.new() 89 | assert {:ok, result} = TextDelta.diff(a, b) 90 | assert result == delta 91 | end 92 | 93 | test "embed integer mismatch" do 94 | a = TextDelta.insert(TextDelta.new(), 1) 95 | b = TextDelta.insert(TextDelta.new(), 2) 96 | 97 | delta = 98 | TextDelta.new() 99 | |> TextDelta.delete(1) 100 | |> TextDelta.insert(2) 101 | 102 | assert {:ok, result} = TextDelta.diff(a, b) 103 | assert result == delta 104 | end 105 | 106 | test "embed object match" do 107 | a = TextDelta.insert(TextDelta.new(), %{image: "http://quilljs.com"}) 108 | b = TextDelta.insert(TextDelta.new(), %{image: "http://quilljs.com"}) 109 | delta = TextDelta.new() 110 | assert {:ok, result} = TextDelta.diff(a, b) 111 | assert result == delta 112 | end 113 | 114 | test "embed object mismatch" do 115 | a = 116 | TextDelta.insert(TextDelta.new(), %{ 117 | image: "http://quilljs.com", 118 | alt: 'Overwrite' 119 | }) 120 | 121 | b = TextDelta.insert(TextDelta.new(), %{image: "http://quilljs.com"}) 122 | 123 | delta = 124 | TextDelta.new() 125 | |> TextDelta.insert(%{image: "http://quilljs.com"}) 126 | |> TextDelta.delete(1) 127 | 128 | assert {:ok, result} = TextDelta.diff(a, b) 129 | assert result == delta 130 | end 131 | 132 | test "embed false positive" do 133 | a = TextDelta.insert(TextDelta.new(), 1) 134 | b = TextDelta.insert(TextDelta.new(), List.to_string([0])) 135 | 136 | delta = 137 | TextDelta.new() 138 | |> TextDelta.insert(List.to_string([0])) 139 | |> TextDelta.delete(1) 140 | 141 | assert {:ok, result} = TextDelta.diff(a, b) 142 | assert result == delta 143 | end 144 | 145 | test "inconvenient indexes" do 146 | a = 147 | TextDelta.new() 148 | |> TextDelta.insert("12", %{bold: true}) 149 | |> TextDelta.insert("34", %{italic: true}) 150 | 151 | b = 152 | TextDelta.new() 153 | |> TextDelta.insert("123", %{color: "red"}) 154 | 155 | delta = 156 | TextDelta.new() 157 | |> TextDelta.retain(2, %{bold: nil, color: "red"}) 158 | |> TextDelta.retain(1, %{italic: nil, color: "red"}) 159 | |> TextDelta.delete(1) 160 | 161 | assert {:ok, result} = TextDelta.diff(a, b) 162 | assert result == delta 163 | end 164 | 165 | test "combination" do 166 | a = 167 | TextDelta.new() 168 | |> TextDelta.insert("Bad", %{"color" => "red"}) 169 | |> TextDelta.insert("cat", %{"color" => "blue"}) 170 | 171 | b = 172 | TextDelta.new() 173 | |> TextDelta.insert("Good", %{"bold" => true}) 174 | |> TextDelta.insert("dog", %{"italic" => true}) 175 | 176 | delta = 177 | TextDelta.new() 178 | |> TextDelta.insert("Goo", %{"bold" => true}) 179 | |> TextDelta.delete(2) 180 | |> TextDelta.retain(1, %{"bold" => true, "color" => nil}) 181 | |> TextDelta.delete(3) 182 | |> TextDelta.insert("dog", %{"italic" => true}) 183 | 184 | assert {:ok, result} = TextDelta.diff(a, b) 185 | assert result == delta 186 | assert TextDelta.apply!(a, delta) == b 187 | end 188 | 189 | test "same document" do 190 | a = 191 | TextDelta.new() 192 | |> TextDelta.insert("A") 193 | |> TextDelta.insert("B", %{"bold" => true}) 194 | 195 | delta = TextDelta.new() 196 | assert {:ok, result} = TextDelta.diff(a, a) 197 | assert result == delta 198 | end 199 | end 200 | 201 | describe "diff!" do 202 | test "proper document" do 203 | delta = 204 | TextDelta.new() 205 | |> TextDelta.insert("hi") 206 | 207 | assert TextDelta.diff!(delta, delta) == TextDelta.new() 208 | end 209 | 210 | test "retain delta" do 211 | delta = 212 | TextDelta.new() 213 | |> TextDelta.retain(5) 214 | 215 | assert_raise RuntimeError, fn -> 216 | TextDelta.diff!(delta, delta) 217 | end 218 | end 219 | end 220 | end 221 | -------------------------------------------------------------------------------- /test/text_delta/document_test.exs: -------------------------------------------------------------------------------- 1 | defmodule TextDelta.DocumentTest do 2 | use ExUnit.Case 3 | 4 | doctest TextDelta.Document 5 | 6 | describe "lines" do 7 | test "not a document" do 8 | delta = TextDelta.delete(TextDelta.new(), 5) 9 | assert {:error, :bad_document} = TextDelta.lines(delta) 10 | delta = TextDelta.retain(TextDelta.new(), 5) 11 | assert {:error, :bad_document} = TextDelta.lines(delta) 12 | 13 | delta = 14 | TextDelta.new() 15 | |> TextDelta.delete(2) 16 | |> TextDelta.insert("5") 17 | |> TextDelta.retain(5) 18 | 19 | assert {:error, :bad_document} = TextDelta.lines(delta) 20 | end 21 | 22 | test "empty document" do 23 | delta = TextDelta.new() 24 | assert {:ok, lines} = TextDelta.lines(delta) 25 | assert lines == [] 26 | end 27 | 28 | test "document with a single insert" do 29 | delta = 30 | TextDelta.new() 31 | |> TextDelta.insert("a") 32 | 33 | assert {:ok, lines} = TextDelta.lines(delta) 34 | assert lines == [{delta, %{}}] 35 | end 36 | 37 | test "document with one insert containing newline" do 38 | delta = 39 | TextDelta.new() 40 | |> TextDelta.insert("a\nb") 41 | 42 | a_delta = 43 | TextDelta.new() 44 | |> TextDelta.insert("a") 45 | 46 | b_delta = 47 | TextDelta.new() 48 | |> TextDelta.insert("b") 49 | 50 | assert {:ok, lines} = TextDelta.lines(delta) 51 | assert lines == [{a_delta, %{}}, {b_delta, %{}}] 52 | end 53 | 54 | test "document with one insert containing two newlines" do 55 | delta = 56 | TextDelta.new() 57 | |> TextDelta.insert("a\nb\nc\n") 58 | 59 | a_delta = 60 | TextDelta.new() 61 | |> TextDelta.insert("a") 62 | 63 | b_delta = 64 | TextDelta.new() 65 | |> TextDelta.insert("b") 66 | 67 | c_delta = 68 | TextDelta.new() 69 | |> TextDelta.insert("c") 70 | 71 | assert {:ok, lines} = TextDelta.lines(delta) 72 | assert lines == [{a_delta, %{}}, {b_delta, %{}}, {c_delta, %{}}] 73 | end 74 | 75 | test "document with one paragraph, including newline attributes" do 76 | delta = 77 | TextDelta.new() 78 | |> TextDelta.insert("ab", %{bold: true}) 79 | |> TextDelta.insert("\n", %{header: 1}) 80 | 81 | a_delta = 82 | TextDelta.new() 83 | |> TextDelta.insert("ab", %{bold: true}) 84 | 85 | assert {:ok, lines} = TextDelta.lines(delta) 86 | assert lines == [{a_delta, %{header: 1}}] 87 | end 88 | 89 | test "document with embeds" do 90 | delta = 91 | TextDelta.new() 92 | |> TextDelta.insert("ab") 93 | |> TextDelta.insert(1) 94 | |> TextDelta.insert("\n") 95 | |> TextDelta.insert("c") 96 | 97 | a_delta = 98 | TextDelta.new() 99 | |> TextDelta.insert("ab") 100 | |> TextDelta.insert(1) 101 | 102 | b_delta = 103 | TextDelta.new() 104 | |> TextDelta.insert("c") 105 | 106 | assert {:ok, lines} = TextDelta.lines(delta) 107 | assert lines == [{a_delta, %{}}, {b_delta, %{}}] 108 | end 109 | 110 | test "document with two paragraphs, but only one with newline attributes" do 111 | delta = 112 | TextDelta.new() 113 | |> TextDelta.insert("ab", %{bold: true}) 114 | |> TextDelta.insert("\n", %{header: 1}) 115 | |> TextDelta.insert("cd") 116 | 117 | a_delta = 118 | TextDelta.new() 119 | |> TextDelta.insert("ab", %{bold: true}) 120 | 121 | b_delta = 122 | TextDelta.new() 123 | |> TextDelta.insert("cd") 124 | 125 | assert {:ok, lines} = TextDelta.lines(delta) 126 | assert lines == [{a_delta, %{header: 1}}, {b_delta, %{}}] 127 | end 128 | 129 | test "complex document including mixed attributes" do 130 | delta = 131 | TextDelta.new() 132 | |> TextDelta.insert("a") 133 | |> TextDelta.insert("b", %{bold: true}) 134 | |> TextDelta.insert("cd\n") 135 | |> TextDelta.insert("e", %{italic: true}) 136 | |> TextDelta.insert("f", %{bold: false}) 137 | |> TextDelta.insert("\n", %{header: 2}) 138 | |> TextDelta.insert("g") 139 | |> TextDelta.insert("h\n", %{bold: true, italic: true}) 140 | 141 | a_delta = 142 | TextDelta.new() 143 | |> TextDelta.insert("a") 144 | |> TextDelta.insert("b", %{bold: true}) 145 | |> TextDelta.insert("cd") 146 | 147 | b_delta = 148 | TextDelta.new() 149 | |> TextDelta.insert("e", %{italic: true}) 150 | |> TextDelta.insert("f", %{bold: false}) 151 | 152 | c_delta = 153 | TextDelta.new() 154 | |> TextDelta.insert("g") 155 | |> TextDelta.insert("h", %{bold: true, italic: true}) 156 | 157 | assert {:ok, lines} = TextDelta.lines(delta) 158 | assert lines == [{a_delta, %{}}, {b_delta, %{header: 2}}, {c_delta, %{}}] 159 | end 160 | 161 | test "document with one insert containing both newline and attributes" do 162 | delta = 163 | TextDelta.new() 164 | |> TextDelta.insert("a\nb", %{bold: true}) 165 | 166 | a_delta = 167 | TextDelta.new() 168 | |> TextDelta.insert("a", %{bold: true}) 169 | 170 | b_delta = 171 | TextDelta.new() 172 | |> TextDelta.insert("b", %{bold: true}) 173 | 174 | assert {:ok, lines} = TextDelta.lines(delta) 175 | assert lines == [{a_delta, %{}}, {b_delta, %{}}] 176 | end 177 | end 178 | 179 | describe "lines!" do 180 | test "proper document" do 181 | delta = 182 | TextDelta.new() 183 | |> TextDelta.insert("hi") 184 | 185 | assert TextDelta.lines!(delta) == [{delta, %{}}] 186 | end 187 | 188 | test "retain delta" do 189 | delta = 190 | TextDelta.new() 191 | |> TextDelta.retain(5) 192 | 193 | assert_raise RuntimeError, fn -> 194 | TextDelta.lines!(delta) 195 | end 196 | end 197 | end 198 | end 199 | -------------------------------------------------------------------------------- /test/text_delta/iterator_test.exs: -------------------------------------------------------------------------------- 1 | defmodule TextDelta.IteratorTest do 2 | use ExUnit.Case 3 | 4 | alias TextDelta.{Operation, Iterator} 5 | 6 | describe "next" do 7 | test "of empty operation lists" do 8 | assert Iterator.next({[], []}) == {{nil, []}, {nil, []}} 9 | end 10 | 11 | test "of an empty operation list" do 12 | ops = [Operation.insert("test")] 13 | assert Iterator.next({[], ops}) == {{nil, []}, {%{insert: "test"}, []}} 14 | assert Iterator.next({ops, []}) == {{%{insert: "test"}, []}, {nil, []}} 15 | end 16 | 17 | test "operations of equal length" do 18 | ops_a = [Operation.insert("test")] 19 | ops_b = [Operation.retain(4)] 20 | 21 | assert Iterator.next({ops_a, ops_b}) == { 22 | {%{insert: "test"}, []}, 23 | {%{retain: 4}, []} 24 | } 25 | end 26 | 27 | test "operations of different length (>)" do 28 | ops_a = [Operation.insert("test")] 29 | ops_b = [Operation.retain(2)] 30 | 31 | assert Iterator.next({ops_a, ops_b}) == { 32 | {%{insert: "te"}, [%{insert: "st"}]}, 33 | {%{retain: 2}, []} 34 | } 35 | end 36 | 37 | test "operations of different length (>) with skip" do 38 | ops_a = [Operation.insert("test")] 39 | ops_b = [Operation.retain(2)] 40 | 41 | assert Iterator.next({ops_a, ops_b}, :insert) == { 42 | {%{insert: "test"}, []}, 43 | {%{retain: 2}, []} 44 | } 45 | end 46 | 47 | test "operations of different length (<)" do 48 | ops_a = [Operation.insert("test")] 49 | ops_b = [Operation.retain(6)] 50 | 51 | assert Iterator.next({ops_a, ops_b}) == { 52 | {%{insert: "test"}, []}, 53 | {%{retain: 4}, [%{retain: 2}]} 54 | } 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /test/text_delta/operation_test.exs: -------------------------------------------------------------------------------- 1 | defmodule TextDelta.OperationTest do 2 | use ExUnit.Case 3 | alias TextDelta.Operation 4 | 5 | doctest TextDelta.Operation 6 | 7 | describe "insert" do 8 | test "text" do 9 | assert Operation.insert("test") == %{insert: "test"} 10 | end 11 | 12 | test "text with attributes" do 13 | assert Operation.insert("test", %{italic: true, font: "serif"}) == %{ 14 | insert: "test", 15 | attributes: %{italic: true, font: "serif"} 16 | } 17 | end 18 | 19 | test "embed" do 20 | assert Operation.insert(2) == %{insert: 2} 21 | end 22 | 23 | test "embed as a map" do 24 | assert Operation.insert(%{img: "me.png"}) == %{insert: %{img: "me.png"}} 25 | end 26 | 27 | test "embed as a map with attributes" do 28 | assert Operation.insert(%{img: "me.png"}, %{alt: "My photo"}) == %{ 29 | insert: %{img: "me.png"}, 30 | attributes: %{alt: "My photo"} 31 | } 32 | end 33 | 34 | test "with empty attributes" do 35 | assert Operation.insert("test", %{}) == %{insert: "test"} 36 | end 37 | 38 | test "with nil attributes" do 39 | assert Operation.insert("test", nil) == %{insert: "test"} 40 | end 41 | end 42 | 43 | describe "retain" do 44 | test "length" do 45 | assert Operation.retain(3) == %{retain: 3} 46 | end 47 | 48 | test "length with attributes" do 49 | assert Operation.retain(3, %{italic: true}) == %{ 50 | retain: 3, 51 | attributes: %{italic: true} 52 | } 53 | end 54 | 55 | test "length with empty attributes" do 56 | assert Operation.retain(3, %{}) == %{retain: 3} 57 | end 58 | 59 | test "length with nil attributes" do 60 | assert Operation.retain(3, nil) == %{retain: 3} 61 | end 62 | end 63 | 64 | describe "delete" do 65 | test "length" do 66 | assert Operation.delete(5) == %{delete: 5} 67 | end 68 | end 69 | 70 | describe "type" do 71 | test "of insert" do 72 | assert Operation.type(%{insert: "test"}) == :insert 73 | end 74 | 75 | test "of insert with attributes" do 76 | assert Operation.type(%{insert: "test", attributes: %{bold: true}}) == 77 | :insert 78 | end 79 | 80 | test "of retain" do 81 | assert Operation.type(%{retain: 4}) == :retain 82 | end 83 | 84 | test "of retain with attributes" do 85 | assert Operation.type(%{retain: 4, attributes: %{italic: true}}) == 86 | :retain 87 | end 88 | 89 | test "of delete" do 90 | assert Operation.type(%{delete: 10}) == :delete 91 | end 92 | end 93 | 94 | describe "length" do 95 | test "of insert" do 96 | assert Operation.length(%{insert: "test"}) == 4 97 | end 98 | 99 | test "of insert with attributes" do 100 | assert Operation.length(%{insert: "test", attributes: %{bold: true}}) == 4 101 | end 102 | 103 | test "of numerical embed insert with attributes" do 104 | assert Operation.length(%{insert: 5, attributes: %{bold: true}}) == 1 105 | end 106 | 107 | test "of map embed insert with attributes" do 108 | assert Operation.length(%{ 109 | insert: %{tweet: "4412"}, 110 | attributes: %{bold: true} 111 | }) == 1 112 | end 113 | 114 | test "of retain" do 115 | assert Operation.length(%{retain: 5}) == 5 116 | end 117 | 118 | test "of retain with attributes" do 119 | assert Operation.length(%{retain: 5, attributes: %{italic: true}}) == 5 120 | end 121 | 122 | test "of delete" do 123 | assert Operation.length(%{delete: 10}) == 10 124 | end 125 | end 126 | 127 | describe "compare" do 128 | test "greater insert with delete" do 129 | assert Operation.compare( 130 | %{insert: "test", attributes: %{italic: true}}, 131 | %{delete: 2} 132 | ) == :gt 133 | end 134 | 135 | test "lesser retain with insert" do 136 | assert Operation.compare(%{retain: 2, attributes: %{italic: true}}, %{ 137 | insert: "tes" 138 | }) == :lt 139 | end 140 | 141 | test "equal delete and retain" do 142 | assert Operation.compare(%{delete: 3}, %{ 143 | retain: 3, 144 | attributes: %{bold: true} 145 | }) == :eq 146 | end 147 | end 148 | 149 | describe "slice" do 150 | test "insert" do 151 | assert Operation.slice(%{insert: "hello"}, 3) == 152 | {%{insert: "hel"}, %{insert: "lo"}} 153 | end 154 | 155 | test "insert with attributes" do 156 | assert Operation.slice(%{insert: "hello", attributes: %{bold: true}}, 3) == 157 | { 158 | %{insert: "hel", attributes: %{bold: true}}, 159 | %{insert: "lo", attributes: %{bold: true}} 160 | } 161 | end 162 | 163 | test "insert of numeric embed" do 164 | assert Operation.slice(%{insert: 1}, 3) == {%{insert: 1}, %{insert: ""}} 165 | end 166 | 167 | test "insert of map embed" do 168 | assert Operation.slice(%{insert: %{img: "me.png"}}, 3) == { 169 | %{insert: %{img: "me.png"}}, 170 | %{insert: ""} 171 | } 172 | end 173 | 174 | test "retain" do 175 | assert Operation.slice(%{retain: 5}, 3) == {%{retain: 3}, %{retain: 2}} 176 | end 177 | 178 | test "retain with attributes" do 179 | assert Operation.slice(%{retain: 5, attributes: %{italic: true}}, 3) == { 180 | %{retain: 3, attributes: %{italic: true}}, 181 | %{retain: 2, attributes: %{italic: true}} 182 | } 183 | end 184 | 185 | test "delete" do 186 | assert Operation.slice(%{delete: 5}, 3) == {%{delete: 3}, %{delete: 2}} 187 | end 188 | end 189 | 190 | describe "compact" do 191 | test "inserts" do 192 | assert Operation.compact(%{insert: "hel"}, %{insert: "lo"}) == [ 193 | %{insert: "hello"} 194 | ] 195 | end 196 | 197 | test "inserts with attributes" do 198 | assert Operation.compact(%{insert: "hel", attributes: %{bold: true}}, %{ 199 | insert: "lo", 200 | attributes: %{bold: true} 201 | }) == [%{insert: "hello", attributes: %{bold: true}}] 202 | end 203 | 204 | test "inserts of numeric embeds" do 205 | assert Operation.compact(%{insert: 1}, %{insert: 1}) == [ 206 | %{insert: 1}, 207 | %{insert: 1} 208 | ] 209 | end 210 | 211 | test "inserts of map embeds" do 212 | assert Operation.compact(%{insert: %{img: "me.png"}}, %{ 213 | insert: %{img: "me.png"} 214 | }) == [ 215 | %{insert: %{img: "me.png"}}, 216 | %{insert: %{img: "me.png"}} 217 | ] 218 | end 219 | 220 | test "retains" do 221 | assert Operation.compact(%{retain: 3}, %{retain: 2}) == [%{retain: 5}] 222 | end 223 | 224 | test "retains with attributes" do 225 | assert Operation.compact(%{retain: 3, attributes: %{italic: true}}, %{ 226 | retain: 2, 227 | attributes: %{italic: true} 228 | }) == [%{retain: 5, attributes: %{italic: true}}] 229 | end 230 | 231 | test "deletes" do 232 | assert Operation.compact(%{delete: 3}, %{delete: 2}) == [%{delete: 5}] 233 | end 234 | end 235 | 236 | describe "trimmable?" do 237 | test "insert" do 238 | refute Operation.trimmable?(%{insert: "test"}) 239 | end 240 | 241 | test "delete" do 242 | refute Operation.trimmable?(%{delete: 5}) 243 | end 244 | 245 | test "retain" do 246 | assert Operation.trimmable?(%{retain: 5}) 247 | end 248 | end 249 | end 250 | -------------------------------------------------------------------------------- /test/text_delta/transformation_test.exs: -------------------------------------------------------------------------------- 1 | defmodule TextDelta.TransformationTest do 2 | use ExUnit.Case 3 | use EQC.ExUnit 4 | import TextDelta.Generators 5 | 6 | property "document states converge via opposite-priority transformations" do 7 | forall {doc, side} <- {document(), priority_side()} do 8 | forall {delta_a, delta_b} <- {document_delta(doc), document_delta(doc)} do 9 | delta_a_prime = TextDelta.transform(delta_b, delta_a, side) 10 | delta_b_prime = TextDelta.transform(delta_a, delta_b, opposite(side)) 11 | 12 | doc_a = 13 | doc 14 | |> TextDelta.compose(delta_a) 15 | |> TextDelta.compose(delta_b_prime) 16 | 17 | doc_b = 18 | doc 19 | |> TextDelta.compose(delta_b) 20 | |> TextDelta.compose(delta_a_prime) 21 | 22 | ensure(doc_a == doc_b) 23 | end 24 | end 25 | end 26 | 27 | describe "transform" do 28 | test "insert against insert" do 29 | first = 30 | TextDelta.new() 31 | |> TextDelta.insert("A") 32 | 33 | second = 34 | TextDelta.new() 35 | |> TextDelta.insert("B") 36 | 37 | transformed_left = 38 | TextDelta.new() 39 | |> TextDelta.retain(1) 40 | |> TextDelta.insert("B") 41 | 42 | transformed_right = 43 | TextDelta.new() 44 | |> TextDelta.insert("B") 45 | 46 | assert TextDelta.transform(first, second, :left) == transformed_left 47 | assert TextDelta.transform(first, second, :right) == transformed_right 48 | end 49 | 50 | test "retain against insert" do 51 | first = 52 | TextDelta.new() 53 | |> TextDelta.insert("A") 54 | 55 | second = 56 | TextDelta.new() 57 | |> TextDelta.retain(1, %{bold: true, color: "red"}) 58 | 59 | transformed = 60 | TextDelta.new() 61 | |> TextDelta.retain(1) 62 | |> TextDelta.retain(1, %{bold: true, color: "red"}) 63 | 64 | assert TextDelta.transform(first, second, :left) == transformed 65 | end 66 | 67 | test "delete against insert" do 68 | first = 69 | TextDelta.new() 70 | |> TextDelta.insert("A") 71 | 72 | second = 73 | TextDelta.new() 74 | |> TextDelta.delete(1) 75 | 76 | transformed = 77 | TextDelta.new() 78 | |> TextDelta.retain(1) 79 | |> TextDelta.delete(1) 80 | 81 | assert TextDelta.transform(first, second, :left) == transformed 82 | end 83 | 84 | test "insert against delete" do 85 | first = 86 | TextDelta.new() 87 | |> TextDelta.delete(1) 88 | 89 | second = 90 | TextDelta.new() 91 | |> TextDelta.insert("B") 92 | 93 | transformed = 94 | TextDelta.new() 95 | |> TextDelta.insert("B") 96 | 97 | assert TextDelta.transform(first, second, :left) == transformed 98 | end 99 | 100 | test "retain against delete" do 101 | first = 102 | TextDelta.new() 103 | |> TextDelta.delete(1) 104 | 105 | second = 106 | TextDelta.new() 107 | |> TextDelta.retain(1, %{bold: true, color: "red"}) 108 | 109 | transformed = TextDelta.new() 110 | assert TextDelta.transform(first, second, :left) == transformed 111 | end 112 | 113 | test "delete against delete" do 114 | first = 115 | TextDelta.new() 116 | |> TextDelta.delete(1) 117 | 118 | second = 119 | TextDelta.new() 120 | |> TextDelta.delete(1) 121 | 122 | transformed = TextDelta.new() 123 | assert TextDelta.transform(first, second, :left) == transformed 124 | end 125 | 126 | test "insert against retain" do 127 | first = 128 | TextDelta.new() 129 | |> TextDelta.retain(1, %{color: "blue"}) 130 | 131 | second = 132 | TextDelta.new() 133 | |> TextDelta.insert("B") 134 | 135 | transformed = 136 | TextDelta.new() 137 | |> TextDelta.insert("B") 138 | 139 | assert TextDelta.transform(first, second, :left) == transformed 140 | end 141 | 142 | test "retain against retain" do 143 | first = 144 | TextDelta.new() 145 | |> TextDelta.retain(1, %{color: "blue"}) 146 | 147 | second = 148 | TextDelta.new() 149 | |> TextDelta.retain(1, %{bold: true, color: "red"}) 150 | 151 | transformed_second = 152 | TextDelta.new() 153 | |> TextDelta.retain(1, %{bold: true}) 154 | 155 | transformed_first = TextDelta.new() 156 | assert TextDelta.transform(first, second, :left) == transformed_second 157 | assert TextDelta.transform(second, first, :left) == transformed_first 158 | end 159 | 160 | test "retain against retain with right as priority" do 161 | first = 162 | TextDelta.new() 163 | |> TextDelta.retain(1, %{color: "blue"}) 164 | 165 | second = 166 | TextDelta.new() 167 | |> TextDelta.retain(1, %{bold: true, color: "red"}) 168 | 169 | transformed_second = 170 | TextDelta.new() 171 | |> TextDelta.retain(1, %{bold: true, color: "red"}) 172 | 173 | transformed_first = 174 | TextDelta.new() 175 | |> TextDelta.retain(1, %{color: "blue"}) 176 | 177 | assert TextDelta.transform(first, second, :right) == transformed_second 178 | assert TextDelta.transform(second, first, :right) == transformed_first 179 | end 180 | 181 | test "delete against retain" do 182 | first = 183 | TextDelta.new() 184 | |> TextDelta.retain(1, %{color: "blue"}) 185 | 186 | second = 187 | TextDelta.new() 188 | |> TextDelta.delete(1) 189 | 190 | transformed = 191 | TextDelta.new() 192 | |> TextDelta.delete(1) 193 | 194 | assert TextDelta.transform(first, second, :left) == transformed 195 | end 196 | 197 | test "alternating edits" do 198 | first = 199 | TextDelta.new() 200 | |> TextDelta.retain(2) 201 | |> TextDelta.insert("si") 202 | |> TextDelta.delete(5) 203 | 204 | second = 205 | TextDelta.new() 206 | |> TextDelta.retain(1) 207 | |> TextDelta.insert("e") 208 | |> TextDelta.delete(5) 209 | |> TextDelta.retain(1) 210 | |> TextDelta.insert("ow") 211 | 212 | transformed_second = 213 | TextDelta.new() 214 | |> TextDelta.retain(1) 215 | |> TextDelta.insert("e") 216 | |> TextDelta.delete(1) 217 | |> TextDelta.retain(2) 218 | |> TextDelta.insert("ow") 219 | 220 | transformed_first = 221 | TextDelta.new() 222 | |> TextDelta.retain(2) 223 | |> TextDelta.insert("si") 224 | |> TextDelta.delete(1) 225 | 226 | assert TextDelta.transform(first, second, :right) == transformed_second 227 | assert TextDelta.transform(second, first, :right) == transformed_first 228 | end 229 | 230 | test "conflicting appends" do 231 | first = 232 | TextDelta.new() 233 | |> TextDelta.retain(3) 234 | |> TextDelta.insert("aa") 235 | 236 | second = 237 | TextDelta.new() 238 | |> TextDelta.retain(3) 239 | |> TextDelta.insert("bb") 240 | 241 | transformed_second_with_left_priority = 242 | TextDelta.new() 243 | |> TextDelta.retain(5) 244 | |> TextDelta.insert("bb") 245 | 246 | transformed_first_with_right_priority = 247 | TextDelta.new() 248 | |> TextDelta.retain(3) 249 | |> TextDelta.insert("aa") 250 | 251 | assert TextDelta.transform(first, second, :left) == 252 | transformed_second_with_left_priority 253 | 254 | assert TextDelta.transform(second, first, :right) == 255 | transformed_first_with_right_priority 256 | end 257 | 258 | test "prepend and append" do 259 | first = 260 | TextDelta.new() 261 | |> TextDelta.insert("aa") 262 | 263 | second = 264 | TextDelta.new() 265 | |> TextDelta.retain(3) 266 | |> TextDelta.insert("bb") 267 | 268 | transformed_second = 269 | TextDelta.new() 270 | |> TextDelta.retain(5) 271 | |> TextDelta.insert("bb") 272 | 273 | transformed_first = 274 | TextDelta.new() 275 | |> TextDelta.insert("aa") 276 | 277 | assert TextDelta.transform(first, second, :right) == transformed_second 278 | assert TextDelta.transform(second, first, :right) == transformed_first 279 | end 280 | 281 | test "trailing deletes with differing lengths" do 282 | first = 283 | TextDelta.new() 284 | |> TextDelta.retain(2) 285 | |> TextDelta.delete(1) 286 | 287 | second = 288 | TextDelta.new() 289 | |> TextDelta.delete(3) 290 | 291 | transformed_second = 292 | TextDelta.new() 293 | |> TextDelta.delete(2) 294 | 295 | transformed_first = TextDelta.new() 296 | assert TextDelta.transform(first, second, :right) == transformed_second 297 | assert TextDelta.transform(second, first, :right) == transformed_first 298 | end 299 | end 300 | end 301 | -------------------------------------------------------------------------------- /test/text_delta_test.exs: -------------------------------------------------------------------------------- 1 | defmodule TextDeltaTest do 2 | use ExUnit.Case 3 | use EQC.ExUnit 4 | import TextDelta.Generators 5 | 6 | alias TextDelta.Operation 7 | doctest TextDelta 8 | 9 | describe "compaction" do 10 | property "consecutive operations with same attributes compact" do 11 | forall ops <- list(oneof([bitstring_insert(), retain(), delete()])) do 12 | delta = TextDelta.new(ops) 13 | ensure(consecutive_ops_with_same_attrs(delta) == 0) 14 | end 15 | end 16 | 17 | defp consecutive_ops_with_same_attrs(%TextDelta{ops: []}), do: 0 18 | 19 | defp consecutive_ops_with_same_attrs(delta) do 20 | delta 21 | |> TextDelta.operations() 22 | |> Enum.chunk_by(&{Operation.type(&1), Map.get(&1, :attributes)}) 23 | |> Enum.filter(&(Enum.count(&1) > 1)) 24 | |> Enum.count() 25 | end 26 | end 27 | 28 | describe "create" do 29 | test "empty delta" do 30 | assert ops(TextDelta.new()) == [] 31 | end 32 | 33 | test "empty delta using zero-operations" do 34 | delta = 35 | TextDelta.new() 36 | |> TextDelta.insert("") 37 | |> TextDelta.delete(0) 38 | |> TextDelta.retain(0) 39 | 40 | assert ops(delta) == [] 41 | end 42 | end 43 | 44 | describe "insert" do 45 | test "text" do 46 | delta = TextDelta.insert(TextDelta.new(), "test") 47 | assert ops(delta) == [%{insert: "test"}] 48 | end 49 | 50 | test "after delete" do 51 | delta = 52 | TextDelta.new() 53 | |> TextDelta.delete(1) 54 | |> TextDelta.insert("a") 55 | 56 | assert ops(delta) == [%{insert: "a"}, %{delete: 1}] 57 | end 58 | 59 | test "after delete with merge" do 60 | delta = 61 | TextDelta.new() 62 | |> TextDelta.insert("a") 63 | |> TextDelta.delete(1) 64 | |> TextDelta.insert("b") 65 | 66 | assert ops(delta) == [%{insert: "ab"}, %{delete: 1}] 67 | end 68 | 69 | test "after delete without merge" do 70 | delta = 71 | TextDelta.new() 72 | |> TextDelta.insert(1) 73 | |> TextDelta.delete(1) 74 | |> TextDelta.insert("a") 75 | 76 | assert ops(delta) == [%{insert: 1}, %{insert: "a"}, %{delete: 1}] 77 | end 78 | end 79 | 80 | describe "delete" do 81 | test "0" do 82 | delta = TextDelta.delete(TextDelta.new(), 0) 83 | assert ops(delta) == [] 84 | end 85 | 86 | test "positive" do 87 | delta = TextDelta.delete(TextDelta.new(), 3) 88 | assert ops(delta) == [%{delete: 3}] 89 | end 90 | end 91 | 92 | describe "retain" do 93 | test "0" do 94 | delta = TextDelta.retain(TextDelta.new(), 0) 95 | assert ops(delta) == [] 96 | end 97 | 98 | test "positive" do 99 | delta = TextDelta.retain(TextDelta.new(), 3) 100 | assert ops(delta) == [%{retain: 3}] 101 | end 102 | end 103 | 104 | describe "append" do 105 | test "to empty delta" do 106 | op = Operation.insert("a") 107 | assert ops(TextDelta.append(%TextDelta{}, op)) == [%{insert: "a"}] 108 | end 109 | 110 | test "noop" do 111 | delta = TextDelta.new() 112 | assert ops(TextDelta.append(delta, nil)) == [] 113 | assert ops(TextDelta.append(delta, [])) == [] 114 | end 115 | 116 | test "consecutive deletes" do 117 | delta = TextDelta.delete(TextDelta.new(), 3) 118 | op = Operation.delete(3) 119 | assert ops(TextDelta.append(delta, op)) == [%{delete: 6}] 120 | end 121 | 122 | test "consecutive inserts" do 123 | delta = TextDelta.insert(TextDelta.new(), "a") 124 | op = Operation.insert("c") 125 | assert ops(TextDelta.append(delta, op)) == [%{insert: "ac"}] 126 | end 127 | 128 | test "consecutive inserts with same attributes" do 129 | delta = TextDelta.insert(TextDelta.new(), "a", %{bold: true}) 130 | op = Operation.insert("c", %{bold: true}) 131 | 132 | assert ops(TextDelta.append(delta, op)) == [ 133 | %{insert: "ac", attributes: %{bold: true}} 134 | ] 135 | end 136 | 137 | test "consecutive embed inserts with same attributes" do 138 | delta = TextDelta.insert(TextDelta.new(), 1, %{bold: true}) 139 | op = Operation.insert(1, %{bold: true}) 140 | 141 | assert ops(TextDelta.append(delta, op)) == [ 142 | %{insert: 1, attributes: %{bold: true}}, 143 | %{insert: 1, attributes: %{bold: true}} 144 | ] 145 | end 146 | 147 | test "consecutive embed inserts with different attributes" do 148 | delta = TextDelta.insert(TextDelta.new(), "a", %{bold: true}) 149 | op = Operation.insert("c", %{italic: true}) 150 | 151 | assert ops(TextDelta.append(delta, op)) == [ 152 | %{insert: "a", attributes: %{bold: true}}, 153 | %{insert: "c", attributes: %{italic: true}} 154 | ] 155 | end 156 | 157 | test "consecutive retains" do 158 | delta = TextDelta.retain(TextDelta.new(), 3) 159 | op = Operation.retain(3) 160 | assert ops(TextDelta.append(delta, op)) == [%{retain: 6}] 161 | end 162 | 163 | test "consecutive retains with same attributes" do 164 | delta = TextDelta.retain(TextDelta.new(), 3, %{color: "red"}) 165 | op = Operation.retain(3, %{color: "red"}) 166 | 167 | assert ops(TextDelta.append(delta, op)) == [ 168 | %{retain: 6, attributes: %{color: "red"}} 169 | ] 170 | end 171 | 172 | test "consecutive retains with different attributes" do 173 | delta = TextDelta.retain(TextDelta.new(), 3, %{color: "red"}) 174 | op = Operation.retain(2, %{color: "blue"}) 175 | 176 | assert ops(TextDelta.append(delta, op)) == [ 177 | %{retain: 3, attributes: %{color: "red"}}, 178 | %{retain: 2, attributes: %{color: "blue"}} 179 | ] 180 | end 181 | end 182 | 183 | describe "trim" do 184 | test "delta with no retains at the end" do 185 | delta = TextDelta.insert(TextDelta.new(), "a") 186 | assert ops(TextDelta.trim(delta)) == [%{insert: "a"}] 187 | end 188 | 189 | test "delta with a retain at the end" do 190 | delta = 191 | TextDelta.new() 192 | |> TextDelta.insert("a") 193 | |> TextDelta.retain(3) 194 | 195 | assert ops(TextDelta.trim(delta)) == [%{insert: "a"}] 196 | end 197 | 198 | test "delta with a retain at the beginning" do 199 | delta = 200 | TextDelta.new() 201 | |> TextDelta.retain(3) 202 | |> TextDelta.insert("a") 203 | 204 | assert ops(TextDelta.trim(delta)) == [%{retain: 3}, %{insert: "a"}] 205 | end 206 | end 207 | 208 | describe "an edge case of" do 209 | test "potential duplication of inserts" do 210 | delta = 211 | TextDelta.new() 212 | |> TextDelta.insert("collaborative") 213 | |> TextDelta.retain(1) 214 | |> TextDelta.delete(1) 215 | |> TextDelta.insert("a") 216 | 217 | assert ops(delta) == [ 218 | %{insert: "collaborative"}, 219 | %{retain: 1}, 220 | %{insert: "a"}, 221 | %{delete: 1} 222 | ] 223 | end 224 | end 225 | 226 | defp ops(delta), do: TextDelta.operations(delta) 227 | end 228 | --------------------------------------------------------------------------------