defmodule Dmp.Diff do @moduledoc """ Compare two blocks of plain text and efficiently return a list of differences. """ import Dmp.StringUtils alias Dmp.{Cursor, Options} @typedoc """ A diff's operation type. The operation `:nil` is used internally to indicate a nil value for the diff. """ @type op() :: :delete | :insert | :equal | nil @typedoc """ The diff tuple, consisting of two elements: the operation and the associated text. """ @type t() :: {op(), String.t()} @typedoc """ A list of diff operations, representing the difference between two text versions. A "difflist" is an Elixir list of "diff" tuples. Here is an example difflist: ``` [{:delete, "Hello"}, {:insert, "Goodbye"}, {:equal, " world."}] ``` which means: delete "Hello", add "Goodbye" and keep " world." """ @type difflist() :: list(t()) @type options() :: Options.t() @type expiry() :: :never | non_neg_integer() @doc """ Find the differences between two texts. * `text1` - Old string to be diffed. * `text2` - New string to be diffed. * `check_lines` - Speedup flag. If `false`, then don't run a line-level diff first to identify the changed areas. If `true`, then run a faster slightly less optimal diff. * `opts` - A options keyword list, `[]` to use the default options. Most of the time `check_lines` is wanted, so it defaults to `true`. Returns a difflist. """ @spec main(String.t(), String.t(), boolean(), options()) :: difflist() def main(text1, text2, check_lines \\ true, opts \\ []) do opts = Options.valid_options!(opts) main_(text1, text2, check_lines, opts) end @doc """ Skips validation of options. Used internally by `Patch.apply`. """ @spec main_(String.t(), String.t(), boolean(), options()) :: difflist() def main_(text1, text2, check_lines, opts) do diff_timeout = Keyword.fetch!(opts, :diff_timeout) deadline = if diff_timeout <= 0 do :never else :os.system_time(:millisecond) + round(diff_timeout * 1000) end main_impl(text1, text2, check_lines, deadline) end @spec main_impl(String.t(), String.t(), boolean(), expiry()) :: difflist() defp main_impl(text1, text2, check_lines, deadline) when is_binary(text1) and is_binary(text2) do # Check for equality (speedup). if text1 == text2 do if text1 == "" do [] else [{:equal, text1}] end else # Trim off common prefix (speedup). {prefix, text1, text2} = common_prefix(text1, text2) # Trim off common suffix (speedup). {suffix, text1, text2} = common_suffix(text1, text2) # Compute the diff on the middle block. diffs = compute(text1, text2, check_lines, deadline) # Restore the prefix and suffix. diffs = if prefix != "" do [{:equal, prefix} | diffs] else diffs end diffs = if suffix != "" do diffs ++ [{:equal, suffix}] else diffs end diffs = cleanup_merge(diffs) diffs end end @doc """ Find the differences between two texts. * `text1` - Old string to be diffed. * `text2` - New string to be diffed. * `check_lines` - Speedup flag. If false, then don't run a line-level diff first to identify the changed areas. If true, then run a faster slightly less optimal diff. * `deadline` - Unix timestamp in milliseconds when the diff should be complete by. Assumes that the texts do not have any common prefix or suffix. Returns a difflist. """ @spec compute(String.t(), String.t(), boolean(), expiry()) :: difflist() def compute("", text2, _, _) do # Just add some text (speedup). [{:insert, text2}] end def compute(text1, "", _, _) do # Just delete some text (speedup). [{:delete, text1}] end # credo:disable-for-lines:43 Credo.Check.Refactor.CyclomaticComplexity def compute(text1, text2, check_lines, deadline) do text1_length = String.length(text1) text2_length = String.length(text2) {longtext, shorttext, shorttext_length, op} = if text1_length > text2_length do {text1, text2, text2_length, :delete} else {text2, text1, text1_length, :insert} end case String.split(longtext, shorttext, parts: 2) do [left, right] -> # Shorter text is inside the longer text (speedup). [{op, left}, {:equal, shorttext}, {op, right}] _notfound -> if shorttext_length == 1 do # Single character string. # After the previous speedup, the character can't be an equality. [{:delete, text1}, {:insert, text2}] else # Check to see if the problem can be split in two. case half_match(text1, text2, deadline) do {text1_a, text1_b, text2_a, text2_b, mid_common} -> # Send both pairs off for separate processing. diffs_a = main_impl(text1_a, text2_a, check_lines, deadline) diffs_b = main_impl(text1_b, text2_b, check_lines, deadline) # Merge the results. diffs_a ++ [{:equal, mid_common} | diffs_b] nil -> if check_lines && text1_length > 100 && text2_length > 100 do line_mode(text1, text2, deadline) else bisect(text1, text2, deadline) end end end end end @doc """ Do a quick line-level diff on both strings, then rediff the parts for greater accuracy. * `text1` - Old string to be diffed. * `text2` - New string to be diffed. * `deadline` - Unix timestamp (in milliseconds) when the diff should be complete by. This speedup can produce non-minimal diffs. Returns a difflist. """ @spec line_mode(String.t(), String.t(), expiry()) :: difflist() def line_mode(text1, text2, deadline) do # Scan the text on a line-by-line basis first. {text1, text2, line_array} = lines_to_chars(text1, text2) diffs = main_impl(text1, text2, false, deadline) # Convert the diff back to original text. |> chars_to_lines(line_array) # Eliminate freak matches (e.g. blank lines) |> cleanup_semantic() if diffs == [] do diffs else # Add a dummy entry at the end. # Rediff any replacement blocks, this time character-by-character. # Remove the dummy entry at the end. (diffs ++ [{:equal, ""}]) |> Cursor.from_list(position: 0) |> line_mode_loop({0, 0, "", ""}, deadline) |> remove_dummy() end end defp line_mode_loop( %Cursor{current: nil} = diffs, _acc, _deadline ), do: Cursor.to_list(diffs) defp line_mode_loop( %Cursor{current: this_diff} = diffs, {count_delete, count_insert, text_delete, text_insert}, deadline ) do {op, text} = this_diff {cursor, count_delete, count_insert, text_delete, text_insert} = case op do :insert -> {diffs, count_delete, count_insert + 1, text_delete, text_insert <> text} :delete -> {diffs, count_delete + 1, count_insert, text_delete <> text, text_insert} :equal -> # Upon reaching an equality, check for prior redundancies. diffs2 = if count_delete > 0 && count_insert > 0 do # Delete the offending records and add the merged ones. diffs1 = Cursor.delete_before(diffs, count_delete + count_insert) sub_diff = main_impl(text_delete, text_insert, false, deadline) Cursor.insert_before(diffs1, sub_diff) else diffs end {diffs2, 0, 0, "", ""} _ -> raise RuntimeError, "Invalid operation #{inspect(op)}" end line_mode_loop( Cursor.move_forward(cursor), {count_delete, count_insert, text_delete, text_insert}, deadline ) end @doc """ Find the "middle snake" of a diff, split the problem in two and return the recursively constructed diff. See: [An O(ND) Difference Algorithm and Its Variations (Meyers, 1986)](http://www.xmailserver.org/diff2.pdf) * `text1` - Old string to be diffed. * `text2` - New string to be diffed. * `deadline` - Unix timestamp (in milliseconds) at which to bail if not yet complete. Returns a difflist. """ @spec bisect(String.t(), String.t(), expiry()) :: difflist() def bisect(text1, text2, deadline) do # Cache the text lengths to prevent multiple calls. text1_length = String.length(text1) text2_length = String.length(text2) max_d = div(text1_length + text2_length + 1, 2) v_offset = max_d v_length = 2 * max_d v1init = Enum.reduce(0..(v_length - 1), [], fn i, acc -> val = if i == v_offset + 1 do 0 else -1 end [{i, val} | acc] end) v2init = Map.new(v1init) v1init = Map.new(v1init) # Offsets for start and end of k loop. # Prevents mapping of space beyond the grid. # k1_start = 0 # k1_end = 0 # k2_start = 0 # k2_end = 0 diffs = Enum.reduce_while(0..max_d, {v1init, v2init, 0, 0, 0, 0}, fn d, {v1, v2, k1_start, k1_end, k2_start, k2_end} -> if is_integer(deadline) && :os.system_time(:millisecond) > deadline do # Bail out if deadline is reached. {:halt, nil} else # Walk the front path one step. {v1, diffs1} = advance_front(v1, v2, d, -d + k1_start, { k1_start, k1_end, v_offset, v_length, text1, text1_length, text2, text2_length, deadline }) if is_list(diffs1) do {:halt, diffs1} else # Walk the reverse path one step. {v2, diffs2} = advance_reverse( v1, v2, d, -d + k2_start, {k2_start, k2_end, v_offset, v_length, text1, text1_length, text2, text2_length, deadline} ) if is_list(diffs2) do {:halt, diffs2} else {:cont, {v1, v2, k1_start, k1_end, k2_start, k2_end}} end end end end) if is_list(diffs) do diffs else # Diff took too long and hit the deadline or # number of diffs equals number of characters, no commonality at all. [{:delete, text1}, {:insert, text2}] end end defp advance_front( v1, _v2, d, k1, {_k1_start, k1_end, _v_offset, _v_length, _text1, _text1_length, _text2, _text2_length, _deadline} ) when k1 > d - k1_end do {v1, nil} end defp advance_front( v1, v2, d, k1, {k1_start, k1_end, v_offset, v_length, text1, text1_length, text2, text2_length, deadline} ) do {v1, x1, y1} = get_front_location(v1, d, k1, v_offset, text1, text1_length, text2, text2_length) {k1_start, k1_end, diffs} = bisect_front( v2, k1, x1, y1, {k1_start, k1_end, v_offset, v_length, text1, text1_length, text2, text2_length, deadline} ) if is_list(diffs) do {v1, diffs} else advance_front( v1, v2, d, k1 + 2, {k1_start, k1_end, v_offset, v_length, text1, text1_length, text2, text2_length, deadline} ) end end defp get_front_location(v1, d, k1, v_offset, text1, text1_length, text2, text2_length) do k1_offset = v_offset + k1 v1_minus1 = v1[k1_offset - 1] v1_plus1 = v1[k1_offset + 1] x1 = if k1 == -d || (k1 != d && v1_minus1 < v1_plus1) do v1_plus1 else v1_minus1 + 1 end {x1, y1} = advance_x1_y1(x1, x1 - k1, text1, text1_length, text2, text2_length) v1 = Map.put(v1, k1_offset, x1) {v1, x1, y1} end defp advance_x1_y1(x1, y1, text1, text1_length, text2, text2_length) do if x1 < text1_length && y1 < text2_length && String.at(text1, x1) == String.at(text2, y1) do advance_x1_y1(x1 + 1, y1 + 1, text1, text1_length, text2, text2_length) else {x1, y1} end end defp bisect_front( v2, k1, x1, y1, {k1_start, k1_end, v_offset, v_length, text1, text1_length, text2, text2_length, deadline} ) do delta = text1_length - text2_length # If the total number of characters is odd, then the front path will # collide with the reverse path. front = rem(delta, 2) != 0 cond do x1 > text1_length -> # Ran off the right of the graph. {k1_start, k1_end + 2, nil} y1 > text2_length -> # Ran off the bottom of the graph. {k1_start + 2, k1_end, nil} front -> k2_offset = v_offset + delta - k1 if k2_offset >= 0 && k2_offset < v_length && v2[k2_offset] != -1 do # Mirror x2 onto top-left coordinate system. x2 = text1_length - v2[k2_offset] if x1 >= x2 do # Overlap detected. {k1_start, k1_end, bisect_split(text1, text2, x1, y1, deadline)} else {k1_start, k1_end, nil} end else {k1_start, k1_end, nil} end true -> {k1_start, k1_end, nil} end end defp advance_reverse( _v1, v2, d, k2, {_k2_start, k2_end, _v_offset, _v_length, _text1, _text1_length, _text2, _text2_length, _deadline} ) when k2 > d - k2_end do {v2, nil} end defp advance_reverse( v1, v2, d, k2, {k2_start, k2_end, v_offset, v_length, text1, text1_length, text2, text2_length, deadline} ) do {v2, x2, y2} = get_reverse_location(v2, d, k2, v_offset, text1, text1_length, text2, text2_length) {k2_start, k2_end, diffs} = bisect_reverse( v1, k2, x2, y2, {k2_start, k2_end, v_offset, v_length, text1, text1_length, text2, text2_length, deadline} ) if is_list(diffs) do {v2, diffs} else advance_reverse( v1, v2, d, k2 + 2, {k2_start, k2_end, v_offset, v_length, text1, text1_length, text2, text2_length, deadline} ) end end defp get_reverse_location(v2, d, k2, v_offset, text1, text1_length, text2, text2_length) do k2_offset = v_offset + k2 v2_minus1 = v2[k2_offset - 1] v2_plus1 = v2[k2_offset + 1] x2 = if k2 == -d || (k2 != d && v2_minus1 < v2_plus1) do v2_plus1 else v2_minus1 + 1 end {x2, y2} = advance_x2_y2(x2, x2 - k2, text1, text1_length, text2, text2_length) v2 = Map.put(v2, k2_offset, x2) {v2, x2, y2} end defp advance_x2_y2(x2, y2, text1, text1_length, text2, text2_length) do if x2 < text1_length && y2 < text2_length && String.at(text1, text1_length - x2 - 1) == String.at(text2, text2_length - y2 - 1) do advance_x2_y2(x2 + 1, y2 + 1, text1, text1_length, text2, text2_length) else {x2, y2} end end defp bisect_reverse( v1, k2, x2, y2, {k2_start, k2_end, v_offset, v_length, text1, text1_length, text2, text2_length, deadline} ) do delta = text1_length - text2_length # If the total number of characters is odd, then the front path will # collide with the reverse path. front = rem(delta, 2) != 0 cond do x2 > text1_length -> # Ran off the right of the graph. {k2_start, k2_end + 2, nil} y2 > text2_length -> # Ran off the bottom of the graph. {k2_start + 2, k2_end, nil} !front -> k1_offset = v_offset + delta - k2 if k1_offset >= 0 && k1_offset < v_length && v1[k1_offset] != -1 do x1 = v1[k1_offset] y1 = v_offset + x1 - k1_offset # Mirror x2 onto top-left coordinate system. x2 = text1_length - x2 if x1 >= x2 do # Overlap detected. {k2_start, k2_end, bisect_split(text1, text2, x1, y1, deadline)} else {k2_start, k2_end, nil} end else {k2_start, k2_end, nil} end true -> {k2_start, k2_end, nil} end end @doc """ Given the location of the "middle snake", split the diff in two parts and recurse. * `text1` - Old string to be diffed. * `text2` - New string to be diffed. * `x` - Index of split point in text1. * `y` - Index of split point in text2. * `deadline` - Unix timestamp (in milliseconds) at which to bail if not yet complete. Returns a difflist. """ @spec bisect_split( String.t(), String.t(), non_neg_integer(), non_neg_integer(), non_neg_integer() ) :: difflist() def bisect_split(text1, text2, x, y, deadline) do {text1a, text1b} = String.split_at(text1, x) {text2a, text2b} = String.split_at(text2, y) # Compute both diffs serially. diffsa = main_impl(text1a, text2a, false, deadline) diffsb = main_impl(text1b, text2b, false, deadline) diffsa ++ diffsb end @doc """ Split two texts into a list of strings. Reduce the texts to a string of hashes where each Unicode character represents one line. * `text1` - First string. * `text2` - Second string. Returns a tuple containing the encoded `text1`, the encoded `text2` and the list of unique strings. The zeroth element of the list of unique strings is intentionally blank. """ @spec lines_to_chars(String.t(), String.t()) :: {String.t(), String.t(), list(String.t())} def lines_to_chars(text1, text2) do # e.g. Enum.at(line_array, 4) == "Hello\n" # e.g. Map.get(line_hash, "Hello\n") == 4 # "\x00" is a valid character, but various debuggers don't like it. # So we'll insert a junk entry in line_array to avoid generating a null character. # Bail out at 55_295 because to_string([55_296]) raises "invalid code point 55296" # Allocate 2/3rds of the space for text1, the rest for text2. {line_hash, line_array, chars1} = lines_to_chars_munge(text1, {%{}, [""], nil, nil}, 37_000) {_line_hash, line_array, chars2} = lines_to_chars_munge(text2, {line_hash, line_array, nil, nil}, 55_295) {chars1, chars2, line_array} end defp pop_line(text) do case String.split(text, "\n", parts: 2) do [line] -> {line, ""} [line, rest] -> {line <> "\n", rest} _ -> raise RuntimeError, "pop_line error" end end @typep munge_line_hash() :: %{String.t() => non_neg_integer()} @typep munge_line_acc() :: {munge_line_hash(), list(String.t()), nil | list(non_neg_integer()), nil | non_neg_integer()} @typep munge_line_result() :: {munge_line_hash(), list(String.t()), String.t()} @spec lines_to_chars_munge( String.t(), munge_line_acc(), non_neg_integer() ) :: munge_line_result() # Case 1. Initial text is "" defp lines_to_chars_munge("", {h, arr, nil, _}, _max_lines) do {h, arr, ""} end # Case 2. No more text, or overflowed max_lines defp lines_to_chars_munge("", {h, arr, chars, _}, _max_lines) do {h, Enum.reverse(arr), Enum.reverse(chars) |> List.to_string()} end # Case 3. Process one line of text and recurse defp lines_to_chars_munge(text, {h, arr, chars, next_val}, max_lines) do # First time if chars == nil {arr, chars, next_val} = if is_nil(chars) do {Enum.reverse(arr), [], Enum.count(arr)} else {arr, chars, next_val} end {line, rest} = pop_line(text) {rest, {h, arr, chars, next_val}} = cond do Map.has_key?(h, line) -> val = Map.get(h, line) {rest, {h, arr, [val | chars], next_val}} next_val < max_lines -> {rest, {Map.put(h, line, next_val), [line | arr], [next_val | chars], next_val + 1}} true -> # Bail out {"", {h, arr, chars, next_val}} end lines_to_chars_munge(rest, {h, arr, chars, next_val}, max_lines) end @doc """ Rehydrate the text in a diff from a string of line hashes to real lines of text. * `diffs` - A difflist. * `line_array` - A list of unique strings. Returns the rehydrated difflist. """ @spec chars_to_lines(difflist(), list(String.t())) :: difflist() def chars_to_lines(diffs, line_array) do Enum.map(diffs, fn {op, encoded_text} -> {op, encoded_text |> String.to_charlist() |> Enum.reduce("", fn i, text -> text <> Enum.at(line_array, i) end)} end) end @doc """ Determine the common prefix of two strings. * `text1` - First string. * `text2` - Second string. Returns a tuple `{prefix, rest1, rest2}`, where * `prefix` - The common prefix. * `rest1` - `text1` with the prefix removed. * `rest2` - `text2` with the prefix removed. """ @spec common_prefix(String.t(), String.t()) :: {String.t(), String.t(), String.t()} def common_prefix(text1, text2) do # Cache the text lengths to prevent multiple calls. text1_length = String.length(text1) text2_length = String.length(text2) n = min(text1_length, text2_length) if n == 0 do {"", text1, text2} else prefix = Enum.reduce_while(0..(n - 1), "", fn i, acc -> ch = String.at(text1, i) if ch == String.at(text2, i) do {:cont, acc <> ch} else {:halt, acc} end end) {prefix, String.replace_prefix(text1, prefix, ""), String.replace_prefix(text2, prefix, "")} end end @doc """ Determine the common suffix of two strings. * `text1` - First string. * `text2` - Second string. Returns a tuple `{suffix, rest1, rest2}`, where * `suffix` - The common suffix. * `rest1` - `text1` with the suffix removed. * `rest2` - `text2` with the suffix removed. """ @spec common_suffix(String.t(), String.t()) :: {String.t(), String.t(), String.t()} def common_suffix(text1, text2) do # Cache the text lengths to prevent multiple calls. text1_length = String.length(text1) text2_length = String.length(text2) n = min(text1_length, text2_length) if n == 0 do {"", text1, text2} else suffix = Enum.reduce_while(1..n, "", fn i, acc -> ch = String.at(text1, text1_length - i) if ch == String.at(text2, text2_length - i) do {:cont, ch <> acc} else {:halt, acc} end end) {suffix, String.replace_suffix(text1, suffix, ""), String.replace_suffix(text2, suffix, "")} end end @doc """ Determine if the suffix of one string is the prefix of another. * `text1` - First string. * `text2` - Second string. Returns the number of characters common to the end of the first string and the start of the second string. """ @spec common_overlap(String.t(), String.t()) :: non_neg_integer() def common_overlap(text1, text2) do # Cache the text lengths to prevent multiple calls. text1_length = String.length(text1) text2_length = String.length(text2) # Eliminate the null case. if text1_length == 0 || text2_length == 0 do 0 else # Truncate the longer string. {text1, text2} = cond do text1_length > text2_length -> {substring(text1, text1_length - text2_length), text2} text1_length < text2_length -> {text1, substring(text2, 0, text1_length)} true -> {text1, text2} end text_length = min(text1_length, text2_length) # Quick check for the worst case. if text1 == text2 do text_length else best_overlap(0, 1, text_length, text1, text2) end end end # Start by looking for a single character match # and increase length until no match is found. # Performance analysis: https://neil.fraser.name/news/2010/11/04/ @spec best_overlap( non_neg_integer(), non_neg_integer(), non_neg_integer(), String.t(), String.t() ) :: non_neg_integer() defp best_overlap(best, length, text_length, text1, text2) do pattern = substring(text1, text_length - length) case String.split(text2, pattern, parts: 2) do [left, _right] -> found = String.length(left) length = length + found {best, length} = if found == 0 || pattern == left do {length, length + 1} else {best, length} end best_overlap(best, length, text_length, text1, text2) _not_found -> best end end @typedoc """ The result of a successful `Diff.half_match/3` call. A tuple of five strings: 1. the prefix of `text1` 2. the suffix of `text1` 3. the prefix of `text2` 4. the suffix of `text2` 5. the common middle """ @type half_match_result() :: {String.t(), String.t(), String.t(), String.t(), String.t()} @doc """ Do the two texts share a substring which is at least half the length of the longer text? * `text1` - First string. * `text2` - Second string. * `deadline` - Unix timestamp (in milliseconds) at which to bail if not yet complete. This speedup can produce non-minimal diffs. Returns a `half_match_result` 5-tuple, or `nil` if there was no match. Returns `nil` if `deadline` is zero (no time limit specified). """ @spec half_match(String.t(), String.t(), non_neg_integer()) :: nil | half_match_result() def half_match(text1, text2, deadline) do if deadline == 0 do # Don't risk returning a non-optimal diff if we have unlimited time. nil else # Cache the text lengths to prevent multiple calls. text1_length = String.length(text1) text2_length = String.length(text2) normal_order = text1_length > text2_length {longtext, shorttext, longtext_length, shorttext_length} = if normal_order do {text1, text2, text1_length, text2_length} else {text2, text1, text2_length, text1_length} end if longtext_length < 4 || shorttext_length * 2 < longtext_length do # Pointless. nil else # First check if the second quarter is the seed for a half-match. i = div(longtext_length + 3, 4) hm1 = half_match_impl(longtext, shorttext, i) # Check again based on the third quarter. i = div(longtext_length + 1, 2) hm2 = half_match_impl(longtext, shorttext, i) longer_half_match(hm1, hm2) |> sorted_half_match(normal_order) end end end @spec half_match_impl(String.t(), String.t(), non_neg_integer()) :: nil | {String.t(), String.t(), String.t(), String.t(), String.t()} defp half_match_impl(longtext, shorttext, i) do seed_length = div(String.length(longtext), 4) seed = String.slice(longtext, i, seed_length) j = index_of(shorttext, seed) best_half_match_loop({"", "", "", "", ""}, seed, i, j, longtext, shorttext) end defp best_half_match_loop( {best_longtext_a, best_longtext_b, best_shorttext_a, best_shorttext_b, best_common}, _seed, _i, -1, longtext, _shorttext ) do if String.length(best_common) * 2 >= String.length(longtext) do {best_longtext_a, best_longtext_b, best_shorttext_a, best_shorttext_b, best_common} else nil end end defp best_half_match_loop( {best_longtext_a, best_longtext_b, best_shorttext_a, best_shorttext_b, best_common}, seed, i, j, longtext, shorttext ) do {longa, longb} = String.split_at(longtext, i) {shorta, shortb} = String.split_at(shorttext, j) {prefix, ptext1, ptext2} = common_prefix(longb, shortb) {suffix, stext1, stext2} = common_suffix(longa, shorta) common = suffix <> prefix {best_longtext_a, best_longtext_b, best_shorttext_a, best_shorttext_b, best_common} = if String.length(best_common) < String.length(common) do {stext1, ptext1, stext2, ptext2, common} else {best_longtext_a, best_longtext_b, best_shorttext_a, best_shorttext_b, best_common} end j = index_of(shorttext, seed, j + 1) best_half_match_loop( {best_longtext_a, best_longtext_b, best_shorttext_a, best_shorttext_b, best_common}, seed, i, j, longtext, shorttext ) end defp longer_half_match({_, _, _, _, common1} = hm1, {_, _, _, _, common2} = hm2) do # Both matched. Select the longest. if String.length(common1) > String.length(common2) do hm1 else hm2 end end defp longer_half_match(hm1, nil), do: hm1 defp longer_half_match(nil, hm2), do: hm2 defp longer_half_match(_, _), do: nil # A half-match was found, sort out the return data. def sorted_half_match(nil, _), do: nil def sorted_half_match(hm, true), do: hm def sorted_half_match({prefix1, suffix1, prefix2, suffix2, common}, _) do {prefix2, suffix2, prefix1, suffix1, common} end @typep diffqueue() :: :queue.queue() defp safe_drop_r(queue, n \\ 1) defp safe_drop_r(queue, 1) do if :queue.is_empty(queue) do queue else :queue.drop_r(queue) end end defp safe_drop_r(queue, n) when n > 1 do safe_drop_r(queue) |> safe_drop_r(n - 1) end defp safe_drop_r(queue, _), do: queue @doc """ Reduce the number of edits in a diff by eliminating semantically trivial equalities. Returns the updated difflist. """ @spec cleanup_semantic(difflist()) :: difflist() def cleanup_semantic([]), do: [] def cleanup_semantic(diffs) do diffs = diffs |> Cursor.from_list(position: 0) {diffs, changes} = split_small_equalities(diffs, {false, :queue.new(), nil, 0, 0, 0, 0}) diffs = if changes do # Normalize the diff diffs |> cleanup_merge() else diffs end if Enum.count(diffs) < 2 do diffs else diffs |> cleanup_semantic_lossless() |> Cursor.from_list(position: 1) |> cleanup_overlap_loop() end end # `equalities` - Double-ended queue of equalities.. # `last_equality` - Always equal to the text of `:queue.peek_r(equalities)`. defp split_small_equalities( %Cursor{current: nil} = diffs, {changes, _equalities, _last_equality, _length_insertions1, _length_deletions1, _length_insertions2, _length_deletions2} ) do diffs = Cursor.to_list(diffs) {diffs, changes} end defp split_small_equalities( %Cursor{current: this_diff} = diffs, {changes, equalities, last_equality, length_insertions1, length_deletions1, length_insertions2, length_deletions2} ) do {op, text} = this_diff {cursor, changes, equalities, last_equality, length_insertions1, length_deletions1, length_insertions2, length_deletions2} = if op == :equal do # Equality found. Insert at rear of queue. eq_pos = Cursor.position(diffs) equalities = :queue.in(eq_pos, equalities) {Cursor.move_forward(diffs), changes, equalities, text, length_insertions2, length_deletions2, 0, 0} else # An insertion or deletion. {length_insertions2, length_deletions2} = if op == :insert do {length_insertions2 + String.length(text), length_deletions2} else {length_insertions2, length_deletions2 + String.length(text)} end # Eliminate an equality that is smaller or equal to the edits on both # sides of it. if !is_nil(last_equality) && String.length(last_equality) <= max(length_insertions1, length_deletions1) && String.length(last_equality) <= max(length_insertions2, length_deletions2) do # Walk back to offending equality. eq_pos = :queue.get_r(equalities) diffs = Cursor.move_to(diffs, eq_pos) # Replace equality with a delete. # Insert a corresponding an insert. new_diffs = [{:delete, last_equality}, {:insert, last_equality}] diffs = diffs |> Cursor.delete(1) |> Cursor.insert(new_diffs) # Throw away the equality we just deleted. # Throw away the previous equality (it needs to be reevaluated). equalities = safe_drop_r(equalities, 2) if :queue.is_empty(equalities) do # There are no previous equalities, walk back to the start. # Reset the counters {Cursor.move_first(diffs), true, equalities, nil, 0, 0, 0, 0} else eq_pos = :queue.get_r(equalities) # There is a safe equality we can fall back to. # Reset the counters diffs = Cursor.move_to(diffs, eq_pos) {diffs, true, equalities, nil, 0, 0, 0, 0} end else {Cursor.move_forward(diffs), changes, equalities, last_equality, length_insertions1, length_deletions1, length_insertions2, length_deletions2} end end split_small_equalities( cursor, {changes, equalities, last_equality, length_insertions1, length_deletions1, length_insertions2, length_deletions2} ) end # Find any overlaps between deletions and insertions. # e.g: abcxxxxxxdef # -> abcxxxdef # e.g: xxxabcdefxxx # -> defxxxabc # Only extract an overlap if it is as big as the edit ahead or behind it. @spec cleanup_overlap_loop(Cursor.t()) :: difflist() defp cleanup_overlap_loop(%Cursor{current: nil} = diffs) do diffs = Cursor.to_list(diffs) diffs end defp cleanup_overlap_loop(%Cursor{} = diffs) do {prev_diff, this_diff, _} = Cursor.get(diffs) {prev_op, deletion} = prev_diff {op, insertion} = this_diff diffs = if prev_op == :delete && op == :insert do overlap_length1 = common_overlap(deletion, insertion) overlap_length2 = common_overlap(insertion, deletion) if overlap_length1 >= overlap_length2 do if overlap_length1 * 2 >= String.length(deletion) || overlap_length1 * 2 >= String.length(insertion) do # Overlap found. Insert an equality and trim the surrounding edits. overlap = substring(insertion, 0, overlap_length1) deletion = substring(deletion, 0, String.length(deletion) - overlap_length1) insertion = substring(insertion, overlap_length1) new_diffs = [{:delete, deletion}, {:equal, overlap}, {:insert, insertion}] diffs |> Cursor.move_back() |> Cursor.delete(2) |> Cursor.insert(new_diffs) else diffs end else if overlap_length2 * 2 >= String.length(deletion) || overlap_length2 * 2 >= String.length(insertion) do # Reverse overlap found. # Insert an equality and swap and trim the surrounding edits. overlap = substring(deletion, 0, overlap_length2) insertion = substring(insertion, 0, String.length(insertion) - overlap_length2) deletion = substring(deletion, overlap_length2) new_diffs = [{:insert, insertion}, {:equal, overlap}, {:delete, deletion}] diffs |> Cursor.move_back(1) |> Cursor.delete(2) |> Cursor.insert(new_diffs) else diffs end end else diffs end diffs |> Cursor.move_forward() |> cleanup_overlap_loop() end @doc """ Look for single edits in a diff that are surrounded on both sides by equalities which can be shifted sideways to align the edit to a word boundary. Example: `The cat came.` becomes `The cat came.` Returns the updated difflist. """ @spec cleanup_semantic_lossless(difflist()) :: difflist() def cleanup_semantic_lossless(diffs) do # Intentionally ignore the first and last element (don't need checking). diffs |> Cursor.from_list(position: 1) |> cleanup_semantic_lossless_loop() end # Intentionally ignore the first and last element (don't need checking). defp cleanup_semantic_lossless_loop(%Cursor{next: []} = diffs) do Cursor.to_list(diffs) end defp cleanup_semantic_lossless_loop(%Cursor{} = diffs) do {prev_diff, this_diff, next_diff} = Cursor.get(diffs) {prev_op, prev_text} = prev_diff {next_op, next_text} = next_diff diffs = if prev_op == :equal && next_op == :equal do # This is a single edit surrounded by equalities. equality1 = prev_text {op, edit} = this_diff equality2 = next_text # First, shift the edit as far left as possible. {suffix, text1, text2} = common_suffix(equality1, edit) {equality1, edit, equality2} = {text1, suffix <> text2, suffix <> equality2} score1 = semantic_score(equality1, edit) score2 = semantic_score(edit, equality2) score = score1 + score2 {_best_score, best_equality1, best_edit, best_equality2} = best_score_loop( equality1, edit, equality2, {score, equality1, edit, equality2} ) if prev_text != best_equality1 do # We have an improvement, save it back to the diff. diffs = if best_equality1 != "" do new_prev = {prev_op, best_equality1} # Update prev_diff. diffs |> Cursor.delete_before(1) |> Cursor.insert_before([new_prev]) else # Delete prev_diff. Cursor.delete_before(diffs, 1) end new_cur = {op, best_edit} # Update this_diff. diffs = diffs |> Cursor.delete(1) |> Cursor.insert_before([new_cur]) # Now pointing at next_diff diffs = if best_equality2 != "" do new_next = {next_op, best_equality2} # Update next_diff diffs |> Cursor.delete(1) |> Cursor.insert_before([new_next]) |> Cursor.move_back(2) else # Delete next_diff diffs |> Cursor.delete(1) |> Cursor.move_back(1) end # Now back to pointing at this_diff diffs else diffs end else diffs end diffs |> Cursor.move_forward() |> cleanup_semantic_lossless_loop() end defp best_score_loop( equality1, edit, equality2, {best_score, _best_equality1, _best_edit, _best_equality2} = acc ) do if edit == "" || equality2 == "" do acc else # Second, step character by character right, looking for the best fit. {edit_first, edit} = String.split_at(edit, 1) {equality2_first, equality2} = String.split_at(equality2, 1) if edit_first != equality2_first do acc else equality1 = equality1 <> edit_first edit = edit <> equality2_first score1 = semantic_score(equality1, edit) score2 = semantic_score(edit, equality2) score = score1 + score2 # The >= encourages trailing rather than leading whitespace on edits. if score >= best_score do best_score_loop(equality1, edit, equality2, {score, equality1, edit, equality2}) else best_score_loop( equality1, edit, equality2, acc ) end end end end # Define some regex patterns for matching boundaries. @alphanumeric ~r/^[0-9A-Za-z]+$/ @whitespace ~r/^[\s]+$/ @line_break ~r/^[\r\n]+$/ @blank_line_end ~r/\n\r?\n\Z/ @blank_line_start ~r/\A\r?\n\r?\n/ @doc """ Given two strings, compute a score representing whether the internal boundary falls on logical boundaries. Scores range from 6 (best) to 0 (worst). * `one` - First string. * `two` - Second string. Scores are: * 6 if `one` or `two` is an empty string. * 5 if a blank line ends in `one` or a blank line starts in `two`. * 4 if `one` ends, or `two` starts, with a newline. * 3 if `one` ends in a punctuation and `two` starts with white space. * 2 if `one` ends, or `two` starts, with white space. * 1 if `one` ends, or `two` starts, with a non-alphanumeric. * 0 otherwise ## Examples iex> Diff.semantic_score("two is empty string", "") 6 iex> Diff.semantic_score("one ends in blank line\\n\\n", "two") 5 iex> Diff.semantic_score("one ends in new line\\n", "two") 4 iex> Diff.semantic_score("one sentence.", " space before two") 3 iex> Diff.semantic_score("one sentence.", "no space before two") 1 iex> Diff.semantic_score("one ends with white space ", "two") 2 iex> Diff.semantic_score("one ends in 'punctuation'", "two") 1 iex> Diff.semantic_score("one ends in middle of word", "two") 0 """ @spec semantic_score(String.t(), String.t()) :: non_neg_integer() def semantic_score("", _two), do: 6 def semantic_score(_one, ""), do: 6 # credo:disable-for-lines:38 Credo.Check.Refactor.CyclomaticComplexity def semantic_score(one, two) do char1 = String.last(one) char2 = String.first(two) non_alphanumeric1 = !Regex.match?(@alphanumeric, char1) non_alphanumeric2 = !Regex.match?(@alphanumeric, char2) whitespace1 = Regex.match?(@whitespace, char1) whitespace2 = Regex.match?(@whitespace, char2) line_break1 = Regex.match?(@line_break, char1) line_break2 = Regex.match?(@line_break, char2) blank_line1 = line_break1 && Regex.match?(@blank_line_end, one) blank_line2 = line_break2 && Regex.match?(@blank_line_start, two) cond do blank_line1 || blank_line2 -> # Five points for blank lines. 5 line_break1 || line_break2 -> # Four points for line breaks. 4 non_alphanumeric1 && !whitespace1 && whitespace2 -> # Three points for end of sentences. 3 whitespace1 || whitespace2 -> # Two points for whitespace. 2 non_alphanumeric1 || non_alphanumeric2 -> # One point for non-alphanumeric. 1 true -> 0 end end @typep efficiency_acc() :: { boolean(), diffqueue(), nil | String.t(), non_neg_integer(), non_neg_integer(), non_neg_integer(), non_neg_integer(), non_neg_integer() } @doc """ Reduce the number of edits in a diff by eliminating operationally trivial equalities. * `diff_edit_cost` Cost of an empty edit operation in terms of edit characters. Returns the updated difflist. """ @spec cleanup_efficiency(difflist(), non_neg_integer()) :: difflist() def cleanup_efficiency([], _diff_edit_cost), do: [] def cleanup_efficiency(diffs, diff_edit_cost) do {diffs, changes} = diffs |> Cursor.from_list(position: 0) |> cleanup_efficiency_loop( {false, :queue.new(), nil, 0, 0, 0, 0, 0}, diff_edit_cost ) if changes do cleanup_merge(diffs) else diffs end end # `equalities` - Double-ended queue of equalities. # `last_equality` - Always equal to the text of `equalities.get_r()` # `safe_diff` - The position of the last diff that is known to be unsplittable. # `pre_ins` - 1 if there is an insertion operation before the last equality. # `pre_del` - 1 if there is a deletion operation before the last equality. # `post_ins` - 1 if there is an insertion operation after the last equality. # `post_del` - 1 if there is a deletion operation after the last equality. # `diff_edit_cost` - Cost of an empty edit operation in terms of edit characters. @spec cleanup_efficiency_loop(Cursor.t(), efficiency_acc(), non_neg_integer()) :: {difflist(), boolean()} defp cleanup_efficiency_loop( %Cursor{current: nil} = diffs, {changes, _equalities, _last_equality, _safe_diff, _pre_ins, _pre_del, _post_ins, _post_del}, _diff_edit_cost ), do: {Cursor.to_list(diffs), changes} defp cleanup_efficiency_loop( %Cursor{current: {op, _} = this_diff} = diffs, acc, diff_edit_cost ) do {diffs, acc} = if op == :equal do # Equality found. handle_efficiency_equality(diffs, this_diff, acc, diff_edit_cost) else # An insertion or deletion. handle_efficiency_ins_del(diffs, this_diff, acc, diff_edit_cost) end diffs |> cleanup_efficiency_loop( acc, diff_edit_cost ) end defp handle_efficiency_equality( diffs, {_op, text}, {changes, equalities, _last_equality, safe_diff, pre_ins, pre_del, post_ins, post_del}, diff_edit_cost ) do eq_pos = Cursor.position(diffs) {equalities, last_equality, safe_diff, pre_ins, pre_del} = if String.length(text) < diff_edit_cost && (post_ins != 0 || post_del != 0) do # Candidate found. Insert at rear of queue. equalities = :queue.in(eq_pos, equalities) {equalities, text, safe_diff, post_ins, post_del} else # Not a candidate, and can never become one. # Remember our position. {:queue.new(), nil, eq_pos, pre_ins, pre_del} end {Cursor.move_forward(diffs), {changes, equalities, last_equality, safe_diff, pre_ins, pre_del, 0, 0}} end defp handle_efficiency_ins_del( diffs, {op, _text}, {changes, equalities, last_equality, safe_diff, pre_ins, pre_del, post_ins, post_del}, diff_edit_cost ) do # An insertion or deletion. {post_ins, post_del} = if op == :delete do {post_ins, 1} else {1, post_del} end # Five types to be split: # ABXYCD # AXCD # ABXC # AXCD # ABXC ins_del_count = pre_ins + pre_del + post_ins + post_del if !is_nil(last_equality) && (ins_del_count == 4 || (String.length(last_equality) * 2 < diff_edit_cost && ins_del_count == 3)) do # Walk back to offending equality. # Replace equality with a delete. # Insert a corresponding an insert. eq_pos = :queue.get_r(equalities) diffs = diffs |> Cursor.move_to(eq_pos) |> Cursor.delete(1) |> Cursor.insert_before([{:delete, last_equality}, {:insert, last_equality}]) {diffs, equalities, post_ins, post_del} = update_equalities_and_move_cursor(diffs, equalities, safe_diff, pre_ins, pre_del) {diffs, {true, equalities, nil, safe_diff, pre_ins, pre_del, post_ins, post_del}} else {Cursor.move_forward(diffs), {changes, equalities, last_equality, safe_diff, pre_ins, pre_del, post_ins, post_del}} end end defp update_equalities_and_move_cursor(diffs, equalities, safe_diff, pre_ins, pre_del) do # Throw away the equality we just deleted. equalities = safe_drop_r(equalities) if pre_ins != 0 && pre_del != 0 do # No changes made which could affect previous entry, keep going. {Cursor.move_forward(diffs), :queue.new(), 1, 1} else # Throw away the previous equality (it needs to be reevaluated). equalities = safe_drop_r(equalities) pos = case :queue.peek_r(equalities) do {:value, eq_pos} -> # There is an equality we can fall back to. eq_pos :empty -> # There are no previous questionable equalities, # walk back to the last known safe diff. safe_diff end {Cursor.move_to(diffs, pos), equalities, 0, 0} end end @doc """ Reorder and merge like edit sections in a diff, merging equalities. Any edit section can move as long as it doesn't cross an equality. Returns the updated difflist. """ @spec cleanup_merge(difflist()) :: difflist() def cleanup_merge([]), do: [] def cleanup_merge(diffs) do {diffs, changes} = diffs |> cleanup_merge_first_pass() |> cleanup_merge_second_pass() if changes do # If shifts were made, the diff needs reordering and another shift sweep. cleanup_merge(diffs) else diffs end end @spec cleanup_merge_first_pass(difflist()) :: difflist() defp cleanup_merge_first_pass([]), do: [] defp cleanup_merge_first_pass(diffs) do # Add a dummy entry at the end (diffs ++ [{:equal, ""}]) |> Cursor.from_list(position: 0) |> first_pass_loop({0, 0, "", ""}) |> remove_dummy() end @type first_pass_acc() :: {non_neg_integer(), non_neg_integer(), String.t(), String.t()} @spec first_pass_loop(Cursor.t(), first_pass_acc()) :: difflist() defp first_pass_loop(%Cursor{current: nil} = diffs, _acc) do Cursor.to_list(diffs) end defp first_pass_loop( %Cursor{} = diffs, {count_delete, count_insert, text_delete, text_insert} ) do {prev_diff, this_diff, _next_diff} = Cursor.get(diffs) {op, text} = this_diff {diffs, acc} = case op do :insert -> {diffs, {count_delete, count_insert + 1, text_delete, text_insert <> text}} :delete -> {diffs, {count_delete + 1, count_insert, text_delete <> text, text_insert}} :equal -> # Upon reaching an equality, check for prior redundancies. diffs = if count_delete + count_insert > 1 do combine_previous_inequalities( diffs, text, count_delete, count_insert, text_delete, text_insert ) else merge_with_previous_equality(diffs, text, prev_diff) end {diffs, {0, 0, "", ""}} _ -> raise RuntimeError, "Invalid operation #{inspect(op)}" end diffs |> Cursor.move_forward() |> first_pass_loop(acc) end def combine_previous_inequalities( diffs, text, count_delete, count_insert, text_delete, text_insert ) do # Delete the offending records diffs = Cursor.delete_before(diffs, count_delete + count_insert) {diffs, text_delete, text_insert} = if count_delete > 0 && count_insert > 0 do # Both types. # Factor out any common prefixes. {diffs, text_delete, text_insert} = factor_out_prefixes(diffs, text_delete, text_insert) # Factor out any common suffixes. factor_out_suffixes(diffs, text, text_delete, text_insert) else {diffs, text_delete, text_insert} end # Insert the merged records. diffs = if text_delete != "" do Cursor.insert_before(diffs, [{:delete, text_delete}]) else diffs end if text_insert != "" do Cursor.insert_before(diffs, [{:insert, text_insert}]) else diffs end end def factor_out_prefixes(diffs, text_delete, text_insert) do {prefix, text1, text2} = common_prefix(text_delete, text_insert) if prefix != "" do {prev_diff, _, _} = Cursor.get(diffs) diffs = if is_tuple(prev_diff) do {prev_op, prev_text} = prev_diff if prev_op != :equal do raise RuntimeError, "Previous diff should have been an equality." end new_prev = {:equal, prev_text <> prefix} diffs |> Cursor.delete_before(1) |> Cursor.insert_before([new_prev]) else new_head = {:equal, prefix} diffs |> Cursor.insert_at_head([new_head]) end {diffs, text1, text2} else {diffs, text_delete, text_insert} end end def factor_out_suffixes(diffs, text, text_delete, text_insert) do {suffix, text1, text2} = common_suffix(text_delete, text_insert) if suffix != "" do new_cur = {:equal, suffix <> text} diffs = diffs |> Cursor.delete(1) |> Cursor.insert([new_cur]) {diffs, text1, text2} else {diffs, text_delete, text_insert} end end defp merge_with_previous_equality(diffs, text, {:equal, prev_text}) do # Merge this equality with the previous one. new_cur = {:equal, prev_text <> text} diffs |> Cursor.move_back(1) |> Cursor.delete(2) |> Cursor.insert([new_cur]) end defp merge_with_previous_equality(diffs, _, _), do: diffs # Second pass: look for single edits surrounded on both sides by equalities # which can be shifted sideways to eliminate an equality. # e.g: ABAC -> ABAC @spec cleanup_merge_second_pass(difflist()) :: {difflist(), boolean()} defp cleanup_merge_second_pass([]), do: {[], false} defp cleanup_merge_second_pass(diffs) do {diffs, changes} = Cursor.from_list(diffs, position: 1) |> second_pass_loop(false) {diffs, changes} end @spec second_pass_loop(Cursor.t(), boolean()) :: {difflist(), boolean()} defp second_pass_loop(%Cursor{next: []} = diffs, changes), do: {Cursor.to_list(diffs), changes} defp second_pass_loop(%Cursor{} = diffs, changes) do {prev_diff, this_diff, next_diff} = Cursor.get(diffs) {prev_op, prev_text} = prev_diff {op, text} = this_diff {next_op, next_text} = next_diff {cursor, changes} = if prev_op == :equal && next_op == :equal do # This is a single edit surrounded by equalities. cond do String.ends_with?(text, prev_text) -> # Shift the edit over the previous equality. diffs = if prev_text != "" do text = prev_text <> substring(text, 0, String.length(text) - String.length(prev_text)) next_text = prev_text <> next_text new_cur_and_next = [{op, text}, {next_op, next_text}] # Delete this_diff and next_diff # Update this_diff and next_diff diffs |> Cursor.delete(2) |> Cursor.insert(new_cur_and_next) else diffs end # Delete prev_diff diffs = diffs |> Cursor.delete_before(1) {diffs, true} String.starts_with?(text, next_text) -> # Shift the edit over the next equality. prev_text = prev_text <> next_text text = substring(text, String.length(next_text)) <> next_text new_prev_and_cur = [{prev_op, prev_text}, {op, text}] # Delete prev_diff # Delete this_diff # Update prev_diff and this_diff # Delete next_diff diffs = diffs |> Cursor.move_back() |> Cursor.delete(2) |> Cursor.insert(new_prev_and_cur) |> Cursor.move_forward(2) |> Cursor.delete(1) {diffs, true} true -> {diffs, changes} end else {diffs, changes} end cursor |> Cursor.move_forward() |> second_pass_loop(changes) end # Remove the dummy entry at the end. @spec remove_dummy(difflist()) :: difflist() defp remove_dummy(diffs) do case List.last(diffs) do {_, ""} -> Enum.drop(diffs, -1) _ -> diffs end end @doc """ Given `loc`, a location in `text1`, compute and return the equivalent location in `text2`. * `diffs` - a difflist. * `loc` - Location within `text1`. Returns location within `text2`. ## Examples iex> Diff.main("The cat", "The big cat") |> Diff.x_index(1) 1 iex> Diff.main("The cat", "The big cat") |> Diff.x_index(4) 8 """ @spec x_index(difflist(), non_neg_integer()) :: non_neg_integer() def x_index(diffs, loc) do {last_diff, _chars1, _chars2, last_chars1, last_chars2} = Enum.reduce_while(diffs, {nil, 0, 0, 0, 0}, fn {op, text} = diff, {_last_diff, chars1, chars2, last_chars1, last_chars2} -> text_length = String.length(text) {chars1, chars2} = case op do :equal -> {chars1 + text_length, chars2 + text_length} :insert -> {chars1, chars2 + text_length} :delete -> {chars1 + text_length, chars2} _ -> raise RuntimeError, "Invalid operation #{inspect(op)}" end if chars1 > loc do # Overshot the location. {:halt, {diff, chars1, chars2, last_chars1, last_chars2}} else {:cont, {nil, chars1, chars2, chars1, chars2}} end end) case last_diff do {:delete, _} -> # The location was deleted last_chars2 _ -> # Add the remaining character length. last_chars2 + (loc - last_chars1) end end @html_entities [ {"&", "&"}, {"<", "<"}, {">", ">"}, {"\n", "¶
"} ] @doc """ Generate a pretty HTML report from a difflist. """ @spec pretty_html(difflist()) :: String.t() def pretty_html(diffs) do Enum.reduce(diffs, "", fn {op, text}, acc -> text = Enum.reduce(@html_entities, text, fn {from, to}, acc -> String.replace(acc, from, to) end) case op do :insert -> acc <> "" <> text <> "" :delete -> acc <> "" <> text <> "" :equal -> acc <> "" <> text <> "" _ -> raise RuntimeError, "Invalid operation #{inspect(op)}" end end) end @doc """ Compute and return the source text of a diff (all equalities and deletions). """ @spec text1(difflist()) :: String.t() def text1(diffs) do Enum.reduce(diffs, "", fn {op, text}, acc -> if op != :insert do acc <> text else acc end end) end @doc """ Compute and return the destination text of a diff (all equalities and insertions). """ @spec text2(difflist()) :: String.t() def text2(diffs) do Enum.reduce(diffs, "", fn {op, text}, acc -> if op != :delete do acc <> text else acc end end) end @doc """ Compute the Levenshtein distance of a diff--the number of inserted, deleted or substituted characters. """ @spec levenshtein(difflist()) :: non_neg_integer() def levenshtein(diffs) do {levenshtein, insertions, deletions} = Enum.reduce(diffs, {0, 0, 0}, fn {op, text}, {levenshtein, insertions, deletions} -> text_length = String.length(text) case op do :insert -> {levenshtein, insertions + text_length, deletions} :delete -> {levenshtein, insertions, deletions + text_length} :equal -> # A deletion and an insertion is one substitution. {levenshtein + max(insertions, deletions), 0, 0} _ -> raise RuntimeError, "Invalid operation #{inspect(op)}" end end) levenshtein + max(insertions, deletions) end @doc """ Crush a diff into an encoded string which describes the operations required to transform `text1` into `text2`. For example, "=3\t-2\t+ing" means keep 3 chars, delete 2 chars, insert "ing". Operations are tab-separated. Inserted text is escaped using %xx notation. ## Examples |> iex [{:equal, "abc"}, {:delete, "de"}, {:insert, "ing"}] |> to_delta() |> IO.inspect() "=3\\t-2\\t+ing" """ @spec to_delta(difflist()) :: String.t() def to_delta(diffs) do delta = Enum.reduce(diffs, "", fn {op, text}, acc -> case op do :insert -> acc <> "+" <> uri_encode(text) <> "\t" :delete -> acc <> "-#{String.length(text)}\t" :equal -> acc <> "=#{String.length(text)}\t" _ -> raise RuntimeError, "Invalid operation #{inspect(op)}" end end) # Strip off trailing tab character. delta |> String.replace_suffix("\t", "") |> unescape_for_encode_uri_compatability() end @doc """ Given the original `text1`, and an encoded string which describes the operations required to transform `text1` into `text2`, compute the full diff. * `text1 - Source string for the diff. * `delta - Encoded delta text. Returns a difflist. Raises an `ArgumentError` if the encoded delta has invalid contents for the given text. """ @spec from_delta(String.t(), String.t()) :: nil | difflist() def from_delta(text1, delta) do {diffs, pointer} = delta |> String.split("\t") |> Enum.reduce({[], 0}, fn token, {diffs, pointer} -> parse_delta_token(token, diffs, pointer, text1) end) if pointer != String.length(text1) do raise ArgumentError, "Delta length (#{pointer}) smaller than source text length (#{String.length(text1)})" end Enum.reverse(diffs) end # @variable token # @accumulator {diffs, pointer} # @constant text1 defp parse_delta_token("", diffs, pointer, _), do: {diffs, pointer} defp parse_delta_token(token, diffs, pointer, text1) do # Each token begins with a one character parameter which specifies the # operation of this token (delete, insert, equality). {op, param} = String.split_at(token, 1) if op == "+" do # decode would change all "+" to " " param = param |> String.replace("+", "%2B") |> URI.decode() {[{:insert, param} | diffs], pointer} else n = parse_delta_integer_param(param, pointer, text1) text = substring(text1, pointer, pointer + n) case op do "=" -> {[{:equal, text} | diffs], pointer + n} "-" -> {[{:delete, text} | diffs], pointer + n} _ -> raise ArgumentError, "Invalid diff operation in from_delta: '#{op}'" end end end defp parse_delta_integer_param(param, pointer, text1) do case Integer.parse(param) do {n, ""} -> cond do n < 0 -> raise ArgumentError, "Negative number in from_delta: #{param}" pointer + n > String.length(text1) -> raise ArgumentError, "Delta length (#{pointer + n}) larger than source text length (#{String.length(text1)})" true -> n end _ -> raise ArgumentError, "Invalid number in from_delta: #{param}" end end @doc """ Returns the diff tuple, or a "nil" pseudo-diff (with op `:nil` and empty text). """ @spec undiff(nil | t()) :: t() def undiff({op, text}), do: {op, text} def undiff(nil), do: {nil, ""} end