Skip to main content

lib/mix/tasks/compile/musubi_ts.ex

defmodule Mix.Tasks.Compile.MusubiTs do
  @shortdoc "Renders the Musubi TypeScript bundle for every `state do` module"

  @moduledoc """
  Mix compiler that walks every Musubi `state do` module exposed by the
  current Mix project and writes one TypeScript bundle file with namespaces
  mirroring the Elixir module tree.

  ## Setup

  Add `:musubi_ts` to the project's compiler chain:

      def project do
        [
          ...,
          compilers: Mix.compilers() ++ [:musubi_ts]
        ]
      end

  Running `mix compile` then keeps the bundle in sync automatically. Invoke
  the compiler directly with `mix compile.musubi_ts` if you want to regenerate
  without a full project recompile.

  ## Options

    * `--check` — exit non-zero with a `Mix.Task.Compiler.Diagnostic` if the
      on-disk bundle differs from a freshly-rendered one. Wire this into a
      `precommit` / CI alias to gate drift:

          aliases: [
            precommit: ["compile --warnings-as-errors", "compile.musubi_ts --check", ...]
          ]

  ## Configuration

  Output path defaults to `priv/codegen/ts/musubi.ts`. Override per-app:

      config :musubi, :ts_codegen_output_path, "priv/codegen/ts/musubi.ts"

  ## Discovery

  Every Musubi `state do` module ends up with a manifest entry under
  `Mix.Project.build_path()/musubi-codegen-ts/<inspect(module)>/state.term`,
  stamped at module-compile time by `Musubi.Plugin.TypeScript`'s injected
  `@after_compile` callback. This compiler simply lists those entries —
  there is no beam scan or `:application.get_key/2` walk. Modules whose
  source lives under `test/` (e.g. `test/support/` fixtures) are skipped at
  stamp time so they never appear in the bundle.
  """

  use Mix.Task.Compiler

  alias Musubi.Codegen.TypeScript
  alias Musubi.Codegen.TypeScript.Manifest

  @compiler_name "musubi_ts"
  @default_output_path "priv/codegen/ts/musubi.d.ts"

  @impl Mix.Task.Compiler
  @spec run([String.t()]) ::
          :ok
          | :noop
          | {:ok, [Mix.Task.Compiler.Diagnostic.t()]}
          | {:error, [Mix.Task.Compiler.Diagnostic.t()]}
  def run(argv) do
    {opts, _rest, _invalid} = OptionParser.parse(argv, strict: [check: :boolean])

    Manifest.clean_outdated()

    entries = Manifest.list()
    output_path = configured_output_path()
    contents = TypeScript.render(entries)
    existing = File.read(output_path)
    check? = opts[:check] == true

    cond do
      existing == {:ok, contents} ->
        :noop

      entries == [] and existing == {:error, :enoent} ->
        :noop

      check? ->
        {:error, [drift_diagnostic(output_path)]}

      true ->
        write_bundle!(contents, output_path)
        {:ok, []}
    end
  end

  @impl Mix.Task.Compiler
  @spec manifests() :: [Path.t()]
  def manifests, do: [Manifest.target_dir()]

  @impl Mix.Task.Compiler
  @spec clean() :: :ok
  def clean do
    _ignore = File.rm_rf(Manifest.target_dir())
    :ok
  end

  defp configured_output_path do
    Application.get_env(:musubi, :ts_codegen_output_path, @default_output_path)
  end

  defp write_bundle!(contents, output_path) do
    File.mkdir_p!(Path.dirname(output_path))
    File.write!(output_path, contents)
    Mix.shell().info("[musubi_ts] wrote #{output_path}")
  end

  defp drift_diagnostic(output_path) do
    %Mix.Task.Compiler.Diagnostic{
      compiler_name: @compiler_name,
      file: output_path,
      message:
        "Musubi TypeScript bundle is out of date. Run `mix compile.musubi_ts` and commit the result.",
      position: nil,
      severity: :error
    }
  end
end