lib/common_x/application_x.ex

defmodule ApplicationX do
  @moduledoc ~S"""
  Application module extended functions.
  """
  alias Mix.Project
  import Mix.Task, only: [run: 2]
  @ignore [:kernel, :stdlib, :elixir, :logger]
  @load_error 'no such file or directory'

  @external_resource Path.join(File.cwd!(), "../../mix.exs")
  @external_resource Path.join(File.cwd!(), "mix.exs")
  @external_resource Path.join(__DIR__, "../../../../mix.exs")
  @external_resource Path.join(__DIR__, "../../mix.exs")

  mix_tasks =
    if p = Process.whereis(Mix.TasksServer) do
      case Agent.get(p, & &1) do
        state = %{} -> state
        ets when is_atom(ets) -> :ets.tab2list(ets)
      end
      |> Enum.map(&elem(&1, 0))
    else
      false
    end

  task_module =
    if mix_tasks do
      Enum.find_value(mix_tasks, false, fn
        {:task, task, m} when task in ~W(app.start deps.loadpaths) -> m
        _ -> false
      end)
    end

  main_project =
    case task_module do
      falsy when falsy in [nil, false] -> falsy
      Mix.InstallProject -> []
      module -> module.project()
    end

  {main_project, main_app} =
    if main_project do
      {main_project, main_project[:app]}
    else
      [
        Path.join(File.cwd!(), "../../mix.exs"),
        Path.join(File.cwd!(), "mix.exs"),
        Path.join(__DIR__, "../../../../mix.exs"),
        Path.join(__DIR__, "../../mix.exs")
      ]
      |> Enum.find_value(false, fn file ->
        if File.exists?(file) do
          mod =
            ~r/defmodule\W*(?<module>[a-z\_\.]+)/i
            |> Regex.named_captures(File.read!(file))
            |> Kernel.||(%{})
            |> Map.get("module")

          name = if mod, do: Module.concat(Elixir, mod)
          name != nil and (Code.ensure_loaded?(name) || Code.compile_file(file))

          project = name.project()
          {project, project[:app]}
        end
      end)
    end

  @doc ~S"""
  The atom of the current main application.

  For example take an application called `:my_app`,
  which includes the `:my_dep` dependencies,
  which has `:common_x` as dependency.

  So:
  ```
  :my_app
  ├── ...
  └── :my_dep
      ├── ...
      └── :common_x
  ```

  In that scenario calling `ApplicationX.main` will return `:my_app`
  both for code in `:my_app` and `:my_dep`.

  ## Examples

  ```elixir
  iex> ApplicationX.main
  :common_x
  ```
  """
  @spec main :: atom
  def main, do: unquote(main_app)

  @doc ~S"""
  The mix configuration of the current main application.

  For example take an application called `:my_app`,
  which includes the `:my_dep` dependencies,
  which has `:common_x` as dependency.

  So:
  ```
  :my_app
  ├── ...
  └── :my_dep
      ├── ...
      └── :common_x
  ```

  In that scenario calling `ApplicationX.main` will return
  the mix config of `:my_app` both for code in `:my_app` and `:my_dep`.

  ## Examples

  ```elixir
  iex> config = ApplicationX.main_project
  iex> config[:app]
  :common_x
  iex> config[:description]
  "Extension of common Elixir modules."
  ```
  """
  @spec main_project :: Keyword.t()
  def main_project, do: unquote(Macro.escape(main_project || []))

  env =
    cond do
      e = System.get_env("MIX_ENV") ->
        String.to_existing_atom(e)

      mix_tasks ->
        tasks =
          mix_tasks
          |> Enum.map(&elem(&1, 1))
          |> Enum.uniq()

        preferred =
          if main_project, do: Keyword.get(main_project, :preferred_cli_env, []), else: []

        path =
          case :lists.reverse(String.split(to_string(:code.priv_dir(:common_x)), "/")) do
            ["priv", "common_x", "lib", env, "_build" | _] -> env
            _ -> nil
          end

        cond do
          env = %{"test" => :test, "dev" => :dev, "prod" => :prod}[path] -> env
          env = Enum.find_value(tasks, &Mix.Task.preferred_cli_env/1) -> env
          env = Enum.find_value(tasks, &Keyword.get(preferred, String.to_atom(&1))) -> env
          :default -> :dev
        end

      :default ->
        :prod
    end

  @doc ~S"""
  Get the current Mix environment.

  ## Example

  ```elixir
  iex> ApplicationX.mix_env
  :test
  ```
  """
  @spec mix_env :: atom
  def mix_env, do: unquote(env)

  @doc ~S"""
  List all available applications excluding system ones.

  This function is save to run in `Mix.Task`s.

  ## Example

  Since `:common_x` does not have any dependencies:
  ```elixir
  iex> ApplicationX.applications
  [:common_x]
  ```
  """
  @spec applications :: [atom]
  def applications do
    app = if(Process.whereis(Mix.ProjectStack), do: Project.config()[:app])

    if app do
      with {:error, {@load_error, _}} <- :application.load(app), do: run("loadpaths", [])
      gather_applications([app])
    else
      :application.loaded_applications()
      |> Enum.map(&elem(&1, 0))
      |> Enum.reject(&(&1 in @ignore))
      |> gather_applications()
    end
  end

  @doc ~S"""
  List all dependant applications excluding system ones.
  Does includes the given application.

  This function is save to run in `Mix.Task`s.

  ## Example

  Since `:common_x` does not have any dependencies:
  ```elixir
  iex> ApplicationX.applications(:common_x)
  [:common_x]
  ```

  Duplicates are ignored and only returned once:
  ```elixir
  iex> ApplicationX.applications([:common_x, :common_x])
  [:common_x]
  ```

  Unknown applications are safe, but returned:
  ```elixir
  iex> ApplicationX.applications(:fake)
  [:fake]
  ```
  """
  @spec applications(atom | [atom]) :: [atom]
  def applications(app) when is_atom(app), do: gather_applications([app])
  def applications(apps), do: gather_applications(apps)

  @doc ~S"""
  List all available modules excluding system ones.

  This function is save to run in `Mix.Task`s.

  ## Example

  Since `:common_x` does not have any dependencies:
  ```elixir
  iex> ApplicationX.modules
  [ApplicationX, CodeX, CommonX, EnumX, MacroX, MapX]
  ```
  """
  @spec modules :: [module]
  def modules do
    app = if(Process.whereis(Mix.ProjectStack), do: Project.config()[:app])

    if app do
      with {:error, {@load_error, _}} <- :application.load(app), do: run("loadpaths", [])
      modules(app)
    else
      :application.loaded_applications()
      |> Enum.map(&elem(&1, 0))
      |> Enum.reject(&(&1 in @ignore))
      |> modules()
    end
  end

  @doc ~S"""
  List all available modules for the given app[s] and dependencies of those apps.
  This excludes system modules.

  This function is save to run in `Mix.Task`s.

  ## Example

  Normally system modules are excluded,
  but can be added by manually passing the respective system application:
  ```elixir
  iex> ApplicationX.modules(:logger)
  [Logger, Logger.App, Logger.BackendSupervisor, Logger.Backends.Console,
    Logger.Config, Logger.Counter, Logger.Filter, Logger.Formatter, Logger.Handler,
    Logger.Translator, Logger.Utils, Logger.Watcher]
  ```

  Duplicate applications are ignored:
  ```elixir
  iex> ApplicationX.modules([:logger, :logger])
  [Logger, Logger.App, Logger.BackendSupervisor, Logger.Backends.Console,
    Logger.Config, Logger.Counter, Logger.Filter, Logger.Formatter, Logger.Handler,
    Logger.Translator, Logger.Utils, Logger.Watcher]
  ```

  Unknown applications are safe to pass:
  ```elixir
  iex> ApplicationX.modules(:fake)
  []
  ```
  """
  @spec modules(atom | [atom]) :: [module]
  def modules(app) when is_atom(app), do: gather_modules([app])
  def modules(apps), do: gather_modules(apps)

  @doc ~S"""
  Check whether the given app is a runtime dependency of the main application.

  Included applications are inside build releases, but do not count as runtime.

  This excludes system modules.
  This function is save to run in `Mix.Task`s.

  ## Examples

  ```elixir
  iex> ApplicationX.runtime?(:common_x)
  true
  iex> ApplicationX.runtime?(:jason)
  false
  ```

  Unknown applications are safe to pass:
  ```elixir
  iex> ApplicationX.modules(:fake)
  false
  ```
  """
  @spec runtime?(app :: atom) :: boolean
  def runtime?(app), do: gather_runtime?([main()], app)

  ### Helpers ###

  @spec gather_runtime?([atom], target :: atom, [atom]) :: boolean
  defp gather_runtime?(apps, target, checked \\ [])
  defp gather_runtime?([], _target, _checked), do: false
  defp gather_runtime?([target | _], target, _checked), do: true

  defp gather_runtime?([app | t], target, acc) do
    if app in acc do
      gather_runtime?(t, target, acc)
    else
      runtime = app |> app_keys() |> Keyword.get(:applications, [])
      gather_runtime?(t ++ (runtime -- @ignore), target, [app | acc])
    end
  end

  @spec gather_applications([atom], [atom]) :: [atom]
  defp gather_applications(apps, acc \\ [])
  defp gather_applications([], acc), do: acc

  defp gather_applications([app | t], acc) do
    if app in acc do
      gather_applications(t, acc)
    else
      gather_applications(t ++ (deps(app) -- @ignore), [app | acc])
    end
  end

  @spec gather_modules([atom], %{required(atom) => [module]}) :: [module]
  defp gather_modules(apps, acc \\ %{})
  defp gather_modules([], acc), do: acc |> Map.values() |> List.flatten()

  defp gather_modules([app | t], acc) do
    if Map.has_key?(acc, app) do
      gather_modules(t, acc)
    else
      data = app_keys(app)
      gather_modules(t ++ (deps(data) -- @ignore), Map.put(acc, app, data[:modules] || []))
    end
  end

  defp deps(app) when is_atom(app), do: app |> app_keys() |> deps()

  defp deps(opts),
    do: Keyword.get(opts, :applications, []) ++ Keyword.get(opts, :included_applications, [])

  @spec app_keys(app :: atom) :: Keyword.t()
  defp app_keys(app) do
    :application.load(app)

    case :application.get_all_key(app) do
      {:ok, data} -> data
      _ -> []
    end
  end
end