Packages

A port of the Hunt-Szymanski diff algorithm to Elixir, with a focus on performance and memory efficiency.

Current section

Files

Jump to
hunt_szymanski_diff lib h_s_diff.ex
Raw

lib/h_s_diff.ex

defmodule HSDiff do
@moduledoc """
Provides a Hunt–Szymanski-based line diff:
1) Compute the LCS (Longest Common Subsequence) using the Hunt–Szymanski approach.
2) Build a diff result (eq/del/ins) from that LCS.
Typically used for large lists of lines where changes are relatively sparse.
This version also handles cases where left/right differ in length or contain
multiple occurrences of the same line.
"""
defdelegate optimize(diff), to: HSDiff.Optimize
defdelegate patch(old, diff), to: HSDiff.Patch
@doc """
diff/2 entry point:
diff(left, right)
If `left` and `right` are strings, we split them by `\n` into lists of lines.
If `left` and `right` are already lists, we pass them along.
"""
def diff(left, right) when is_list(left) and is_list(right) do
lcs_result = hunt_szymanski_lcs(left, right)
build_diff(left, right, lcs_result)
end
def diff(left, right) when is_binary(left) and is_binary(right) do
diff(String.split(left, "\n"), String.split(right, "\n"))
end
# --------------------------------------------------------------------------------
# HUNT–SZYMANSKI LCS
# --------------------------------------------------------------------------------
@doc """
Compute the LCS of two lists (commonly lines) via Hunt–Szymanski.
Returns just the **list of common elements** in order.
You can then convert that LCS into a diff using `build_diff/3` or your own logic.
"""
def hunt_szymanski_lcs(left, right) do
pos_map = build_positions_map(right)
# We'll maintain a structure for the "Longest Increasing Subsequence" (LIS)
# on the indexes in `right`.
lis_state = %{
tails: [],
links: %{},
length: 0
}
{final_lis_state, _count} =
Enum.reduce(left, {lis_state, 0}, fn line, {state, count} ->
case Map.get(pos_map, line) do
nil ->
# line not in right at all
{state, count}
positions ->
new_state = Enum.reduce(positions, state, &update_lis/2)
{new_state, count + 1}
end
end)
build_lcs_from_lis(final_lis_state, right)
end
defp build_positions_map(right) do
right
|> Enum.with_index()
|> Enum.reduce(%{}, fn {line, idx}, acc ->
Map.update(acc, line, [idx], fn old -> [idx | old] end)
end)
# we collected indexes in reverse; reverse each list to ascending
|> Enum.map(fn {line, idxs} -> {line, Enum.reverse(idxs)} end)
|> Map.new()
end
defp update_lis(i, %{tails: tails, links: links, length: length} = state) do
{pos, _} = binary_search_lis(tails, i, 0, length - 1)
new_tails =
if pos == length do
tails ++ [i]
else
List.replace_at(tails, pos, i)
end
new_links =
if pos > 0 do
Map.put(links, i, Enum.at(new_tails, pos - 1))
else
links
end
new_length = if pos == length, do: length + 1, else: length
%{state | tails: new_tails, links: new_links, length: new_length}
end
defp binary_search_lis(_tails, _i, low, high) when high < low, do: {low, nil}
defp binary_search_lis(tails, i, low, high) do
mid = div(low + high, 2)
mid_val = Enum.at(tails, mid)
cond do
i == mid_val ->
{mid, mid_val}
i < mid_val ->
binary_search_lis(tails, i, low, mid - 1)
true ->
binary_search_lis(tails, i, mid + 1, high)
end
end
defp build_lcs_from_lis(%{tails: tails, links: links, length: length}, right) do
if length == 0 do
[]
else
last_idx = Enum.at(tails, length - 1)
path = unwind_lis(last_idx, links, [])
# Convert indexes to lines
Enum.map(path, &Enum.at(right, &1))
end
end
defp unwind_lis(nil, _links, acc), do: acc
defp unwind_lis(idx, links, acc) do
next = Map.get(links, idx, nil)
unwind_lis(next, links, [idx | acc])
end
# --------------------------------------------------------------------------------
# DIFF BUILDING (POINTER-BASED)
# --------------------------------------------------------------------------------
@doc """
Build a [eq: [...], del: [...], ins: [...]] diff from `left`, `right`,
and `lcs` lines, using pointer arithmetic with a `reduce_while` call.
We return `{:cont, {acc, i, j}}` to keep going,
or `{:halt, {acc, i, j}}` if we detect the LCS line can't be found.
After the reduce, we handle leftover lines in `left` or `right`.
"""
def build_diff(left, right, lcs) do
# 1. Run reduce_while to walk through LCS lines in order
final_state =
Enum.reduce_while(lcs, {[], 0, 0}, fn lcs_line, {acc, li, rj} ->
case next_index_of(left, lcs_line, li) do
nil ->
# Can't find LCS line in left => fallback: leftover is all changed
leftover_left = Enum.slice(left, li, length(left) - li)
leftover_right = Enum.slice(right, rj, length(right) - rj)
new_acc =
acc
|> maybe_add_block({:del, leftover_left})
|> maybe_add_block({:ins, leftover_right})
{:halt, {new_acc, length(left), length(right)}}
found_li ->
case next_index_of(right, lcs_line, rj) do
nil ->
# Can't find LCS line in right => fallback
leftover_left = Enum.slice(left, li, length(left) - li)
leftover_right = Enum.slice(right, rj, length(right) - rj)
new_acc =
acc
|> maybe_add_block({:del, leftover_left})
|> maybe_add_block({:ins, leftover_right})
{:halt, {new_acc, length(left), length(right)}}
found_rj ->
# Lines before found_li => :del
segment_left = Enum.slice(left, li, found_li - li)
# Lines before found_rj => :ins
segment_right = Enum.slice(right, rj, found_rj - rj)
new_acc =
acc
|> maybe_add_block({:del, segment_left})
|> maybe_add_block({:ins, segment_right})
|> maybe_add_block({:eq, [lcs_line]})
# move pointers beyond the matched line
{:cont, {new_acc, found_li + 1, found_rj + 1}}
end
end
end)
# 2. Now `final_state` is EITHER:
# - {acc, i, j}, if we never halted
# - {:halt, {acc, i, j}}, if we halted early
{blocks, i, j} =
case final_state do
{:halt, triple} -> triple
triple -> triple
end
# 3. Add leftover lines after we finish or halt
leftover_left = Enum.slice(left, i, length(left) - i)
leftover_right = Enum.slice(right, j, length(right) - j)
blocks
|> maybe_add_block({:del, leftover_left})
|> maybe_add_block({:ins, leftover_right})
|> Enum.reverse()
end
defp next_index_of(list, item, start_index) do
Enum.drop(list, start_index)
|> Enum.find_index(&(&1 == item))
|> case do
nil -> nil
offset -> start_index + offset
end
end
defp maybe_add_block(acc, {_tag, []}), do: acc
defp maybe_add_block(acc, block), do: [block | acc]
end