lib/mix/tasks/helpers.ex

defmodule Ash.Mix.Tasks.Helpers do
  @moduledoc """
  Helpers for Ash Mix tasks.
  """

  require Logger

  @doc """
  Gets all extensions in use by the current project's domains and resources
  """
  def extensions!(argv, opts \\ []) do
    if opts[:in_use?] do
      Mix.shell().info("Getting extensions in use by resources in current project...")
      domains = Ash.Mix.Tasks.Helpers.domains!(argv)

      resource_extensions =
        domains
        |> Enum.flat_map(&Ash.Domain.Info.resources/1)
        |> all_extensions()

      domains
      |> all_extensions()
      |> Enum.concat(resource_extensions)
      |> Enum.uniq()
      |> case do
        [] ->
          Mix.shell().info("No extensions in use by resources in current project...")

        extensions ->
          extensions
      end
    else
      Mix.shell().info("Getting extensions in current project...")

      apps =
        if Code.ensure_loaded?(Mix.Project) do
          if apps_paths = Mix.Project.apps_paths() do
            apps_paths |> Map.keys() |> Enum.sort()
          else
            [Mix.Project.config()[:app]]
          end
        else
          []
        end

      apps()
      |> Stream.concat(apps)
      |> Stream.uniq()
      |> Task.async_stream(
        fn app ->
          app
          |> :application.get_key(:modules)
          |> case do
            :undefined ->
              []

            {_, mods} ->
              mods
              |> List.wrap()
              |> Enum.filter(&Spark.implements_behaviour?(&1, Spark.Dsl.Extension))
          end
        end,
        timeout: :infinity
      )
      |> Stream.map(&elem(&1, 1))
      |> Stream.flat_map(& &1)
      |> Stream.uniq()
      |> Enum.to_list()
    end
  end

  Code.ensure_loaded!(Mix.Project)

  if function_exported?(Mix.Project, :deps_tree, 0) do
    # for our app, and all dependency apps, we want to find extensions
    # the benefit of not just getting all loaded applications is that this
    # is actually a surprisingly expensive thing to do for every single built
    # in application for elixir/erlang. Instead we get anything w/ a dependency on ash or spark
    # this could miss things, but its unlikely. And if it misses things, it actually should be
    # fixed in the dependency that is relying on a transitive dependency :)
    defp apps do
      Mix.Project.deps_tree()
      |> Stream.filter(fn {_, nested_deps} ->
        Enum.any?(nested_deps, &(&1 == :spark || &1 == :ash))
      end)
      |> Stream.map(&elem(&1, 0))
    end
  else
    defp apps do
      Logger.warning(
        "Mix.Project.deps_tree/0 not available, falling back to loaded_applications/0. Upgrade to Elixir 1.15+ to make this *much* faster."
      )

      :application.loaded_applications()
      |> Stream.map(&elem(&1, 0))
    end
  end

  @doc """
  Get all domains for the current project and ensure they are compiled.
  """
  def domains!(argv) do
    {opts, _} = OptionParser.parse!(argv, strict: [domains: :string])

    domains =
      if opts[:domains] && opts[:domains] != "" do
        opts[:domains]
        |> Kernel.||("")
        |> String.split(",")
        |> Enum.flat_map(fn
          "" ->
            []

          domain ->
            [Module.concat([domain])]
        end)
      else
        apps =
          if Code.ensure_loaded?(Mix.Project) do
            if apps_paths = Mix.Project.apps_paths() do
              apps_paths |> Map.keys() |> Enum.sort()
            else
              [Mix.Project.config()[:app]]
            end
          else
            []
          end

        Enum.flat_map(apps, &Application.get_env(&1, :ash_domains, []))
      end

    domains
    |> Enum.map(&ensure_compiled(&1, argv))
    |> case do
      [] ->
        raise "must supply the --domains argument, or set `config :my_app, ash_domains: [...]` in config"

      domains ->
        domains
    end
  end

  defp all_extensions(modules) do
    modules
    |> Enum.flat_map(&Spark.extensions/1)
    |> Enum.uniq()
  end

  defp ensure_compiled(domain, args) do
    if Code.ensure_loaded?(Mix.Tasks.App.Config) do
      Mix.Task.run("app.config", args)
    else
      Mix.Task.run("loadpaths", args)
      "--no-compile" not in args && Mix.Task.run("compile", args)
    end

    case Code.ensure_compiled(domain) do
      {:module, _} ->
        # TODO: We shouldn't need to make sure that the resources are compiled
        domain
        |> Ash.Domain.Info.resources()
        |> Enum.each(&Code.ensure_compiled/1)

        domain

      {:error, error} ->
        Mix.raise("Could not load #{inspect(domain)}, error: #{inspect(error)}. ")
    end
  end
end