defmodule GitGud.Git.DiffParser do
@moduledoc """
Parse `git diff --no-color -U<ctx> <base> <head>` output into a
structured list of file diffs the renderer can walk.
We deliberately don't use `gix` here — gix's diff API doesn't yet
emit hunks in a shape that's faster than just reading git's
well-defined text format, and the text format is stable across all
modern git versions.
"""
@type status :: :added | :deleted | :modified | :renamed | :binary
@type line :: %{
type: :context | :add | :del,
text: String.t(),
old_line: integer() | nil,
new_line: integer() | nil
}
@type hunk :: %{
old_start: integer(),
old_count: integer(),
new_start: integer(),
new_count: integer(),
header: String.t(),
lines: [line()]
}
@type file_diff :: %{
path: String.t(),
old_path: String.t() | nil,
status: status(),
hunks: [hunk()],
# Raw counts derived during parsing, useful for the file
# header even before the renderer expands the hunks.
insertions: integer(),
deletions: integer(),
binary?: boolean()
}
@doc "Parse a full diff blob into a list of file diffs."
@spec parse(String.t()) :: [file_diff()]
def parse(text) when is_binary(text) do
text
|> split_files()
|> Enum.map(&parse_file/1)
end
# Split on the `diff --git` boundary. We keep the marker as the first
# line of each chunk so `parse_file/1` can read it.
defp split_files(""), do: []
defp split_files(text) do
case String.split(text, ~r/^diff --git /m, trim: true) do
[] -> []
chunks -> Enum.map(chunks, &("diff --git " <> &1))
end
end
defp parse_file(chunk) do
lines =
chunk
|> String.split("\n")
|> drop_trailing_empties()
{head, body} = take_header(lines)
{path, old_path, status} = paths_and_status(head)
binary? = Enum.any?(head, &String.starts_with?(&1, "Binary files "))
hunks =
if binary?, do: [], else: parse_hunks(body)
{ins, del} =
hunks
|> Enum.flat_map(& &1.lines)
|> Enum.reduce({0, 0}, fn
%{type: :add}, {a, d} -> {a + 1, d}
%{type: :del}, {a, d} -> {a, d + 1}
_, acc -> acc
end)
%{
path: path,
old_path: old_path,
status: status,
hunks: hunks,
insertions: ins,
deletions: del,
binary?: binary?
}
end
# Header lines = everything up to the first `@@` hunk header (or
# until a binary marker / end-of-chunk). The body is what's left.
defp take_header(lines), do: take_header(lines, [])
defp take_header([], acc), do: {Enum.reverse(acc), []}
defp take_header(["@@" <> _ = h | rest], acc), do: {Enum.reverse(acc), [h | rest]}
defp take_header([h | rest], acc), do: take_header(rest, [h | acc])
# `diff --git a/<old> b/<new>` plus optional `rename from / rename to`,
# plus `--- a/<old>` and `+++ b/<new>` (or `/dev/null` for add/delete).
defp paths_and_status(head) do
new_path = parse_plus_path(head)
old_path = parse_minus_path(head)
cond do
Enum.any?(head, &String.starts_with?(&1, "rename from ")) ->
{new_path, old_path, :renamed}
old_path == "/dev/null" ->
{new_path, nil, :added}
new_path == "/dev/null" ->
{old_path, nil, :deleted}
true ->
{new_path || old_path || "?", nil, :modified}
end
end
defp parse_plus_path(lines) do
Enum.find_value(lines, fn
"+++ b/" <> p -> p
"+++ " <> "/dev/null" -> "/dev/null"
"+++ " <> p -> String.trim_leading(p, "b/")
_ -> nil
end)
end
defp parse_minus_path(lines) do
Enum.find_value(lines, fn
"--- a/" <> p -> p
"--- " <> "/dev/null" -> "/dev/null"
"--- " <> p -> String.trim_leading(p, "a/")
_ -> nil
end)
end
# Body is the lines after the first `@@`. Walk forward collecting
# hunks. Each hunk starts with `@@ -ostart,ocount +nstart,ncount @@`,
# followed by ` `, `+`, `-` lines until the next `@@` or EOF.
defp parse_hunks(lines), do: parse_hunks(lines, [])
defp parse_hunks([], acc), do: Enum.reverse(acc)
defp parse_hunks(["@@" <> rest = header_line | tail], acc) do
{hunk_meta, header_text} = parse_hunk_header(rest, header_line)
{body_lines, more} = split_until_next_hunk(tail, [])
parsed_lines =
walk_lines(body_lines, hunk_meta.old_start, hunk_meta.new_start, [])
hunk = Map.merge(hunk_meta, %{header: header_text, lines: parsed_lines})
parse_hunks(more, [hunk | acc])
end
defp parse_hunks([_ | tail], acc), do: parse_hunks(tail, acc)
# `@@ -10,7 +12,9 @@ optional function header`
defp parse_hunk_header(rest, full_line) do
case Regex.run(~r/^\s*-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@/, rest) do
[_, os, oc, ns, nc] ->
{%{
old_start: String.to_integer(os),
old_count: parse_int_default(oc, 1),
new_start: String.to_integer(ns),
new_count: parse_int_default(nc, 1)
}, full_line}
_ ->
# Best-effort; renderer can still draw the line but won't have
# numbers. Keeps parsing forward on malformed input.
{%{old_start: 1, old_count: 0, new_start: 1, new_count: 0}, full_line}
end
end
defp parse_int_default("", default), do: default
defp parse_int_default(nil, default), do: default
defp parse_int_default(s, _), do: String.to_integer(s)
defp split_until_next_hunk([], acc), do: {Enum.reverse(acc), []}
defp split_until_next_hunk(["@@" <> _ | _] = rest, acc), do: {Enum.reverse(acc), rest}
defp split_until_next_hunk([head | tail], acc), do: split_until_next_hunk(tail, [head | acc])
defp walk_lines([], _old, _new, acc), do: Enum.reverse(acc)
defp walk_lines(["\\ " <> _ | rest], old, new, acc) do
# `\ No newline at end of file` — context-only marker; ignore for
# rendering, doesn't advance line numbers.
walk_lines(rest, old, new, acc)
end
defp walk_lines(["+" <> rest_text | rest], old, new, acc) do
walk_lines(rest, old, new + 1, [
%{type: :add, text: rest_text, old_line: nil, new_line: new} | acc
])
end
defp walk_lines(["-" <> rest_text | rest], old, new, acc) do
walk_lines(rest, old + 1, new, [
%{type: :del, text: rest_text, old_line: old, new_line: nil} | acc
])
end
defp walk_lines([" " <> rest_text | rest], old, new, acc) do
walk_lines(rest, old + 1, new + 1, [
%{type: :context, text: rest_text, old_line: old, new_line: new} | acc
])
end
# Empty line in the middle of a hunk usually means a literal blank
# context line; treat it as ` ` would.
defp walk_lines(["" | rest], old, new, acc) do
walk_lines(rest, old + 1, new + 1, [
%{type: :context, text: "", old_line: old, new_line: new} | acc
])
end
defp walk_lines([_unknown | rest], old, new, acc),
do: walk_lines(rest, old, new, acc)
defp drop_trailing_empties(lines),
do: lines |> Enum.reverse() |> Enum.drop_while(&(&1 == "")) |> Enum.reverse()
end
7.0 KiB · text
5af55d9