lib/archeometer/analysis/treemap.ex

defmodule Archeometer.Analysis.Treemap do
  @moduledoc """
  Functions for generating different TreeMaps.
  """

  defmodule Node do
    @moduledoc """
    Represents a Node within a Treemap.
    """

    defstruct name: nil, nodes: [], kind: :group, val: 0, pct: 0, metric: :none
  end

  @doc """
  Generates a treemap with the given options.
  Available metrics:

  - size:
  - functions:
  - issues:

  Available options:

  - app: Application where the modules to consider belong
    (default: :none, it means to include all applications)
  - namespace: Namespace of the desired modules (default: "*")
  - db: Path to Database (default: archeometer_project_name.db)
  - skip_tests: If skip testing modules or not (default: true)

  ## Examples

  iex> treemap(:size,
  ...>   app: "jissai",
  ...>   namespace: "Jissai.Reports.Attendance",
  ...>   db: "test/resources/db/archeometer_jissai.db"
  ...> )
  %Archeometer.Analysis.Treemap.Node{
    kind: :group,
    metric: :size,
    name: "Jissai",
    nodes: [
      %Archeometer.Analysis.Treemap.Node{
        kind: :group,
        name: "Jissai.Reports",
        nodes: [
          %Archeometer.Analysis.Treemap.Node{
            kind: :leaf,
            name: "Jissai.Reports.Attendance",
            nodes: [],
            pct: 100.0,
            val: 21
          }
        ],
        pct: 100.0,
        val: 21
      }
    ],
    pct: 100,
    val: 21
  }

  Then it can be rendered as SVG with Treemap.SVGRender.render/1
  """
  def treemap(metric, opts \\ []) when is_atom(metric) do
    cond do
      Keyword.drop(opts, [:app, :namespace, :db, :skip_tests]) == [] ->
        app = Keyword.get(opts, :app, :none)
        namespace = Keyword.get(opts, :namespace, "*")
        db_name = Keyword.get(opts, :db, Archeometer.Repo.default_db_name())
        skip_tests = Keyword.get(opts, :skip_tests, true)
        treemap(metric, app, namespace, db_name, skip_tests)

      Keyword.drop(opts, [:modules, :ns_sep]) == [] ->
        modules = Keyword.get(opts, :modules)
        ns_sep = Keyword.get(opts, :ns_sep, ".")
        do_treemap(modules, metric, ns_sep)

      true ->
        {:error, :incompatible_options}
    end
  end

  defp treemap(metric, app, namespace, db_name, skip_tests) do
    case __MODULE__.Data.get_data(metric, app, namespace, db_name, skip_tests) do
      [] ->
        {:error, :no_modules_matched}

      {:error, error} ->
        {:error, error}

      modules ->
        do_treemap(modules, metric)
    end
  end

  defp do_treemap(modules, metric, ns_sep \\ ".") do
    all_groups = all_groups(modules, ns_sep)

    all_groups
    |> get_roots(ns_sep)
    |> init_tree()
    |> add_groups(all_groups, ns_sep)
    |> add_modules(modules, ns_sep)
    |> aggregate_metric()
    |> distribute_pct()
    |> Map.put(:metric, metric)
  end

  defp aggregate_metric(%Node{kind: :group, nodes: nodes} = t) do
    new_nodes = Enum.map(nodes, &aggregate_metric/1)
    new_val = Enum.reduce(new_nodes, 0, fn node, acc -> node.val + acc end)
    %{t | nodes: new_nodes, val: new_val}
  end

  defp aggregate_metric(%Node{kind: :leaf, val: _val} = t), do: t

  defp distribute_pct(%Node{kind: :group, pct: pct, val: val, nodes: nodes} = t) do
    new_nodes =
      Enum.map(nodes, fn node ->
        new_node = %{node | pct: node.val * pct / val}
        distribute_pct(new_node)
      end)

    %{t | nodes: new_nodes}
  end

  defp distribute_pct(%Node{} = t), do: t

  def parents(module, ns_sep) do
    case String.split(module, ns_sep) |> Enum.reverse() do
      [_] ->
        []

      [_ | tail] ->
        groups(tail, ns_sep)
    end
  end

  defp groups([x], _ns_sep), do: [x]

  defp groups([_h | t] = l, ns_sep),
    do: [l |> Enum.reverse() |> Enum.join(ns_sep) | groups(t, ns_sep)]

  defp all_groups(modules, ns_sep) do
    modules
    |> Enum.flat_map(fn {_id, name, _loc} -> parents(name, ns_sep) end)
    |> Enum.uniq()
    |> Enum.sort()
    |> List.delete([])
  end

  defp get_roots(groups, ns_sep) do
    Enum.filter(groups, fn g -> String.split(g, ns_sep) |> length() == 1 end)
  end

  defp init_tree([name]), do: %Node{name: name, nodes: [], pct: 100}

  defp init_tree(names) do
    nodes = Enum.map(names, fn name -> %Node{name: name, nodes: []} end)
    %Node{name: "*", nodes: nodes, pct: 100}
  end

  def add_groups(%Node{} = t, [], _ns_sep), do: t

  def add_groups(%Node{name: root_name, nodes: nodes} = t, [group_name | others], ns_sep) do
    case {subgroup?(root_name, group_name, ns_sep), find_match(nodes, group_name, ns_sep)} do
      {false, _match} ->
        add_groups(t, others, ns_sep)

      {true, nil} ->
        case find_same(nodes, group_name) do
          nil ->
            new_nodes =
              [%Node{name: group_name} | nodes]
              |> Enum.sort_by(fn n -> n.name end)

            new_t = %{t | nodes: new_nodes}
            add_groups(new_t, others, ns_sep)

          _same ->
            add_groups(t, others, ns_sep)
        end

      {true, match} ->
        new_nodes =
          [add_groups(match, [group_name], ns_sep) | List.delete(nodes, match)]
          |> Enum.sort_by(fn n -> n.name end)

        new_t = %{t | nodes: new_nodes}
        add_groups(new_t, others, ns_sep)
    end
  end

  defp subgroup?("*", _group, _ns_sep), do: true

  defp subgroup?(root_name, group, ns_sep) do
    String.starts_with?(group, root_name <> ns_sep)
  end

  defp find_match(nodes, name, ns_sep) do
    Enum.find(nodes, fn node -> String.starts_with?(name, node.name <> ns_sep) end)
  end

  defp find_same(nodes, name) do
    Enum.find(nodes, fn node -> name == node.name end)
  end

  def add_modules(%Node{} = t, [], _ns_sep), do: t

  def add_modules(
        %Node{name: node_name, nodes: nodes} = t,
        [{_id, module, val} = curr | others],
        ns_sep
      ) do
    new_t =
      case {same_name?(node_name, module), direct_child?(module, node_name, ns_sep)} do
        {true, _} ->
          new_nodes =
            [leaf(module, val) | nodes]
            |> Enum.sort_by(fn n -> n.name end)

          %{t | nodes: new_nodes}

        {false, true} ->
          new_nodes =
            case find_same(nodes, module) do
              nil ->
                [leaf(module, val) | nodes]
                |> Enum.sort_by(fn n -> n.name end)

              match ->
                [add_modules(match, [curr], ns_sep) | List.delete(nodes, match)]
                |> Enum.sort_by(fn n -> n.name end)
            end

          %{t | nodes: new_nodes}

        {false, false} ->
          new_nodes = Enum.map(nodes, fn node -> add_modules(node, [curr], ns_sep) end)
          %{t | nodes: new_nodes}
      end

    add_modules(new_t, others, ns_sep)
  end

  defp leaf(name, val) do
    %Node{name: name, kind: :leaf, val: val}
  end

  defp direct_child?(module, root_name, ns_sep) do
    String.split(module, ns_sep) |> Enum.drop(-1) |> Enum.join(ns_sep) == root_name
  end

  defp same_name?(module, node_name) do
    module == node_name
  end
end