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