lib/archeometer/analysis/treemap.ex

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

  defmodule Node do
    @moduledoc false

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

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

      modules ->
        treemap(modules)
    end
  end

  def treemap(modules, 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()
  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