lib/boundary/mix/tasks/visualize/funs.ex

# credo:disable-for-this-file Credo.Check.Readability.Specs

defmodule Mix.Tasks.Boundary.Visualize.Funs do
  @shortdoc "Visualizes cross-function dependencies in a single module."

  @moduledoc """
  #{@shortdoc}

  Usage:

      mix boundary.visualize.funs SomeModule

  The graph is printed to the standard output using the [graphviz dot language](https://graphviz.org/doc/info/lang.html).
  """

  use Boundary, classify_to: Boundary.Mix
  use Mix.Task

  alias Boundary.Graph

  @impl Mix.Task
  def run(argv) do
    unless match?([_], argv),
      do: Mix.raise("usage: mix boundary.visualize.functions SomeModule")

    tracers = Code.get_compiler_option(:tracers)
    Code.put_compiler_option(:tracers, [__MODULE__ | tracers])

    :ets.new(__MODULE__, [:named_table, :public, :duplicate_bag, write_concurrency: true])
    :persistent_term.put({__MODULE__, :module}, hd(argv))

    previous_shell = Mix.shell()
    Mix.shell(Mix.Shell.Quiet)
    :persistent_term.put({__MODULE__, :shell}, previous_shell)

    # need to force recompile the project so we can collect traces
    Mix.Task.Compiler.after_compiler(:app, &after_compiler/1)
    Mix.Task.reenable("compile")
    Mix.Task.run("compile", ["--force"])
  end

  @doc false
  def trace({local, _meta, callee_fun, _arity}, env) when local in ~w/local_function local_macro/a do
    {caller_fun, _arity} = env.function

    if inspect(env.module) == :persistent_term.get({__MODULE__, :module}) and caller_fun != callee_fun,
      do: :ets.insert(__MODULE__, {caller_fun, callee_fun})

    :ok
  end

  def trace(_other, _env), do: :ok

  defp after_compiler(status) do
    Mix.shell(:persistent_term.get({__MODULE__, :shell}))
    Mix.shell().info(build_graph())
    status
  end

  defp build_graph do
    name = "function calls inside #{:persistent_term.get({__MODULE__, :module})}"

    calls()
    |> Enum.reduce(Graph.new(name), fn {from, to}, graph ->
      graph
      |> Graph.add_node(from)
      |> Graph.add_node(to)
      |> Graph.add_dependency(from, to)
    end)
    |> Graph.dot()
  end

  defp calls, do: :ets.tab2list(__MODULE__)
end