lib/archeometer/explore/project.ex

defmodule Archeometer.Explore.Project do
  @moduledoc """
  Utilities for obtaining higher level information about a project, mostly
  through Mix and Application.
  """

  require Logger

  @doc """
  Get the (underscored) module name of the current project`mix.exs` file. Every
  directory with one is considered a project by Mix.
  """
  def get!() do
    Mix.Project.get!()
    |> Macro.to_string()
    |> String.split(".")
    |> hd()
    |> Macro.underscore()
  end

  @doc """
  Get all the locally defined modules.
  """
  def local_modules(extra_deps \\ []) do
    local_modules_by_app(extra_deps)
    |> Map.values()
    |> List.flatten()
  end

  @doc """
  Obtain all the locally defined modules by application.
  """
  def local_modules_by_app(extra_deps \\ []) do
    apps = apps() ++ extra_deps

    Enum.each(apps, fn app ->
      case Application.ensure_loaded(app) do
        {:error, reason} ->
          Logger.warn(
            "#{app} failed to load with #{inspect(reason)}; it will be ignored in further analysis"
          )

        ok ->
          ok
      end
    end)

    Map.new(apps, fn app -> {app, Application.spec(app, :modules)} end)
  end

  @doc """
  Obtain the list of local app in the current project. This is the value of
  `:app` in regular projects and all the children apps in umbrella projects.
  """
  def apps() do
    case Mix.Project.config()[:app] do
      nil -> umbrella_apps()
      app -> [app]
    end
  end

  @doc """
  Given a file path, try to guess its application. If the project is not
  umbrella, just return the project app. Else try to parse the path to guess the
  application.
  """
  def guess_app_from_path(file_path) do
    all_children = Mix.Project.deps_paths()

    default =
      if Mix.Project.umbrella?(),
        do: {:error, :failed_to_guess},
        else: {:ok, Mix.Project.config()[:app]}

    Enum.find_value(
      all_children,
      default,
      fn {dep, dep_path} ->
        if String.contains?(
             file_path,
             Path.relative_to_cwd(dep_path) <> "\/"
           ),
           do: {:ok, dep}
      end
    )
  end

  @doc """
  Obtain all the apps defined as umbrella children.
  """
  def umbrella_apps() do
    all_children = Mix.Dep.Loader.children()

    for %Mix.Dep{scm: Mix.SCM.Path, app: app, opts: opts} <- all_children,
        opts[:from_umbrella] do
      app
    end
  end

  def list_behaviours(modules) do
    Enum.reduce(modules, [], fn module, acc ->
      attributes = module.__info__(:attributes)

      if Keyword.has_key?(attributes, :behaviour) do
        Enum.map(
          Keyword.get_values(attributes, :behaviour) |> List.flatten(),
          &%{
            module: Macro.to_string(module),
            name: Macro.to_string(&1)
          }
        ) ++ acc
      else
        acc
      end
    end)
  end

  def list_modules_with_struct(modules) do
    Enum.reduce(modules, [], fn module, acc ->
      functions = module.__info__(:functions)

      if Keyword.has_key?(functions, :__struct__) do
        [Macro.to_string(module) | acc]
      else
        acc
      end
    end)
  end

  @doc """
  Returns the project dependencies that matches the filters.

  ## Parameters

    - filters: List of wildcard style strings to use as filters.
  """
  def filter_deps([]), do: false

  def filter_deps(filters) when is_list(filters) do
    deps = Mix.Project.deps_paths()
    load_and_cache = Mix.Dep.load_and_cache()

    Enum.flat_map(
      filters,
      fn filter ->
        Enum.filter(deps, &dep_matches?(&1, filter, load_and_cache))
      end
    )
    |> Enum.uniq()
  end

  defp dep_matches?({app, _path}, filter, load_and_cache) do
    matches_filter = {Wild.match?(Atom.to_string(app), filter), mix_managed?(app, load_and_cache)}

    case matches_filter do
      {true, true} ->
        true

      {true, false} ->
        Logger.warn("Dependency #{app} is not Mix managed")
        false

      {_wild, _mix} ->
        false
    end
  end

  defp mix_managed?(dep, load_and_cache) do
    dep_struct = Enum.find(load_and_cache, false, &(&1.app == dep))

    if dep_struct do
      Mix.Dep.mix?(dep_struct)
    else
      false
    end
  end

  def get_curr_app_deps(_module) do
    Mix.Project.config()[:deps]
    |> Enum.map(&elem(&1, 0))
  end

  def in_app(app, fun) do
    if app == Mix.Project.config()[:app] do
      {:ok, fun.()}
    else
      with {:ok, app_path} <- get_dep_path(app) do
        {:ok, Mix.Project.in_project(app, app_path, [], fun)}
      end
    end
  end

  def get_dep_path(dep) do
    all_children = Mix.Dep.Loader.children()

    matching_deps =
      for %Mix.Dep{app: ^dep, opts: opts} <- all_children do
        Keyword.get(opts, :dest)
      end ++
        for {^dep, path} <- Mix.Project.deps_paths() do
          path
        end

    case matching_deps do
      [] -> {:error, :not_found}
      [path | _] -> {:ok, path}
    end
  end

  def get_app_deps(app) do
    if app == Mix.Project.config()[:app] do
      {:ok, get_curr_app_deps(nil)}
    else
      in_app(app, &get_curr_app_deps/1)
    end
  end

  def test_paths([]) do
    Mix.Project.umbrella?()
    |> prj_test_paths()
    |> Enum.map(&Path.relative_to_cwd/1)
  end

  def test_paths(deps_filter) do
    deps_to_include =
      deps_filter
      |> filter_deps()
      |> Keyword.keys()

    Mix.Dep.load_and_cache()
    |> Enum.filter(&(&1.app in deps_to_include))
    |> Enum.flat_map(&dep_test_paths/1)
    |> Enum.concat(prj_test_paths(Mix.Project.umbrella?()))
    |> Enum.map(&Path.relative_to_cwd/1)
  end

  defp dep_test_paths(dep) do
    Mix.Dep.in_dependency(dep, fn _ ->
      prj_test_paths(Mix.Project.umbrella?())
    end)
  end

  defp prj_test_paths(umbrella?)

  defp prj_test_paths(true) do
    Mix.Dep.Umbrella.loaded()
    |> Enum.flat_map(&dep_test_paths/1)
  end

  defp prj_test_paths(false) do
    Mix.Project.config()
    |> Keyword.get(:test_paths, ["test"])
    |> Enum.map(&Path.expand/1)
  end

  def tag_external_apps(apps) do
    Enum.map(apps, &%{app: &1, is_external: external_app?(&1)})
  end

  defp external_app?(app) do
    case Mix.Project.apps_paths() do
      nil ->
        app != Mix.Project.config()[:app]

      apps ->
        app not in Map.keys(apps)
    end
  end
end