@@ -79,6 +79,174 @@
end
end
@typedoc """
A single conflicting region, with each side's lines and the 0-based line
index at which that region starts in each version. `base_start` indexes the
common ancestor, `ours_start` the ours version, `theirs_start` the theirs
version — so a caller can pull surrounding context from the full files and
render real line numbers per side.
"""
@type region :: %{
base_start: non_neg_integer(),
base_lines: [String.t()],
ours_start: non_neg_integer(),
ours_lines: [String.t()],
theirs_start: non_neg_integer(),
theirs_lines: [String.t()]
}
@doc """
Localize the conflicting regions of a three-way merge.
Uses the same stable-anchor analysis as `merge/3`, but instead of collapsing
to a single `:conflict` it returns every region that cannot be auto-merged,
each anchored to its real line offset in base/ours/theirs. This is what a
conflict *visualization* needs: the specific hunks that block a merge, not
three whole files.
* `{:clean, nil}` — no region conflicts (the file merges cleanly)
* `{:conflict, [region]}` — one entry per conflicting region, in file order
* `:binary` — a side contains a NUL byte; not line-mergeable (like git)
"""
@spec conflict_regions(binary(), binary(), binary()) ::
{:clean, nil} | {:conflict, [region()]} | :binary
def conflict_regions(base, ours, theirs)
when is_binary(base) and is_binary(ours) and is_binary(theirs) do
if binary?(base) or binary?(ours) or binary?(theirs) do
:binary
else
regions =
for {:conflict, region} <- segments(split(base), split(ours), split(theirs)),
do: region
if regions == [], do: {:clean, nil}, else: {:conflict, regions}
end
end
@doc """
Render a three-way merge as a conflict-marker file (diff3 style).
Clean regions are emitted verbatim; each conflicting region is wrapped in the
canonical `git merge-file --diff3` markers so the result is a ready-to-edit
starting point for a human resolving the conflict:
<<<<<<< ours-label
...ours...
||||||| base-label
...base...
=======
...theirs...
>>>>>>> theirs-label
Options: `:ours_label`, `:base_label`, `:theirs_label` (all default to the
bare side name). Returns `{:clean, bytes}` when nothing conflicts,
`{:conflict, bytes}` when markers were inserted, or `:binary`.
"""
@spec merge_markers(binary(), binary(), binary(), keyword()) ::
{:clean, binary()} | {:conflict, binary()} | :binary
def merge_markers(base, ours, theirs, opts \\ [])
when is_binary(base) and is_binary(ours) and is_binary(theirs) do
if binary?(base) or binary?(ours) or binary?(theirs) do
:binary
else
segs = segments(split(base), split(ours), split(theirs))
conflicted? = Enum.any?(segs, &match?({:conflict, _}, &1))
body =
segs
|> Enum.flat_map(&render_segment(&1, opts))
|> Enum.join("\n")
if conflicted?, do: {:conflict, body}, else: {:clean, body}
end
end
defp render_segment({:clean, lines}, _opts), do: lines
defp render_segment({:conflict, region}, opts) do
ours_label = Keyword.get(opts, :ours_label, "ours")
base_label = Keyword.get(opts, :base_label, "base")
theirs_label = Keyword.get(opts, :theirs_label, "theirs")
["<<<<<<< #{ours_label}"] ++
region.ours_lines ++
["||||||| #{base_label}"] ++
region.base_lines ++
["======="] ++
region.theirs_lines ++
[">>>>>>> #{theirs_label}"]
end
# Walk the three versions into an ordered list of segments, each either
# `{:clean, lines}` (an auto-merged run, incl. stable anchor lines) or
# `{:conflict, region}`. `conflict_regions/3` and `merge_markers/4` both
# derive from this; clean segments reproduce exactly what `merge/3` would
# emit for the same region.
defp segments(base, ours, theirs) do
base_t = List.to_tuple(base)
ours_t = List.to_tuple(ours)
theirs_t = List.to_tuple(theirs)
anchors = anchor_list(base, ours, theirs, base_t)
seg_walk(anchors, base_t, ours_t, theirs_t, 0, 0, 0, [])
end
defp seg_walk([], base_t, ours_t, theirs_t, bi, oi, ti, acc) do
acc =
add_segment(
acc,
slice(base_t, bi),
slice(ours_t, oi),
slice(theirs_t, ti),
bi,
oi,
ti
)
Enum.reverse(acc)
end
defp seg_walk([{b, o, t} | rest], base_t, ours_t, theirs_t, bi, oi, ti, acc) do
acc =
add_segment(
acc,
slice(base_t, bi, b),
slice(ours_t, oi, o),
slice(theirs_t, ti, t),
bi,
oi,
ti
)
# The anchor line is stable in all three versions — always clean.
acc = push_clean(acc, [elem(base_t, b)])
seg_walk(rest, base_t, ours_t, theirs_t, b + 1, o + 1, t + 1, acc)
end
defp add_segment(acc, base, ours, theirs, bi, oi, ti) do
case resolve(base, ours, theirs) do
{:ok, lines} ->
push_clean(acc, lines)
:conflict ->
region = %{
base_start: bi,
base_lines: base,
ours_start: oi,
ours_lines: ours,
theirs_start: ti,
theirs_lines: theirs
}
[{:conflict, region} | acc]
end
end
# `acc` is newest-first; a run of adjacent clean lines is coalesced so
# callers see contiguous context rather than one segment per line.
defp push_clean([{:clean, prev} | rest], lines), do: [{:clean, prev ++ lines} | rest]
defp push_clean(acc, lines), do: [{:clean, lines} | acc]
# `String.split/3` with trim: false and `Enum.join/2` are exact inverses,
# so a clean merge reproduces bytes precisely (incl. trailing newline,
# which surfaces as a trailing "" element).
@@ -88,16 +256,22 @@
base_t = List.to_tuple(base)
ours_t = List.to_tuple(ours)
theirs_t = List.to_tuple(theirs)
anchors = anchor_list(base, ours, theirs, base_t)
walk(anchors, base_t, ours_t, theirs_t, 0, 0, 0, [])
end
# Stable anchors shared by every walk over these three versions: base lines
# matched to both ours and theirs, minus lines inside a repeated run (see
# `in_repeated_run?/2`). `{base_idx, ours_idx, theirs_idx}`, sorted, strictly
# increasing in all three coordinates.
defp anchor_list(base, ours, theirs, base_t) do
match_o = match_map(base, ours)
match_t = match_map(base, theirs)
anchors =
match_o
|> stable_anchors(match_t)
|> Enum.reject(&in_repeated_run?(&1, base_t))
match_o
|> stable_anchors(match_t)
|> Enum.reject(&in_repeated_run?(&1, base_t))
walk(anchors, base_t, ours_t, theirs_t, 0, 0, 0, [])
end
# A base line that is part of a run of identical lines is an unreliable