defmodule Skuld.Effects.Port.EffectfulFacade do @moduledoc """ Generates an effectful dispatch facade for a port contract. `use Skuld.Effects.Port.EffectfulFacade` reads a contract's metadata and generates effectful caller functions (returning computations) and `__key__` helpers for test stub matching. ## Single-module (simplest) With no options, the module is both the contract and the dispatch facade: defmodule MyApp.Todos do use Skuld.Effects.Port.EffectfulFacade defcallback get_todo(id :: String.t()) :: {:ok, Todo.t()} | {:error, term()} defcallback list_todos() :: [Todo.t()] end `MyApp.Todos` has effectful `@callback`s, `__callbacks__/0`, `__port_effectful__?/0`, and facade dispatch functions — all in one module. ## From an existing DoubleDown Contract When you have a separate contract module, use the `:double_down_contract` option: defmodule MyApp.Todos.Contract do use DoubleDown.Contract defcallback get_todo(id :: String.t()) :: {:ok, Todo.t()} | {:error, term()} defcallback list_todos() :: [Todo.t()] end defmodule MyApp.Todos do use Skuld.Effects.Port.EffectfulFacade, double_down_contract: MyApp.Todos.Contract end `MyApp.Todos` is both the effectful contract (has effectful `@callback`s, `__callbacks__/0`, `__port_effectful__?/0`) and the dispatch facade. ## Separate effectful contract and facade For cases where you want them in different modules: defmodule MyApp.Todos.Effectful do use Skuld.Adapter.EffectfulContract, double_down_contract: MyApp.Todos.Contract end defmodule MyApp.Todos do use Skuld.Effects.Port.EffectfulFacade, contract: MyApp.Todos.Effectful end ## Handler Installation comp do todo <- MyApp.Todos.get_todo("42") todo end |> Port.with_handler(%{MyApp.Todos => MyApp.Todos.Ecto}) |> Throw.with_handler() |> Comp.run!() ## Options * `:contract` — the effectful contract module. Defaults to `__MODULE__`. * `:double_down_contract` — the DoubleDown contract module. When given (and `:contract` is not), implicitly issues `use Skuld.Adapter.EffectfulContract` and sets `:contract` to `__MODULE__`. Cannot be combined with `:contract`. * Without options: single-module pattern — `use DoubleDown.Contract` is issued implicitly and everything is generated on the same module. """ @doc false defmacro __using__(opts) do has_contract? = Keyword.has_key?(opts, :contract) has_double_down? = Keyword.has_key?(opts, :double_down_contract) contract = case Keyword.get(opts, :contract) do nil -> __CALLER__.module c -> Macro.expand(c, __CALLER__) end double_down_contract = case Keyword.get(opts, :double_down_contract) do nil -> nil c -> Macro.expand(c, __CALLER__) end self_ref? = contract == __CALLER__.module # Validate: double_down_contract: is only allowed when contract: is not given if has_double_down? and has_contract? do raise CompileError, description: "Cannot specify both :contract and :double_down_contract. " <> "Use :double_down_contract for combined effectful contract + facade, " <> "or :contract for a separate effectful contract.", file: __CALLER__.file, line: __CALLER__.line end cond do # Single-module: no options at all — contract defaults to __MODULE__ # with no separate DD contract. Issue DD.Contract (callbacks: false) # and register @before_compile. not has_contract? and not has_double_down? -> quote do use DoubleDown.Contract, callbacks: false @skuld_port_contract __MODULE__ @before_compile {Skuld.Effects.Port.EffectfulFacade, :__before_compile__} end # Combined: double_down_contract given, no contract — implicitly issue # use EffectfulContract and set contract to __MODULE__ has_double_down? -> quote do use Skuld.Adapter.EffectfulContract, double_down_contract: unquote(double_down_contract) @skuld_port_contract unquote(contract) @before_compile {Skuld.Effects.Port.EffectfulFacade, :__before_compile__} end # Self-referencing (contract: __MODULE__, no double_down_contract) # The effectful contract was already set up (by EffectfulContract above) self_ref? -> quote do @skuld_port_contract unquote(contract) @before_compile {Skuld.Effects.Port.EffectfulFacade, :__before_compile__} end # Separate module true -> quote do require unquote(contract) @skuld_port_contract unquote(contract) @before_compile {Skuld.Effects.Port.EffectfulFacade, :__before_compile__} end end end @doc false defmacro __before_compile__(env) do contract = Module.get_attribute(env.module, :skuld_port_contract) {operations, is_single_module?} = if contract == env.module do double_down_contract = Module.get_attribute(env.module, :skuld_double_down_contract) if double_down_contract do # Combined pattern: EffectfulContract ran, operations come from # the separate DD contract module. {double_down_contract.__callbacks__(), false} else # Single-module pattern: DD.Contract (callbacks: false) ran, # __callbacks__/0 exists but can't be called yet. Read raw # @callback_operations and normalise to __callbacks__/0 format. raw_ops = Module.get_attribute(env.module, :callback_operations) |> Enum.reverse() if raw_ops == [] do raise CompileError, description: "#{inspect(env.module)} uses Skuld.Effects.Port.EffectfulFacade but has no defcallback declarations", file: env.file, line: 0 end {normalize_operations(raw_ops), true} end else unless Code.ensure_loaded?(contract) do raise CompileError, description: "Contract module #{inspect(contract)} is not loaded. " <> "Ensure it is compiled before #{inspect(env.module)}.", file: env.file, line: 0 end unless function_exported?(contract, :__callbacks__, 0) do raise CompileError, description: "#{inspect(contract)} does not define __callbacks__/0. " <> "Did you `use DoubleDown.Contract` and add `defcallback` declarations?", file: env.file, line: 0 end {contract.__callbacks__(), false} end callers = Enum.map(operations, &generate_caller(&1, contract)) key_helpers = Enum.map(operations, &generate_key_helper(&1, contract)) # Single-module pattern: generate effectful @callback declarations # and __port_effectful__?/0 (DD.Contract already generated __callbacks__/0). effectful_block = if is_single_module? do effectful_callbacks = Enum.map(operations, &generate_effectful_callback/1) quote do unquote_splicing(effectful_callbacks) @doc false def __port_effectful__?, do: true end end quote do @moduledoc """ Effectful dispatch facade for `#{inspect(unquote(contract))}`. Provides typed public functions returning `computation(return_type)` values that dispatch to the configured implementation via the Port effect. Also provides `__key__` helpers for test stub matching. """ unquote(effectful_block) unquote_splicing(callers) unquote_splicing(key_helpers) end end # ------------------------------------------------------------------- # Code Generation: Effectful caller functions # ------------------------------------------------------------------- defp generate_caller( %{ name: name, params: param_names, param_types: param_types, return_type: return_type, user_doc: user_doc }, contract_module ) do param_vars = Enum.map(param_names, fn pname -> {pname, [], nil} end) args_list = param_vars spec_params = param_types comp_type = {:computation, [], [return_type]} doc_ast = if user_doc do {_line, doc_content} = user_doc quote do @doc unquote(doc_content) end else doc_string = "Port operation: `#{name}/#{length(param_names)}`\n\nDispatches to the configured implementation via the Port effect.\n" quote do @doc unquote(doc_string) end end quote do unquote(doc_ast) @spec unquote(name)(unquote_splicing(spec_params)) :: Skuld.Comp.Types.unquote(comp_type) def unquote(name)(unquote_splicing(param_vars)) do Skuld.Effects.Port.request(unquote(contract_module), unquote(name), unquote(args_list)) end end end # ------------------------------------------------------------------- # Code Generation: Key helpers (for Port effect test stubs) # ------------------------------------------------------------------- defp generate_key_helper(%{name: name, params: param_names}, contract_module) do param_vars = Enum.map(param_names, fn pname -> {pname, [], nil} end) args_list = param_vars doc_string = "Build a test stub key for the `#{name}` port operation.\n" quote do @doc unquote(doc_string) def __key__(unquote(name), unquote_splicing(param_vars)) do Skuld.Effects.Port.key(unquote(contract_module), unquote(name), unquote(args_list)) end end end # ------------------------------------------------------------------- # Code Generation: Effectful @callback declarations # ------------------------------------------------------------------- defp generate_effectful_callback(%{ name: name, params: param_names, param_types: param_types, return_type: return_type }) do callback_params = Enum.zip(param_names, param_types) |> Enum.map(fn {pname, ptype} -> {:"::", [], [{pname, [], nil}, ptype]} end) comp_type = {:computation, [], [return_type]} quote do @callback unquote(name)(unquote_splicing(callback_params)) :: Skuld.Comp.Types.unquote(comp_type) end end # ------------------------------------------------------------------- # Raw @callback_operations → __callbacks__/0 format # ------------------------------------------------------------------- # # @callback_operations uses :param_names and lacks :arity/:params. # __callbacks__/0 uses :params (list) and :arity (integer). # Normalise so generate_caller/2 and generate_effectful_callback/1 # can consume operations from either source. defp normalize_operations(raw_ops) do Enum.map(raw_ops, fn %{ name: name, param_names: param_names, param_types: param_types, return_type: return_type, pre_dispatch: pre_dispatch, warn_on_typespec_mismatch?: warn_on_typespec_mismatch?, user_doc: user_doc } -> %{ name: name, params: param_names, param_types: param_types, return_type: return_type, pre_dispatch: pre_dispatch, warn_on_typespec_mismatch?: warn_on_typespec_mismatch?, user_doc: user_doc, arity: length(param_names) } end) end end