lib/formular/client/compiler.ex

defmodule Formular.Client.Compiler do
  @moduledoc """
  Compiler for making the string code into an Elixir module.

  Here we use Agent to prevent concurrent compilation.
  """

  alias Formular.Client.PubSub

  use GenServer
  require Logger

  @reg Formular.Client.Compiler.Registry
  @compile_timeout :timer.minutes(1)
  @idle_timeout :timer.minutes(1)

  def handle_new_code_revision({pid, ref}, name, code, config, opts) do
    {:ok, server} = try_start(name)

    timeout = Map.get(config, :read_timeout, @compile_timeout)
    GenServer.call(server, {:compile, {pid, ref, code, config, opts}}, timeout)

    :ok
  end

  defp try_start(name) do
    case GenServer.start_link(__MODULE__, name, name: via_tuple(name)) do
      {:ok, pid} -> {:ok, pid}
      {:error, {:already_started, pid}} -> {:ok, pid}
    end
  end

  defp via_tuple(name),
    do: {:via, Registry, {@reg, name}}

  @impl true
  def init(name) do
    {:ok, name}
  end

  @impl true
  def handle_call({:compile, {pid, ref, code, config, opts}}, _from, name) do
    case config do
      %{compiler: {m, f, a}} ->
        apply(m, f, [{code, name, opts} | a])
        |> report_result({pid, ref}, {name, opts}, code)

      %{compiler: f} when is_function(f, 1) ->
        apply(f, [{code, name, opts}])
        |> report_result({pid, ref}, {name, opts}, code)
    end

    {:reply, :ok, name, @idle_timeout}
  end

  @impl true
  def handle_info(:timeout, name) do
    {:stop, :normal, name}
  end

  def compile({code, name, opts}) do
    mod = Keyword.get(opts, :compile_as)

    Logger.info(["Recompiling formula: #{name} to module #{mod}. Code: #{inspect(code)}"])

    case mod do
      nil ->
        :ok

      _ when is_atom(mod) ->
        temporarily_disable_compilation_warning(fn ->
          do_compile(code, name, opts)
        end)
    end
  end

  defp do_compile(code, _name, opts) do
    mod = Keyword.get(opts, :compile_as)

    with {:module, ^mod} <- Formular.compile_to_module!(code, mod, opts) do
      :ok
    end
  rescue
    e in CompileError ->
      {:error, e}
  end

  defp temporarily_disable_compilation_warning(f) do
    prev_mc_config = Code.get_compiler_option(:ignore_module_conflict)
    Code.put_compiler_option(:ignore_module_conflict, true)

    ret = f.()

    Code.put_compiler_option(:ignore_module_conflict, prev_mc_config)

    ret
  end

  defp report_result(:ok, {pid, ref}, {name, opts}, code) do
    broadcast_success(name, code, opts[:compile_as], opts[:context])
    send(pid, {ref, :ok})
  end

  defp report_result({:error, e}, {pid, ref}, {name, opts}, code) do
    broadcast_failure(e, name, code, opts[:compile_as], opts[:context])
    send(pid, {ref, {:error, e}})
  end

  defp broadcast_success(name, code, mod, context) do
    PubSub.dispatch({:compiled, name, code, mod: mod, context: context})
  end

  defp broadcast_failure(err, name, code, mod, context) do
    PubSub.dispatch({:compile_failed, name, err, code, mod: mod, context: context})
  end
end