Skip to main content

lib/mix/tasks/compile.nebula.ex

defmodule Mix.Tasks.Compile.Nebula do
  @moduledoc """
  Mix compiler that guards against forgetting `nebula_api_server/0`.

  Opt in from **each** consuming app's `mix.exs` (per-app — see the umbrella note):

      def project do
        [
          # ...
          compilers: Mix.compilers() ++ [:nebula]
        ]
      end

  It runs after `:app`, reads the persisted NebulaAPI attributes from the beams, and:

    * **fails** when the app has local methods on this build but no `nebula_api_server()`
      is wired (workers that would never start);
    * **warns** when a server is wired but the app defines no `defapi` methods at all
      (a server with nothing to serve — likely a leftover `nebula_api_server()`).

  ## Umbrella note

  Per-app: it must be in the `compilers:` of each child app. `mix compile` in an umbrella
  recurses into the child apps and runs *their* `compilers:`; adding `:nebula` to the umbrella
  **root** `mix.exs` does **nothing** (the root's custom compilers are not invoked for an
  `apps_path` project). There is no single root-level switch — opt each app in.
  """

  use Mix.Task.Compiler

  # Per child app: `mix compile` recurses into each app and runs its own compilers, so this runs
  # once per app that lists `:nebula` (with `:app` set). NOT invoked at the umbrella root
  # (apps_path, no `:app`) — a root-only placement does nothing; opt each app in.
  @recursive true

  @impl true
  def run(_argv) do
    case Mix.Project.config()[:app] do
      nil ->
        {:noop, []}

      app ->
        case NebulaAPI.CompilerCheck.verify(modules_attrs()) do
          :ok ->
            {:noop, []}

          {:warn, :server_without_methods} ->
            # A wired server with nothing to serve is a smell, not a build-breaker.
            Mix.shell().info(warning_message(app))
            {:noop, []}

          {:error, local_modules} ->
            message = error_message(app, local_modules)
            Mix.shell().error(message)
            {:error, [diagnostic(message)]}
        end
    end
  end

  defp error_message(app, modules) do
    app_module = application_module(app)
    app_label = "   Application: "

    app_line =
      if app_module do
        caret_indent = String.duplicate(" ", String.length(app_label))

        app_label <>
          inspect(app_module) <>
          "\n" <>
          caret_indent <> "^------ hint: add nebula_api_server() to its supervisor's children"
      else
        app_label <> "(unknown)"
      end

    """
    Found #{length(modules)} module(s) using NebulaAPI with local methods in app #{inspect(app)}, \
    but no nebula_api_server() has been found in #{inspect(app)}'s supervisor — their RPC workers \
    will never start.

       App:         #{inspect(app)}
    #{app_line}
       Modules using NebulaAPI (with local methods on this node):
    #{Enum.map_join(modules, "\n", &"         - #{inspect(&1)}")}
    """
  end

  defp warning_message(app) do
    """
    warning: app #{inspect(app)} wired nebula_api_server() but defines no defapi methods — \
    the server will start no workers (nothing to serve). Add defapi endpoints, or drop \
    nebula_api_server() from #{inspect(app)}'s supervisor.
    """
  end

  defp application_module(app) do
    app_file = Path.join(Mix.Project.compile_path(), "#{app}.app")

    with {:ok, [{:application, ^app, props}]} <- :file.consult(String.to_charlist(app_file)),
         {module, _args} <- Keyword.get(props, :mod) do
      module
    else
      _ -> nil
    end
  end

  defp modules_attrs do
    Mix.Project.compile_path()
    |> Path.join("*.beam")
    |> Path.wildcard()
    |> Enum.flat_map(fn beam ->
      case :beam_lib.chunks(String.to_charlist(beam), [:attributes]) do
        {:ok, {module, [attributes: attrs]}} -> [{module, attrs}]
        _ -> []
      end
    end)
  end

  defp diagnostic(message) do
    %Mix.Task.Compiler.Diagnostic{
      compiler_name: "nebula",
      file: Path.relative_to_cwd(Mix.Project.project_file()),
      severity: :error,
      message: message,
      position: 0,
      details: nil
    }
  end
end