Current section
Files
Jump to
Current section
Files
lib/diff.ex
defmodule Calque.Diff do
@moduledoc """
Utilities to render simple line-by-line diffs used by Calque's CLI output.
"""
@type diff_line_kind :: :old | :new | :shared
@type tagged_line :: %{kind: diff_line_kind(), line: String.t()}
@type diff_line :: %{number: pos_integer(), line: String.t(), kind: diff_line_kind()}
# Internal histogram occurrence type
@type occurrence ::
{:one, non_neg_integer(), [String.t()], [String.t()]}
| {:other, non_neg_integer(), [String.t()], [String.t()]}
| {:both, non_neg_integer(), [String.t()], [String.t()], [String.t()], [String.t()]}
@doc false
@spec line_by_line(String.t(), String.t()) :: [diff_line()]
def line_by_line("", ""), do: [%{number: 1, line: "", kind: :shared}]
def line_by_line(old, new) when is_binary(old) and is_binary(new) do
old_lines = split_lines(old)
new_lines = split_lines(new)
lcs = longest_chain(old_lines, new_lines)
match_diff_lines([], lcs, 1, old_lines, 1, new_lines)
end
def line_by_line(_, _), do: []
@doc false
@spec split_lines(String.t()) :: [String.t()]
defp split_lines(""), do: []
defp split_lines(text), do: String.split(text, "\n", trim: false)
# -------------------------
# DIFF RECONSTRUCTION FROM LCS (HISTOGRAM-BASED)
# -------------------------
# Accumulator is built in reverse, then flipped at the end.
@doc false
@spec match_diff_lines(
[diff_line()],
[String.t()],
pos_integer(),
[String.t()],
pos_integer(),
[String.t()]
) :: [diff_line()]
defp match_diff_lines(acc, lcs, old_idx, old_lines, new_idx, new_lines) do
case {lcs, old_lines, new_lines} do
# Everything drained: return accumulated result in correct order
{[], [], []} ->
Enum.reverse(acc)
# No more common lines; drain remaining old_lines as :old
{[], [old | old_rest], new_lines} ->
match_diff_lines(
[%{number: old_idx, line: old, kind: :old} | acc],
lcs,
old_idx + 1,
old_rest,
new_idx,
new_lines
)
# No more common lines; old is empty; drain remaining new_lines as :new
{[], [], [new | new_rest]} ->
match_diff_lines(
[%{number: new_idx, line: new, kind: :new} | acc],
lcs,
old_idx,
[],
new_idx + 1,
new_rest
)
# While the old side has lines that are not the next common line → :old
{[common | _] = lcs, [old | old_rest], new_lines} when old != common ->
match_diff_lines(
[%{number: old_idx, line: old, kind: :old} | acc],
lcs,
old_idx + 1,
old_rest,
new_idx,
new_lines
)
# While the new side has lines that are not the next common line → :new
{[common | _] = lcs, old_lines, [new | new_rest]} when new != common ->
match_diff_lines(
[%{number: new_idx, line: new, kind: :new} | acc],
lcs,
old_idx,
old_lines,
new_idx + 1,
new_rest
)
# Both lists start with the same common element → :shared
{[common | lcs_rest], [_ | old_rest], [_ | new_rest]} ->
match_diff_lines(
[%{number: new_idx, line: common, kind: :shared} | acc],
lcs_rest,
old_idx + 1,
old_rest,
new_idx + 1,
new_rest
)
end
end
# -------------------------
# HISTOGRAM-BASED LCS ("LONGEST CHAIN")
# -------------------------
@doc false
@spec longest_chain([String.t()], [String.t()]) :: [String.t()]
defp longest_chain(old_lines, new_lines) do
{prefix, old_rest, new_rest} = pop_common_prefix(old_lines, new_lines)
{suffix, old_rest, new_rest} = pop_common_suffix(old_rest, new_rest)
case lowest_occurrence_common_item(old_rest, new_rest) do
nil ->
prefix ++ suffix
{item, _count, before_old, after_old, before_new, after_new} ->
prefix ++
longest_chain(Enum.reverse(before_old), Enum.reverse(before_new)) ++
[item] ++
longest_chain(after_old, after_new) ++
suffix
end
end
# -------------------------
# HISTOGRAM & PIVOT SELECTION
# -------------------------
@doc false
@spec lowest_occurrence_common_item([String.t()], [String.t()]) ::
{String.t(), non_neg_integer(), [String.t()], [String.t()], [String.t()], [String.t()]}
| nil
defp lowest_occurrence_common_item(old_lines, new_lines) do
histogram =
%{}
|> histogram_add(old_lines, :one, [])
|> histogram_add(new_lines, :other, [])
Enum.reduce(histogram, nil, fn
{_line, {:one, _n, _b, _a}}, acc ->
acc
{_line, {:other, _n, _b, _a}}, acc ->
acc
{line, {:both, n, before_old, after_old, before_new, after_new}}, nil ->
{line, n, before_old, after_old, before_new, after_new}
{line, {:both, n, before_old, after_old, before_new, after_new}},
{_best_line, best_n, _bo, _ao, _bn, _an} = acc ->
if n < best_n do
{line, n, before_old, after_old, before_new, after_new}
else
acc
end
end)
end
@doc false
@spec histogram_add(
%{optional(String.t()) => occurrence()},
[String.t()],
:one | :other,
[String.t()]
) :: %{optional(String.t()) => occurrence()}
defp histogram_add(histogram, [], _which, _reverse_prefix), do: histogram
defp histogram_add(histogram, [line | rest], which, reverse_prefix) do
new_occurrence =
case which do
:one -> {:one, 1, reverse_prefix, rest}
:other -> {:other, 1, reverse_prefix, rest}
end
updated_occurrence =
case Map.get(histogram, line) do
nil -> new_occurrence
previous -> sum_occurrences(previous, new_occurrence)
end
histogram
|> Map.put(line, updated_occurrence)
|> histogram_add(rest, which, [line | reverse_prefix])
end
@doc false
@spec sum_occurrences(occurrence(), occurrence()) :: occurrence()
defp sum_occurrences({:one, n, _b1, _a1}, {:one, m, b2, a2}) do
{:one, n + m, b2, a2}
end
defp sum_occurrences({:other, n, _b1, _a1}, {:other, m, b2, a2}) do
{:other, n + m, b2, a2}
end
defp sum_occurrences({:one, n, before_old, after_old}, {:other, m, before_new, after_new}) do
{:both, n + m, before_old, after_old, before_new, after_new}
end
defp sum_occurrences(
{:both, n, before_old, after_old, _b2, _a2},
{:other, m, before_new, after_new}
) do
{:both, n + m, before_old, after_old, before_new, after_new}
end
# -------------------------
# LIST UTILITIES (COMMON PREFIX/SUFFIX)
# -------------------------
@doc false
@spec pop_common_prefix([String.t()], [String.t()]) ::
{[String.t()], [String.t()], [String.t()]}
defp pop_common_prefix(old_lines, new_lines) do
{rev_prefix, rest_old, rest_new} = do_pop_common_prefix([], old_lines, new_lines)
{Enum.reverse(rev_prefix), rest_old, rest_new}
end
@doc false
@spec do_pop_common_prefix([String.t()], [String.t()], [String.t()]) ::
{[String.t()], [String.t()], [String.t()]}
defp do_pop_common_prefix(rev_prefix, [old | rest_old], [new | rest_new]) when old == new do
do_pop_common_prefix([old | rev_prefix], rest_old, rest_new)
end
defp do_pop_common_prefix(rev_prefix, old_lines, new_lines) do
{rev_prefix, old_lines, new_lines}
end
@doc false
@spec pop_common_suffix([String.t()], [String.t()]) ::
{[String.t()], [String.t()], [String.t()]}
defp pop_common_suffix(old_lines, new_lines) do
{suffix, rev_old, rev_new} =
do_pop_common_prefix([], Enum.reverse(old_lines), Enum.reverse(new_lines))
{suffix, Enum.reverse(rev_old), Enum.reverse(rev_new)}
end
end