Skip to main content

lib/skuld/page_machine.ex

defmodule Skuld.PageMachine do
  @moduledoc """
  LiveView integration for FiberPool.Server — a multi-fiber page machine
  with bidirectional message passing.

  ## Usage

      use Skuld.PageMachine,
        tag: :checkout,
        on_yield: &handle_yield/2,
        on_complete: &handle_complete/2

  ## Multi-spindle mode

  For pages with concurrent spindles, use `/3` callbacks:

      on_yield: &handle_yield/3

      defp handle_yield(:cart, status, socket), do: ...
      defp handle_yield(:checkout, step, socket), do: ...

  ## Event routing

  Events route to spindles by name:

      def_pipe_event "search", into: :products, before: &start_spinner/1
      def_pipe_event "buy", into: :products

  ## Typed contract

  For compile-time validation of the entire spindle ↔ LiveView contract,
  define a contract module with `use Skuld.PageMachine.Contract` and pass
  it via the `:contract` option:

      defmodule MyApp.StoreContract do
        use Skuld.PageMachine.Contract

        defevent "search", SearchEvent, params: [query: String.t()]
        defevent "buy", BuyEvent, params: [product: Product.t()]

        defyield :browsing
        defyield :results, params: [products: [Product.t()], total: integer()]
        defyield :checkout, :shipping
      end

      use Skuld.PageMachine,
        contract: MyApp.StoreContract,
        on_yield: &handle_yield/3

  The contract auto-generates `handle_event/3` clauses for each event,
  so manual `def_pipe_event` declarations are unnecessary (though still
  available for ad-hoc additions).
  """

  alias Skuld.FiberPool.Server, as: FiberServer

  @default_tag Module.concat(__MODULE__, Default)
  @default_assign_key Module.concat(__MODULE__, DefaultAssign)

  @doc "The default PageMachine tag."
  def default_tag, do: @default_tag

  @doc "The default assign key used to look up the PageMachine pid from socket.assigns."
  def default_assign_key, do: @default_assign_key

  @doc """
  Start a page machine in a separate FiberPool.Server process.

  Takes a keyword list of `{spindle_key, computation}` pairs:

      {:ok, pid} = PageMachine.run(products: ProductBrowserSpindle.run(%{}))

  When a socket is passed as the first argument, the pid is stored
  automatically in `socket.assigns` under the default assign key
  (`Skuld.PageMachine.DefaultAssign`):

      socket =
        PageMachine.run(socket, products: ProductBrowserSpindle.run(%{}))

  Multiple spindles can be started at once:

      PageMachine.run(products: product_comp, checkout: checkout_comp)

  ## Options

  - `:tag` — the PMC tag for message routing. Defaults to
    `Skuld.PageMachine.Default`. Only needed for multi-PMC pages.
  """
  def run(fibers, _opts \\ [])

  def run(fibers, _opts) when is_list(fibers) do
    FiberServer.start_link(fibers)
  end

  def run(%{assigns: assigns} = socket, fibers) when is_list(fibers) do
    {:ok, pid} = FiberServer.start_link(fibers)
    %{socket | assigns: Map.put(assigns, @default_assign_key, pid)}
  end

  @doc "Resume a yielded spindle with a value."
  def resume(pid, fiber_key, value) do
    FiberServer.resume(pid, fiber_key, value)
  end

  @doc "Cancel a running page machine and all its spindles."
  def cancel(pid) do
    Process.exit(pid, :normal)
  end

  #############################################################################
  ## def_pipe_event macros
  #############################################################################

  defmacro def_pipe_event(event, opts \\ []) do
    generate_clause(event, nil, opts)
  end

  defmacro def_pipe_event(event, pattern, opts) do
    generate_clause(event, pattern, opts)
  end

  defp generate_clause(event, pattern, opts) do
    into = Keyword.get(opts, :into)
    before = Keyword.get(opts, :before)
    assign_key = Keyword.get(opts, :assign, @default_assign_key)
    block = Keyword.get(opts, :do)

    body =
      case pattern do
        nil ->
          quote do
            def handle_event(unquote(event), params, socket) do
              unquote(before_call(before))

              Skuld.PageMachine.resume(
                Map.fetch!(socket.assigns, unquote(assign_key)),
                unquote(into),
                {unquote(event), params}
              )

              {:noreply, socket}
            end
          end

        _ ->
          quote do
            def handle_event(unquote(event), unquote(pattern), socket) do
              unquote(before_call(before))

              value = unquote(block)

              Skuld.PageMachine.resume(
                Map.fetch!(socket.assigns, unquote(assign_key)),
                unquote(into),
                value
              )

              {:noreply, socket}
            end
          end
      end

    body
  end

  defp before_call(nil), do: nil
  defp before_call(callback), do: quote(do: socket = unquote(callback).(socket))

  #############################################################################
  ## __using__ macro
  #############################################################################

  defmacro __using__(opts) do
    contract = Keyword.get(opts, :contract)
    tag = Keyword.get(opts, :tag, @default_tag)
    on_yield_ref = Keyword.fetch!(opts, :on_yield)
    on_complete = Keyword.get(opts, :on_complete)
    on_error = Keyword.get(opts, :on_error)
    on_cancel = Keyword.get(opts, :on_cancel)
    on_throw = Keyword.get(opts, :on_throw)

    yield_arity = callback_arity(on_yield_ref)

    clauses =
      [
        build_clause(
          on_yield_ref,
          yield_arity,
          quote(do: %Skuld.Comp.ExternalSuspend{value: value}),
          quote(do: value)
        ),
        if(on_error,
          do: build_clause(on_error, 2, quote(do: {:error, reason}), quote(do: reason))
        ),
        if(on_cancel,
          do:
            build_clause(
              on_cancel,
              2,
              quote(do: %Skuld.Comp.Cancelled{reason: reason}),
              quote(do: reason)
            )
        ),
        if(on_throw,
          do:
            build_clause(
              on_throw,
              2,
              quote(do: %Skuld.Comp.Throw{error: error}),
              quote(do: error)
            )
        ),
        if(on_complete,
          do: build_clause(on_complete, 2, quote(do: value), quote(do: value))
        )
      ]
      |> Enum.filter(& &1)

    contract_clauses =
      if contract do
        contract = resolve_contract(contract, __CALLER__)

        contract.__contract_events__()
        |> Enum.map(fn %{
                         event: event_name,
                         spindle: spindle,
                         struct_name: struct_name,
                         params: params
                       } ->
          value =
            cond do
              struct_name && params != [] ->
                struct_module = Module.concat(spindle, struct_name)

                quote do
                  atom_params =
                    for {key, val} <- params, into: %{}, do: {String.to_existing_atom(key), val}

                  struct(unquote(struct_module), atom_params)
                end

              params != [] ->
                quote(do: params)

              true ->
                quote(do: {unquote(event_name), params})
            end

          quote do
            def handle_event(unquote(event_name), params, socket) do
              Skuld.PageMachine.resume(
                Map.fetch!(socket.assigns, @pmc_assign_key),
                unquote(spindle),
                unquote(value)
              )

              {:noreply, socket}
            end
          end
        end)
      else
        []
      end

    quote do
      @pmc_tag unquote(tag)
      @pmc_assign_key unquote(@default_assign_key)

      import Skuld.PageMachine,
        only: [def_pipe_event: 1, def_pipe_event: 2, def_pipe_event: 3]

      (unquote_splicing(clauses))
      (unquote_splicing(contract_clauses))
    end
  end

  defp resolve_contract(contract, caller) do
    contract = Macro.expand(contract, caller)

    unless Code.ensure_loaded?(contract) do
      raise CompileError,
        description:
          "Contract module #{inspect(contract)} is not loaded. " <>
            "Ensure it is compiled before the module using it.",
        file: caller.file,
        line: 0
    end

    unless function_exported?(contract, :__contract_events__, 0) do
      raise CompileError,
        description:
          "#{inspect(contract)} does not export __contract_events__/0. " <>
            "Did you `use Skuld.PageMachine.Contract`?",
        file: caller.file,
        line: 0
    end

    contract
  end

  defp callback_arity({:&, _, [{:/, _, [_, arity]}]}), do: arity
  defp callback_arity(_), do: 2

  defp build_clause(callback_ref, arity, pattern, value_expr) do
    callback = callback_ref

    if arity == 3 do
      quote do
        def handle_info(
              {Skuld.FiberPool.Server, fiber_key, unquote(pattern)},
              socket
            ) do
          unquote(callback).(fiber_key, unquote(value_expr), socket)
        end
      end
    else
      quote do
        def handle_info(
              {Skuld.FiberPool.Server, @pmc_tag, unquote(pattern)},
              socket
            ) do
          unquote(callback).(unquote(value_expr), socket)
        end
      end
    end
  end
end