Current section
Files
Jump to
Current section
Files
lib/diffident.ex
defmodule Diffident do
@moduledoc """
Structured diffs between two Elixir values.
Returns a serializable list of tagged tuples describing the differences between two values.
Examples
iex> Diffident.explain([:x, :y], [:x, :z, :w])
[
{:changed, [{Access, :at, [1]}], :y, :z},
{:added, [{Access, :at, [2]}], :w}
]
iex> result = Diffident.explain([a: 1, b: 2], [a: 1, b: 3, c: 4])
iex> length(result)
2
iex> {:changed, [:b], 2, 3} in result
true
iex> {:added, [:c], 4} in result
true
iex> result = Diffident.explain(%{a: 1, b: [1,2]}, %{a: 2, b: [1,3], c: 9})
iex> length(result)
3
iex> {:changed, [:a], 1, 2} in result
true
iex> {:changed, [:b, {Access, :at, [1]}], 2, 3} in result
true
iex> {:added, [:c], 9} in result
true
"""
@type access_mfa :: {Access, atom(), list(any())}
@type path_entry :: access_mfa() | any()
@type path :: list(path_entry())
@doc """
Returns a list of tagged tuples describing differences between `left` and `right`.
No output (empty list) means the values are equal by structural comparison.
## Formats
- `{:added, path, value}` - a new key/element was added
- `{:removed, path, value}` - a key/element was removed
- `{:changed, path, old_value, new_value}` - a value changed
- `{:type_changed, path, old_value, new_value}` - the type of value changed
- `{:tuple_size_changed, path, old_size, new_size}` - tuple size changed
The `path` is a list suitable for use with `get_in/2` and `put_in/3`.
List indices use `{Access, :at, [index]}` and tuple indices use `{Access, :elem, [index]}`.
"""
@spec explain(any(), any()) :: [
{:added, path(), any()}
| {:removed, path(), any()}
| {:changed, path(), any(), any()}
| {:type_changed, path(), any(), any()}
| {:tuple_size_changed, path(), non_neg_integer(), non_neg_integer()}
]
def explain(left, right) do
diff(left, right, [])
end
@doc """
Gets a value from `data` using a path in the format returned by `explain/2`.
"""
@spec get_in(any(), path()) :: any()
def get_in(data, path) do
resolved_path = to_access_compatible(path)
Kernel.get_in(data, resolved_path)
end
@doc """
Puts a value into `data` at the location specified by a path in the format
returned by `explain/2`, returning the updated data structure.
"""
@spec put_in(any(), path(), any()) :: any()
def put_in(data, path, value) do
resolved_path = to_access_compatible(path)
Kernel.put_in(data, resolved_path, value)
end
@doc """
Converts a path from `explain/2` to a format usable with `get_in/2` and `put_in/3`.
Converts MFA tuples like `{Access, :at, [1]}` to actual function calls.
## Examples
iex> Diffident.to_access_compatible([:a, {Access, :at, [1]}])
[:a, Access.at(1)]
iex> Diffident.to_access_compatible([{Access, :elem, [0]}, :field])
[Access.elem(0), :field]
"""
@spec to_access_compatible(path()) :: [any()]
def to_access_compatible(path) do
Enum.map(path, fn
{mod, fun, args} -> apply(mod, fun, args)
other -> other
end)
end
## Core dispatcher
defp diff(x, y, _path) when x === y, do: []
# Structs: compare type first, then fields
defp diff(%_{} = x, %_{} = y, path) do
xtype = x.__struct__
ytype = y.__struct__
if xtype != ytype do
[{:type_changed, Enum.reverse(path), xtype, ytype}]
else
diff(Map.from_struct(x), Map.from_struct(y), path)
end
end
# Maps
defp diff(%{} = x, %{} = y, path) do
x_keys = Map.keys(x) |> MapSet.new()
y_keys = Map.keys(y) |> MapSet.new()
removed =
MapSet.difference(x_keys, y_keys)
|> Enum.map(fn k ->
{:removed, Enum.reverse([k | path]), Map.fetch!(x, k)}
end)
added =
MapSet.difference(y_keys, x_keys)
|> Enum.map(fn k ->
{:added, Enum.reverse([k | path]), Map.fetch!(y, k)}
end)
changed =
MapSet.intersection(x_keys, y_keys)
|> Enum.flat_map(fn k ->
diff(Map.fetch!(x, k), Map.fetch!(y, k), [k | path])
end)
removed ++ added ++ changed
end
# Keyword lists (prefer key-based comparison if keys are unique)
defp diff(x, y, path) when is_list(x) and is_list(y) do
if Keyword.keyword?(x) and Keyword.keyword?(y) do
if unique_keys?(x) and unique_keys?(y) do
diff(Map.new(x), Map.new(y), path)
else
# fall back to index-by-index comparison
diff_list_by_index(x, y, path)
end
else
diff_list_by_index(x, y, path)
end
end
# Lists (non-keyword)
defp diff(x, y, path) when is_list(x) and is_list(y) do
diff_list_by_index(x, y, path)
end
# Tuples: compare arity then elements
defp diff(x, y, path) when is_tuple(x) and is_tuple(y) do
if tuple_size(x) != tuple_size(y) do
[{:tuple_size_changed, Enum.reverse(path), tuple_size(x), tuple_size(y)}]
else
x_list = Tuple.to_list(x)
Enum.with_index(x_list)
|> Enum.flat_map(fn {xe, i} ->
diff(xe, elem(y, i), [{Access, :elem, [i]} | path])
end)
end
end
# Different types entirely
defp diff(x, y, path) do
if same_type?(x, y) do
# Primitive/value change
[{:changed, Enum.reverse(path), x, y}]
else
[{:type_changed, Enum.reverse(path), x, y}]
end
end
## Helpers
defp diff_list_by_index(x, y, path) do
max = max(length(x), length(y))
0..(max - 1)
|> Enum.flat_map(fn i ->
xi = Enum.fetch(x, i)
yi = Enum.fetch(y, i)
case {xi, yi} do
{:error, {:ok, v}} ->
[{:added, Enum.reverse([{Access, :at, [i]} | path]), v}]
{{:ok, v}, :error} ->
[{:removed, Enum.reverse([{Access, :at, [i]} | path]), v}]
{{:ok, xv}, {:ok, yv}} ->
diff(xv, yv, [{Access, :at, [i]} | path])
end
end)
end
defp same_type?(a, b), do: type_tag(a) == type_tag(b)
defp type_tag(%_{} = s), do: {:struct, s.__struct__}
defp type_tag(v) when is_map(v), do: :map
defp type_tag(v) when is_list(v) do
if Keyword.keyword?(v), do: :keyword, else: :list
end
defp type_tag(v) when is_tuple(v), do: :tuple
defp type_tag(v) when is_integer(v), do: :integer
defp type_tag(v) when is_float(v), do: :float
defp type_tag(v) when is_binary(v), do: :binary
defp type_tag(v) when is_atom(v), do: :atom
defp type_tag(v) when is_boolean(v), do: :boolean
defp type_tag(_), do: :other
defp unique_keys?(kw) do
keys = Keyword.keys(kw)
MapSet.size(MapSet.new(keys)) == length(keys)
end
end