lib/mix/tasks/compile.lockstep_rewrite.ex

defmodule Mix.Tasks.Compile.LockstepRewrite do
  @moduledoc """
  Mix compiler that rewrites source files via `Lockstep.Rewriter`
  before the standard Elixir compiler runs.

  ## Setup

  In your `mix.exs`:

      def project, do: [
        ...
        compilers: compilers(Mix.env()),
        elixirc_paths: elixirc_paths(Mix.env()),
        lockstep_rewrite: lockstep_rewrite(Mix.env()),
      ]

      defp compilers(:test), do: [:lockstep_rewrite] ++ Mix.compilers()
      defp compilers(_), do: Mix.compilers()

      defp elixirc_paths(:test) do
        ["_build/test/lockstep_rewritten/lib", "test/support"]
      end

      defp elixirc_paths(_), do: ["lib"]

      defp lockstep_rewrite(:test) do
        %{paths: ["lib/**/*.ex", "src/**/*.erl"]}
      end

      defp lockstep_rewrite(_), do: nil

  In test env this will:

    1. Read every `lib/**/*.ex` and `src/**/*.erl` file.
    2. Run `.ex` through `Lockstep.Rewriter` and `.erl` through
       `Lockstep.ErlangRewriter` -- both rewrite vanilla OTP calls
       (`GenServer.call`, `gen_server:call`, `send/2`, `Pid ! Msg`,
       bare `receive`, ...) to their `Lockstep.*` controller-aware
       equivalents.
    3. Write the rewritten copy to `_build/test/lockstep_rewritten/...`.
    4. The standard compilers then read from there because
       `elixirc_paths(:test)` and `erlc_paths(:test)` are overridden.

  In dev / prod, the original sources compile normally; the rewriter
  never runs.

  ## Configuration

  The `:lockstep_rewrite` project option takes a map:

    * `:paths` (required) -- list of glob patterns to rewrite.
    * `:output` (optional) -- output directory. Defaults to
      `_build/$ENV/lockstep_rewritten`.

  Set the option to `nil` (or omit it) to skip rewriting in that env.

  ## Limitations

    * Comments and source formatting are lost (the rewriter round-
      trips through AST).
    * Only handles user code matching the configured paths. Dep
      libraries (in `deps/`) need a separate workflow because the dep's
      compiled BEAM in `_build/$ENV/lib/<dep>/ebin` would conflict with
      our rewritten copy. See the README's "Rewriting deps" section.
  """

  use Mix.Task.Compiler

  @impl Mix.Task.Compiler
  def run(_args) do
    case Mix.Project.config()[:lockstep_rewrite] do
      nil ->
        {:ok, []}

      config when is_map(config) ->
        do_run(config)
    end
  end

  defp do_run(config) do
    case Lockstep.MixCompiler.compile(config) do
      {:ok, files} ->
        if files != [], do: Mix.shell().info("[lockstep] rewrote #{length(files)} file(s)")
        {:ok, []}

      {:error, {:parse_error, path, meta, message, token}} ->
        diagnostic = %Mix.Task.Compiler.Diagnostic{
          file: path,
          severity: :error,
          message: "lockstep_rewrite parse error: #{message} #{inspect(token)}",
          position: Keyword.get(meta, :line, 0),
          compiler_name: "lockstep_rewrite"
        }

        {:error, [diagnostic]}
    end
  end
end