defmodule Diffie do @moduledoc ~S""" Diffie is a module for making human-readable reports of the differences between two strings, or between two lists of objects (of whatever type). NOT FOR DIFFIE-HELLMAN KEY EXCHANGE! """ @doc ~S""" `diff_report(old, new, opts \\ [])` `diff_report` returns a string containing a report (hence the name), similar to a simplified version of the `diff` command-line utility, of the differences between the two strings, or two lists. Given two strings, by default it works line by line, and is case-sensitive, but you can change this with options, as follows: - `:ignore_case` - (boolean) be case-insensitive when comparing the strings. Does not get applied to strings _within objects_. If you want that, you will have to use a custom transformation function (see below). Also applicable to the version that takes two lists, _if_ you pass it two lists _of strings_. - `:omit_deletes` - (boolean) omit deleted items, and old versions of changed items. Also applicable to the version that takes two lists. Causes the new version of a changed item to be marked with "Updated" rather than "Into". - `:split_on` - (string or regex) split strings on this, not \n. - `:transform` - (function reference) function to apply to items in the report. Also applicable to the version that takes two lists. If you do _not_ supply a transformation: - strings will be handled as-is, - items that implement the `String.chars` protocol will be sent to `to_string`, - and anything else will be sent to `inspect`. If your list items do not implement `String.chars` and you wish to apply some transformation, that transformation must include turning them into something that does, such as piping through `inspect`. ## Examples: ``` iex> Diffie.diff_report("foo\nbar", "foo\nbar\nbaz") "Added:\n> baz" iex> Diffie.diff_report("foo\nbar\nbaz", "foo\nbaz") "Removed:\n< bar" iex> Diffie.diff_report("foo\nbar\nbaz", "foo\nboo\nbaz") "Changed:\n< bar\n\nInto:\n> boo" iex> Diffie.diff_report("fooXXbarXXbaz", "fooXXbaz", split_on: "XX") "Removed:\n< bar" iex> Diffie.diff_report("bar", "baz", split_on: "") "Changed:\n< r\n\nInto:\n> z" iex> Diffie.diff_report("foo\nbar\nbaz", "foo\nboo\nbaz", omit_deletes: true) "Updated:\n> boo" # note removal of "Changed:\n< r\n\n" and different marker iex> Diffie.diff_report("foo bar\nbaz quux", "fox bear\nbaz quix", ...> split_on: ~r{\s}) "Changed:\n< foo\n< bar\n\nInto:\n> fox\n> bear\n\nChanged:\n< quux\n\nInto:\n> quix" iex> Diffie.diff_report("foo\nbar\nbaz", "foo\nbaz", transform: &String.upcase/1) "Removed:\n< BAR" iex> Diffie.diff_report("The quick brown fox jumps\nover the lazy dog.", ...> "That fox\njumps quickly over\nthe dig!", ...> split_on: ~r/\s/, transform: &String.upcase/1) "Changed:\n< THE\n< QUICK\n< BROWN\n\nInto:\n> THAT\n\nAdded:\n> QUICKLY\n\nChanged:\n< LAZY\n< DOG.\n\nInto:\n> DIG!" iex> Diffie.diff_report("The quick brown fox jumps over the lazy dog.", ...> "That fox jumps quickly over the dig!", ...> split_on: ~r/\s/, omit_deletes: true) "Updated:\n> That\n\nAdded:\n> quickly\n\nUpdated:\n> dig!" iex> Diffie.diff_report("The quick brown fox jumps over the lazy dog", ...> "That fox jumps quickly", ...> split_on: ~r/\s/, omit_deletes: true) "Updated:\n> That\n\nUpdated:\n> quickly" iex> Diffie.diff_report("The quick brown fox jumps over the lazy dog", ...> "That fox jumps quickly over", ...> split_on: ~r/\s/, omit_deletes: true) "Updated:\n> That\n\nAdded:\n> quickly" iex> Diffie.diff_report([1,2], [1,2,3]) "Added:\n> 3" iex> Diffie.diff_report([1,2,3], [1,2]) "Removed:\n< 3" iex> Diffie.diff_report([1,2,3,4,6], [1,2,5]) "Changed:\n< 3\n< 4\n< 6\n\nInto:\n> 5" iex> Diffie.diff_report([1,2,3,4,6], [1,2,5,6,7], omit_deletes: true) "Updated:\n> 5\n\nAdded:\n> 7" # note removal of "Changed:\n< 3\n\n", and different marker iex> Diffie.diff_report([1,2,3], [1,2], omit_deletes: true) "" iex> Diffie.diff_report([1,2], [1,2,3], transform: fn x->x*2 end) "Added:\n> 6" iex> alice = %{name: "Alice", age: "29"} iex> bob = %{name: "Bob", age: "52"} iex> Diffie.diff_report([alice], [alice,bob]) "Added:\n> %{name: \"Bob\", age: \"52\"}" iex> alice = %{name: "Alice", age: "29"} iex> bob = %{name: "Bob", age: "52"} iex> Diffie.diff_report([alice], [alice, bob], ...> transform: fn p -> Map.delete(p, :age) end) ** (Protocol.UndefinedError) protocol String.Chars not implemented for %{name: "Bob"} of type Map. This protocol is implemented for the following type(s): Atom, BitString, Date, DateTime, Float, Integer, List, NaiveDateTime, Time, URI, Version, Version.Requirement iex> alice = %{name: "Alice", age: "29"} iex> bob = %{name: "Bob", age: "52"} iex> Diffie.diff_report([alice], [alice, bob], ...> transform: fn p -> Map.delete(p, :age) |> inspect end) "Added:\n> %{name: \"Bob\"}" iex> alice = %{name: "Alice", age: "29"} iex> bob = %{name: "Bob", age: "52"} iex> Diffie.diff_report([alice], [alice, bob], ...> transform: fn p -> p.name end) "Added:\n> Bob" ``` """ def diff_report(old, new, opts \\ []) def diff_report(old_str, new_str, opts) when is_binary(old_str) and is_binary(new_str) do split_on = opts[:split_on] || "\n" diff_report(String.split(old_str, split_on), String.split(new_str, split_on), opts) end def diff_report(old_list, new_list, opts) when is_list(old_list) and is_list(new_list) do if opts[:ignore_case] && ! (old_list |> hd |> is_binary) do "ERROR: you said to ignore case, but the list is not of strings!" else List.myers_difference(old_list, new_list) |> fix_diffs(%{ignore_case: !!opts[:ignore_case], omit_deletes: !!opts[:omit_deletes]}, []) |> make_report(opts[:transform]) end end # if we have a del and an ins, and we're omitting deletes, # process as an update defp fix_diffs([del={:del, _}|[ins={:ins, _}|rest]], opts, acc) do process_update(rest, opts, del, ins, acc) end # same if the other way round defp fix_diffs([ins={:ins, _}|[del={:del, _}|rest]], opts, acc) do process_update(rest, opts, del, ins, acc) end # else if we have a delete and we're omitting them, just move on defp fix_diffs([{:del, _}|rest], opts=%{omit_deletes: true}, acc) do fix_diffs(rest, opts, acc) end # else if we have a delete (since we're not omitting them), do it defp fix_diffs([del={:del, _}|rest], opts, acc) do fix_diffs(rest, opts, [del|acc]) end # same for insert defp fix_diffs([ins={:ins, _}|rest], opts, acc) do fix_diffs(rest, opts, [ins|acc]) end # if we have an eq, just move on defp fix_diffs([{:eq, _}|rest], opts, acc) do fix_diffs(rest, opts, acc) end # else if end of list, we're done defp fix_diffs([], _opts, acc), do: Enum.reverse(acc) defp process_update(rest, opts, {:del, dels}, {:ins, inss}, acc) do new_acc = cond do really_same(dels, inss, opts) -> acc opts[:omit_deletes] -> [{:upd, inss}|acc] true -> [{:new, inss}|[{:old, dels}|acc]] end fix_diffs(rest, opts, new_acc) end defp really_same(same, same, _opts), do: true defp really_same(dels, inss, %{ignore_case: true}) do if Enum.count(dels) == Enum.count(inss) do downcases_match(dels, inss) else false end end defp really_same(_, _, _), do: false defp downcases_match([dhead|drest], [ihead|irest]) do if String.downcase(dhead) == String.downcase(ihead) do downcases_match(drest, irest) else false end end defp downcases_match([], []), do: true defp make_report(results, transform_func, acc \\ []) defp make_report([{:eq, _}|rest], tf, acc), do: make_report(rest, tf, acc) defp make_report([{comp, items}|rest], transform_func, acc) do [word, sym] = case comp do :del -> ["Removed", "<"] :ins -> ["Added", ">"] :old -> ["Changed", "<"] :new -> ["Into", ">"] :upd -> ["Updated", ">"] _ -> ["Unknown comparison '#{comp}'", "?"] end diffs = items |> Enum.map(fn item -> "#{sym} #{transform(item, transform_func)}" end) |> Enum.join("\n") make_report(rest, transform_func, ["#{word}:\n#{diffs}" | acc]) end defp make_report([], _, acc), do: acc |> Enum.reverse |> Enum.join("\n\n") defp transform(item, nil) do cond do is_binary(item) -> item String.Chars.impl_for(item) -> to_string(item) true -> inspect(item) end end defp transform(item, func), do: func.(item) end