lib/openagents/diff.ex

58e6347eeb72 · 11 KB

defmodule OpenAgents.Diff do
  @moduledoc """
  A unified diff, decomposed into files, hunks, and lines.

  The model is adapted from Pierre's `@pierre/diffs` (Apache 2.0,
  `pierrecomputer/pierre`), which is the part of that library worth taking:
  what a diff *is* once you stop treating it as text. See
  `docs/2026-08-20-pierre-code-surfaces-port.md`.

  A diff arrives from `git diff-tree -p -M` as one string, and rendering it as
  one string is what the commit page used to do. The whole value of parsing it
  is that each line then carries **both** line numbers -- where it sits in the
  old file and in the new one -- which is what lets a reader point at a line,
  and what a `<pre>` blob can never provide.

  The parser is deliberately tolerant. A diff is a report about somebody else's
  repository: it can be truncated mid-hunk by an upstream cap, describe a
  binary file, record a rename with no content change, or use headers this code
  has not seen. None of that should raise on a page whose job is to show what
  happened. Anything unrecognised inside a file becomes a `:meta` line, which
  renders as plain text and is honest about being unparsed.
  """

  defmodule Line do
    @moduledoc "One line of a hunk, carrying its position in both files."

    @type kind :: :context | :insert | :delete | :meta

    @type t :: %__MODULE__{
            kind: kind(),
            text: String.t(),
            old_number: pos_integer() | nil,
            new_number: pos_integer() | nil
          }

    @enforce_keys [:kind, :text]
    defstruct [:kind, :text, :old_number, :new_number]
  end

  defmodule Hunk do
    @moduledoc """
    One contiguous run of changes.

    `heading` is the text git puts after the `@@` marker -- usually the
    enclosing function -- which is the cheapest orientation a reader gets and
    the reason the header is worth rendering rather than discarding.
    """

    @type t :: %__MODULE__{
            old_start: non_neg_integer(),
            old_count: non_neg_integer(),
            new_start: non_neg_integer(),
            new_count: non_neg_integer(),
            heading: String.t() | nil,
            lines: [Line.t()]
          }

    @enforce_keys [:old_start, :old_count, :new_start, :new_count]
    defstruct [:old_start, :old_count, :new_start, :new_count, :heading, lines: []]
  end

  defmodule File do
    @moduledoc """
    One file's worth of a diff.

    `status` distinguishes the cases a header can describe: `:added`,
    `:deleted`, `:renamed`, or `:modified`. `binary?` is its own flag rather
    than an absence of hunks, because "no textual change to show" and "this
    file cannot be shown as text" are different statements and a reader should
    be told which one they are looking at.
    """

    @type status :: :added | :deleted | :renamed | :modified

    @type t :: %__MODULE__{
            path: String.t(),
            old_path: String.t() | nil,
            status: status(),
            binary?: boolean(),
            hunks: [Hunk.t()],
            insertions: non_neg_integer(),
            deletions: non_neg_integer()
          }

    @enforce_keys [:path]
    defstruct [
      :path,
      :old_path,
      status: :modified,
      binary?: false,
      hunks: [],
      insertions: 0,
      deletions: 0
    ]
  end

  @doc """
  Parse a unified diff into `%File{}` structs, in the order git emitted them.

  Returns `[]` for empty or unparseable input rather than raising: a commit
  page that shows nothing is a worse answer than one that shows the files it
  understood, but both are better than a crash.
  """
  @spec parse(String.t() | nil) :: [File.t()]
  def parse(nil), do: []
  def parse(""), do: []

  def parse(diff) when is_binary(diff) do
    diff
    |> lines()
    |> collect_files(nil, [])
    |> Enum.map(&finalize_file/1)
  end

  # A newline-terminated diff splits to a trailing empty element, which is the
  # terminator rather than a line. Left in, it became a phantom context line on
  # the last hunk of every file and shifted that hunk's line count by one. Only
  # the final one is dropped: an empty element anywhere else is a real line
  # from a generator that writes bare blank lines instead of `" "`.
  defp lines(diff) do
    case String.split(diff, "\n") do
      [] -> []
      parts -> if List.last(parts) == "", do: Enum.drop(parts, -1), else: parts
    end
  end

  @doc """
  Totals across a parsed diff: how many files, and how many lines each way.

  Reported from the parsed lines rather than from git's own summary, so the
  number under a diff always describes the diff above it -- including when the
  input was truncated and the tail is missing.
  """
  @spec totals([File.t()]) :: %{
          files: non_neg_integer(),
          insertions: non_neg_integer(),
          deletions: non_neg_integer()
        }
  def totals(files) when is_list(files) do
    Enum.reduce(files, %{files: 0, insertions: 0, deletions: 0}, fn file, acc ->
      %{
        files: acc.files + 1,
        insertions: acc.insertions + file.insertions,
        deletions: acc.deletions + file.deletions
      }
    end)
  end

  # ── file boundaries ───────────────────────────────────────────────────────

  defp collect_files([], nil, done), do: Enum.reverse(done)
  defp collect_files([], current, done), do: Enum.reverse([current | done])

  defp collect_files(["diff --git " <> paths | rest], current, done) do
    file = %File{path: path_from_header(paths)}
    collect_files(rest, file, if(current, do: [current | done], else: done))
  end

  # Lines before the first `diff --git` are the commit's own headers, not a
  # file's, and are dropped rather than attached to something they precede.
  defp collect_files([_line | rest], nil, done), do: collect_files(rest, nil, done)

  defp collect_files([line | rest], current, done) do
    collect_files(rest, absorb(current, line), done)
  end

  # `diff --git a/x b/x`, where either side may be quoted and contain spaces.
  # The b-side is preferred: it is the path the file has now.
  defp path_from_header(paths) do
    case Regex.run(~r|^"?a/(.*?)"? "?b/(.*?)"?$|, String.trim(paths), capture: :all_but_first) do
      [_old, new] -> new
      nil -> String.trim(paths)
    end
  end

  # ── headers and body ──────────────────────────────────────────────────────

  defp absorb(file, "new file mode" <> _rest), do: %{file | status: :added}
  defp absorb(file, "deleted file mode" <> _rest), do: %{file | status: :deleted}

  defp absorb(file, "rename from " <> old),
    do: %{file | status: :renamed, old_path: unquote_path(old)}

  defp absorb(file, "rename to " <> new), do: %{file | path: unquote_path(new)}

  defp absorb(file, "Binary files " <> _rest), do: %{file | binary?: true}
  defp absorb(file, "GIT binary patch" <> _rest), do: %{file | binary?: true}

  # Dropped: they restate the paths already in the `diff --git` header, and
  # `/dev/null` on either side restates the status.
  defp absorb(file, "--- " <> _rest), do: file
  defp absorb(file, "+++ " <> _rest), do: file
  defp absorb(file, "index " <> _rest), do: file
  defp absorb(file, "old mode " <> _rest), do: file
  defp absorb(file, "new mode " <> _rest), do: file
  defp absorb(file, "similarity index " <> _rest), do: file
  defp absorb(file, "dissimilarity index " <> _rest), do: file

  defp absorb(file, "@@" <> _rest = line) do
    case parse_hunk_header(line) do
      {:ok, hunk} -> %{file | hunks: [hunk | file.hunks]}
      :error -> push_line(file, %Line{kind: :meta, text: line})
    end
  end

  defp absorb(%File{hunks: []} = file, _line), do: file

  defp absorb(file, "+" <> text), do: push_line(file, :insert, text)
  defp absorb(file, "-" <> text), do: push_line(file, :delete, text)
  defp absorb(file, " " <> text), do: push_line(file, :context, text)
  defp absorb(file, ""), do: push_line(file, :context, "")

  # "\ No newline at end of file", and anything else that turns up inside a
  # hunk. Stated rather than swallowed.
  defp absorb(file, line), do: push_line(file, %Line{kind: :meta, text: line})

  defp unquote_path(path), do: path |> String.trim() |> String.trim("\"")

  # `@@ -old,count +new,count @@ optional heading`, where either count may be
  # omitted and means 1.
  defp parse_hunk_header(line) do
    case Regex.run(~r/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@ ?(.*)$/, line,
           capture: :all_but_first
         ) do
      [old_start, old_count, new_start, new_count | heading] ->
        {:ok,
         %Hunk{
           old_start: String.to_integer(old_start),
           old_count: count(old_count),
           new_start: String.to_integer(new_start),
           new_count: count(new_count),
           heading: heading |> List.first() |> presence()
         }}

      nil ->
        :error
    end
  end

  defp count(""), do: 1
  defp count(value), do: String.to_integer(value)

  defp presence(nil), do: nil
  defp presence(""), do: nil
  defp presence(value), do: value

  # ── line numbering ────────────────────────────────────────────────────────

  # Lines are appended unnumbered and numbered once, in `finalize_file/1`.
  # Numbering on the way in means re-counting the hunk for every line, which is
  # quadratic in the hunk's length -- fine for the four-line hunk in a test and
  # not fine for the thousand-line hunk in a real reformatting commit.
  defp push_line(file, kind, text) do
    [hunk | rest] = file.hunks
    line = %Line{kind: kind, text: text}

    %{
      file
      | hunks: [%{hunk | lines: [line | hunk.lines]} | rest],
        insertions: file.insertions + if(kind == :insert, do: 1, else: 0),
        deletions: file.deletions + if(kind == :delete, do: 1, else: 0)
    }
  end

  defp push_line(%File{hunks: []} = file, %Line{} = line) do
    # A meta line before any hunk has nowhere to sit; the file header already
    # said everything it could say.
    _ = line
    file
  end

  defp push_line(file, %Line{} = line) do
    [hunk | rest] = file.hunks
    %{file | hunks: [%{hunk | lines: [line | hunk.lines]} | rest]}
  end

  defp finalize_file(file) do
    hunks =
      file.hunks
      |> Enum.reverse()
      |> Enum.map(&number_hunk/1)

    %{file | hunks: hunks}
  end

  # One pass, carrying the next number on each side. A deletion advances only
  # the old side and an insertion only the new one, which is the whole reason a
  # line needs two numbers: they stop agreeing at the first change.
  defp number_hunk(hunk) do
    {numbered, _old, _new} =
      hunk.lines
      |> Enum.reverse()
      |> Enum.reduce({[], hunk.old_start, hunk.new_start}, fn line, {acc, old, new} ->
        case line.kind do
          :context ->
            {[%{line | old_number: old, new_number: new} | acc], old + 1, new + 1}

          :delete ->
            {[%{line | old_number: old} | acc], old + 1, new}

          :insert ->
            {[%{line | new_number: new} | acc], old, new + 1}

          :meta ->
            {[line | acc], old, new}
        end
      end)

    %{hunk | lines: Enum.reverse(numbered)}
  end
end