lib/mix/tasks/archeometer.xref.ex

defmodule Mix.Tasks.Arch.Xref do
  @moduledoc """
  Mix Task to generate a dependency graph given some module names.

  Usage:

      mix arch.xref [options] mod1 mod2 .. modN

  The following options are accepted:

    * `--db` - Database filename
    * `--format` - Can be one of `dot`, `png` or `svg`
    * `--out` - Output filename
    * `--app` - Application name
    * `--ns` - Namespace of the modules to consider
    * `--origin` - Subgraph of modules that are called from the given module
    * `--dest` - Subgraph of modules that calls a given module


  """
  @shortdoc "Generates a dependency graph from a list of modules"

  require Logger
  require EEx

  use Mix.Task
  use Archeometer.Repo

  @supported_formats ["png", "svg", "dot", "mermaid"]

  @impl Mix.Task
  def run(argv) do
    case parse_args(argv) do
      {:ok, opts} ->
        Archeometer.Analysis.Xref.graph(opts)
        |> write(Keyword.get(opts, :out_fname))

      {:error, e} = error ->
        Mix.shell().error("Error: #{e}")
        print_help()
        error
    end
  end

  defp print_help() do
    Mix.shell().info("""
    Usage:
      mix arch.xref [opts] Mod1 Mod2 ... ModN
    or
      mix arch.xref [opts] [filter_opts]

    opts: --db db_file_name --format (dot, png, svg, mermaid) --out out_fname
    filter_opts: --app application_name --ns namespace --origin --dest
    """)
  end

  defp parse_args(argv) do
    {opts, modules, invalid_switches} =
      OptionParser.parse(
        argv,
        strict: [
          db: :string,
          format: :string,
          out: :string,
          app: :string,
          ns: :string,
          origin: :boolean,
          dest: :boolean
        ]
      )

    case invalid_switches do
      [] ->
        db_name = Keyword.get(opts, :db, default_db_name())
        format = Keyword.get(opts, :format, "dot")
        out_fname = Keyword.get(opts, :out, "console")
        app = Keyword.get(opts, :app, :none)
        namespace = Keyword.get(opts, :ns, "*")
        origin = Keyword.get(opts, :origin, false)
        dest = Keyword.get(opts, :dest, false)

        case validate_options(
               %{
                 db: db_name,
                 format: format,
                 out: out_fname,
                 app: app,
                 ns: namespace,
                 origin: origin,
                 dest: dest
               },
               modules
             ) do
          :ok ->
            {:ok,
             [
               app: app,
               namespace: namespace,
               db_name: db_name,
               modules: modules,
               format: format,
               out_fname: out_fname,
               origin: origin,
               dest: dest
             ]}

          {:error, e} ->
            {:error, e}
        end

      _ ->
        {:error, :wrong_arguments}
    end
  end

  defp validate_options(
         %{
           format: format,
           out: out_fname,
           app: app,
           ns: namespace,
           db: db_name,
           origin: origin,
           dest: dest
         },
         modules
       ) do
    origin_dest = origin or dest
    app_namespace = app != :none or namespace != "*"

    [
      {
        not Archeometer.Repo.db_ready?(:full, db_name),
        {:error, "Database is not existent or doesn't have the required tables."}
      },
      {
        length(modules) > 0 and app_namespace,
        {:error, :incompatible_parameters}
      },
      {
        app_namespace and origin_dest,
        {:error, :incompatible_parameters}
      },
      {
        length(modules) < 1 and origin_dest,
        {:error, :no_modules_matched}
      },
      {
        format not in @supported_formats,
        {:error, :unsupported_output_format}
      },
      {
        format == "png" and out_fname == "console",
        {:error, :png_not_writable_to_console}
      }
    ]
    |> Enum.find_value(:ok, fn {x, e} -> if x, do: e end)
  end

  defp write({:error, error}, _) do
    Mix.shell().error("Error: #{error}")
    Mix.shell().error("No XRef is generated.")
    {:error, error}
  end

  defp write(content, "console") do
    Mix.shell().info(content)
    Mix.shell().info("Graph ready at: console")
  end

  defp write(content, out_fname) do
    File.write!(out_fname, content)
    Mix.shell().info("Graph ready at: '#{out_fname}'")
  end
end