lib/archeometer/analysis/xref.ex

defmodule Archeometer.Analysis.Xref do
  @moduledoc """
  Functions for generating a dependency graph from a list of given modules.

  Accepted output formats are "dot" (graphviz), "png" and "mermaid".
  """

  alias Archeometer.Graphs.Graphviz
  alias Archeometer.Graphs.Mermaid
  alias Archeometer.Repo
  import Archeometer.Analysis.Xref.Data
  @supported_formats ["png", "dot", "mermaid", "svg"]

  @doc """
  Creates a dependency graph between the modules given as parameters

  ## Parameters

  - `modules` is either a list of module names, e.g. `[Foo.Bar, Foo.Rex, Foo.Zorg]` or a tuple `{application, namespace}` where application is the name of a specific applciation or `:none` for all applications, and namespace is a specific namespace or `"*"`.
  - `format` can be one of "dot" (graphviz), "png", or "mermaid".
  - `db_name` is the filename of the DB to be used. If not given uses default DB.

  ## Returns

  - The binary representing the graph, if the operation was completed successfully.
  - `{:error, reason}` if not.

  """
  def graph(opts) do
    opts = Enum.into(opts, %{})

    case opts do
      %{
        app: :none,
        namespace: "*",
        origin: false,
        dest: false
      } ->
        gen_graph(opts.modules, opts.format, opts.db_name)

      %{
        app: app,
        namespace: namespace,
        origin: false,
        dest: false
      } ->
        gen_graph({app, namespace}, opts.format, opts.db_name)

      %{
        app: :none,
        namespace: "*",
        modules: modules,
        origin: origin,
        dest: dest
      } ->
        gen_graph({modules, direction(origin, dest)}, opts.format, opts.db_name)
    end
  end

  defp direction(origin, dest) do
    cond do
      origin and dest ->
        :both

      origin and not dest ->
        :outgoing

      true ->
        :incoming
    end
  end

  def gen_graph(modules, format, db_name \\ Repo.default_db_name())

  def gen_graph({modules, direction}, format, db_name)
      when format in @supported_formats and is_atom(direction) do
    modules_result =
      Enum.reduce(modules, [], fn module, acc ->
        %{rows: neighbors} = neighbors(module, direction, db_name)

        acc ++ List.flatten(neighbors)
      end)

    modules_result = Enum.uniq(modules_result ++ modules)
    gen_graph(modules_result ++ modules, format, db_name)
  end

  def gen_graph({app, ns}, format, db_name) when format in @supported_formats do
    case get_modules(app, ns, db_name) do
      [] ->
        {:error, :no_modules_matched}

      modules ->
        gen_graph(modules, format, db_name)
    end
  end

  def gen_graph(modules, format, db_name) when format in @supported_formats do
    try do
      do_gen_graph(modules, format, db_name)
    rescue
      e in RuntimeError -> {:error, e.message}
      _ in MatchError -> {:error, :no_modules_matched}
    end
  end

  def gen_graph(_modules, _format, _db_name) do
    {:error, :unsupported_format}
  end

  defp do_gen_graph(modules, format, db_name) do
    Enum.each(modules, fn module ->
      if not module_exists?(module, db_name) do
        raise RuntimeError, message: "unknown module '#{module}'"
      end
    end)

    modules
    |> xrefs(db_name)
    |> render(format)
  end

  defp xrefs(modules, db_name) do
    modules
    |> Enum.map(&callees(&1, db_name, modules))
    |> Enum.into(%{})
  end

  defp callees(module, db_name, modules_list) do
    others =
      (modules_list -- [module])
      |> List.flatten()

    %{rows: xrefs} = neighbors(module, :outgoing, db_name)

    callees =
      xrefs
      |> List.flatten()
      |> Enum.uniq()
      |> Enum.filter(&Enum.member?(others, &1))

    {module, callees}
  end

  @doc ~S"""
  Takes a map representing the adjacency list of a graph, and renders it using
  the appropriante backend. This can be both plain text graph formats
  (mermaid or dot) or image formats (svg or png).

      iex> Archeometer.Analysis.Xref.render(%{"a" => ["b"], "b" => []}, "dot") =~
      ...> ~r/digraph G {\n  .*\n.*\n    "a";\n    "b";\n      "a" -> "b";\n}\n/
      true

      iex> Archeometer.Analysis.Xref.render(%{"a" => ["b"], "b" => []}, "mermaid")
      ~s/graph TD;\n  id_0([a]);\n  id_1([b]);\n  id_0-->id_1;\n/
  """
  def render(content, format)

  def render(refs, "dot") do
    Graphviz.render_dot(refs)
  end

  def render(refs, format) when format in ["png", "svg"] do
    Graphviz.render_image(refs, format)
  end

  def render(refs, "mermaid") do
    Mermaid.render(refs)
  end
end