defmodule Skuld.Effects.Port do @moduledoc """ Effect for dispatching parameterizable blocking calls to pluggable backends. This effect lets domain code express "call this function" without binding to a particular implementation. Each request specifies: * `mod` – module identity (contract or implementation module) * `name` – function name * `args` – list of positional arguments ## Use Cases Port is ideal for wrapping any existing side-effecting Elixir code: * Database queries * HTTP API calls * File system operations * External service integrations * Legacy code that performs I/O ## Result Tuple Convention Port handlers should return `{:ok, value}` or `{:error, reason}` tuples. This convention enables two request modes: * `request/3` – returns the result tuple as-is for caller to handle * `request!/3` – unwraps `{:ok, value}` or dispatches `Throw` on error ## Example alias Skuld.Effects.Port # Implementation returns result tuples defmodule MyApp.UserQueries do def find_by_id(id) do case Repo.get(User, id) do nil -> {:error, {:not_found, User, id}} user -> {:ok, user} end end end # Using request/3 - returns result tuple defcomp find_user(id) do result <- Port.request(MyApp.UserQueries, :find_by_id, [id]) case result do {:ok, user} -> user {:error, _} -> nil end end # Using request!/3 - unwraps or throws defcomp find_user!(id) do user <- Port.request!(MyApp.UserQueries, :find_by_id, [id]) user end # Runtime: dispatch to actual modules find_user!(123) |> Port.with_handler(%{MyApp.UserQueries => :direct}) |> Throw.with_handler() |> Comp.run!() # Test: stub responses with exact key matching find_user!(123) |> Port.with_test_handler(%{ Port.key(MyApp.UserQueries, :find_by_id, [123]) => {:ok, %{id: 123, name: "Alice"}} }) |> Throw.with_handler() |> Comp.run!() # Test: function-based handler with pattern matching find_user!(123) |> Port.with_fn_handler(fn MyApp.UserQueries, :find_by_id, [id] -> {:ok, %{id: id, name: "User \#{id}"}} end) |> Throw.with_handler() |> Comp.run!() """ @behaviour Skuld.Comp.IHandle @behaviour Skuld.Comp.IInstall use Skuld.Comp.DefOp alias Skuld.Comp alias Skuld.Comp.Env alias Skuld.Comp.Throw, as: ThrowResult alias Skuld.Comp.Types alias Skuld.Effects.Throw # Single atom key for all Port state — sig() is __MODULE__ (a plain atom), # faster than tuple keys in map lookups. @state_key "Elixir.Skuld.Effects.Port" ############################################################################# ## Effect State ############################################################################# defmodule State do @moduledoc false @enforce_keys [:registry] defstruct [:registry, :log, :handler_state] @type t :: %__MODULE__{ registry: Skuld.Effects.Port.registry(), log: list() | nil, handler_state: term() } @doc false @spec new(Skuld.Effects.Port.registry()) :: t() def new(registry), do: %__MODULE__{registry: registry} @doc false @spec new(Skuld.Effects.Port.registry(), atom() | nil) :: t() def new(registry, log), do: %__MODULE__{registry: registry, log: log} end ############################################################################# ## Operations (generated by def_op) ############################################################################# def_op(request(mod, name, args)) @request_op @__request_op__ ############################################################################# ## Types ############################################################################# @typedoc "Module identity (contract or implementation module)" @type port_module :: module() @typedoc "Function exported by `port_module`" @type port_name :: atom() @typedoc "List of positional arguments" @type args :: list() @typedoc """ Registry entry for dispatching requests. ## Runtime resolvers (used with `with_handler/3`) * `:direct` – call `apply(mod, name, args)`, result is a plain value * `module` – invokes `apply(module, name, args)` (implementation module). Modules where `__port_effectful__?/0` returns truthy (e.g. via `use MyContract.Effectful`) are auto-detected as effectful resolvers whose return values are computations inlined into the current effect context. Returning `false` opts out of auto-detection. * `{:effectful, module}` – explicit effectful resolver (same as above, for backward compatibility or modules without the marker) * `function` (arity 3) – `fun.(mod, name, args)` * `{module, function}` – invokes `apply(module, function, [mod, name, args])` ## Default resolvers (used as `:__default__` catch-all) * `{:test_stub, responses}` – map-based test stubs keyed by `Port.key/3` * `{:test_stub, responses, fallback}` – test stubs with fallback function * `{:fn_dispatch, handler_fn}` – function-based dispatch via `fn(mod, name, args)` * `{:stateful_dispatch, handler_fn}` – stateful dispatch via `fn(mod, name, args, state) -> {result, new_state}` """ @type resolver :: :direct | {:effectful, module()} | (port_module(), port_name(), args() -> term()) | {module(), atom()} | module() | {:test_stub, map()} | {:test_stub, map(), fn_handler()} | {:fn_dispatch, fn_handler()} | {:stateful_dispatch, stateful_handler()} @typedoc "Registry mapping port modules (or `:__default__`) to resolvers" @type registry :: %{(port_module() | :__default__) => resolver()} @typedoc "Function handler for test scenarios - receives (mod, name, args)" @type fn_handler :: (port_module(), port_name(), args() -> term()) @typedoc "Stateful handler function - receives (mod, name, args, state), returns {result, new_state}" @type stateful_handler :: (port_module(), port_name(), args(), term() -> {term(), term()}) # Sentinel registry key for catch-all resolvers (test stubs, fn handlers) @default_key :__default__ ############################################################################# ## Operations ############################################################################# @doc """ Build a request for the given module/function. Returns the result tuple `{:ok, value}` or `{:error, reason}` as-is, allowing the caller to handle errors explicitly. ## Example Port.request(MyApp.UserQueries, :find_by_id, [123]) # => {:ok, %User{...}} or {:error, {:not_found, User, 123}} """ @spec request(port_module(), port_name()) :: Types.computation() def request(mod, name), do: request(mod, name, []) @doc """ Build a request that unwraps the result or throws on error. Expects the handler to return `{:ok, value}` or `{:error, reason}`. On success, returns the unwrapped `value`. On error, dispatches a `Skuld.Effects.Throw` effect with the `reason`. Requires a `Throw.with_handler/1` in the handler chain. ## Example Port.request!(MyApp.UserQueries, :find_by_id, [123]) # => %User{...} or throws {:not_found, User, 123} """ @spec request!(port_module(), port_name(), args()) :: Types.computation() def request!(mod, name, args \\ []) do Comp.bind(request(mod, name, args), fn {:ok, value} -> value {:error, reason} -> Throw.throw(reason) end) end @doc """ Build a request that applies a custom unwrap function, then unwraps or throws. The `unwrap_fn` receives the raw result from the handler and must return `{:ok, value}` or `{:error, reason}`. The result is then handled like `request!/3`: success values are unwrapped, errors dispatch `Throw`. This is used by `Port.Contract` when `bang:` is set to a custom function. Requires a `Throw.with_handler/1` in the handler chain. ## Example Port.request_bang(MyApp.Users, :find_by_id, [123], fn nil -> {:error, :not_found} user -> {:ok, user} end) # => %User{...} or throws :not_found """ @spec request_bang(port_module(), port_name(), args(), (term() -> {:ok, term()} | {:error, term()})) :: Types.computation() def request_bang(mod, name, args, unwrap_fn) do Comp.bind(request(mod, name, args), fn result -> case unwrap_fn.(result) do {:ok, value} -> value {:error, reason} -> Throw.throw(reason) end end) end ############################################################################# ## Key Generation (for test stubs) ############################################################################# @doc """ Build a canonical key usable with `with_test_handler/2`. Arguments are normalized to produce consistent keys. ## Example Port.key(MyApp.UserQueries, :find_by_id, [123]) """ @spec key(port_module(), port_name(), args()) :: {port_module(), port_name(), binary()} def key(mod, name, args) do {mod, name, normalize_args(args)} end @doc false @spec normalize_args(term()) :: binary() def normalize_args(args) do args |> canonical_term() |> :erlang.term_to_binary() end defp canonical_term(%_{} = struct) do # Convert struct to list of {key, value} pairs including __struct__ struct_name = struct.__struct__ struct |> Map.from_struct() |> Enum.map(fn {k, v} -> {k, canonical_term(v)} end) |> Enum.concat([{:__struct__, struct_name}]) |> Enum.sort_by(&elem(&1, 0)) end defp canonical_term(map) when is_map(map) do map |> Enum.map(fn {k, v} -> {k, canonical_term(v)} end) |> Enum.sort_by(&elem(&1, 0)) end defp canonical_term(list) when is_list(list) do if Keyword.keyword?(list) do # Keyword list - sort by key for canonical form list |> Enum.map(fn {k, v} -> {k, canonical_term(v)} end) |> Enum.sort_by(&elem(&1, 0)) else # Regular list - preserve order Enum.map(list, &canonical_term/1) end end defp canonical_term(tuple) when is_tuple(tuple) do {:__tuple__, tuple |> Tuple.to_list() |> Enum.map(&canonical_term/1)} end defp canonical_term(other), do: other ############################################################################# ## Handler Installation - Runtime ############################################################################# @doc """ Install a scoped Port handler for a computation. Pass a registry map keyed by module to control how requests are dispatched. Each entry can be one of: * `:direct` – call `apply(mod, name, args)`, returns a plain value * `module` – invokes `apply(module, name, args)`. Modules where `__port_effectful__?/0` returns truthy (e.g. via `use MyContract.Effectful`) are auto-detected as effectful resolvers whose return values are computations inlined into the current effect context. Returning `false` opts out of auto-detection. * `{:effectful, module}` – explicit effectful resolver (same as above, for backward compatibility or modules without the marker) * `function` (arity 3) – `fun.(mod, name, args)` * `{module, function}` – invokes `apply(module, function, [mod, name, args])` Plain resolvers (`:direct`, function, `{mod, fun}`, module) return values that are passed directly to the continuation. Effectful resolvers (auto- detected or explicit `{:effectful, mod}`) return computations that participate in the surrounding effect context. ## Nested Handlers Nested `with_handler` calls **merge** registries rather than shadowing. Inner entries win on conflict. When the inner scope exits, the previous registry is restored. # Outer registers ModuleA, inner adds ModuleB — both are available my_comp |> Port.with_handler(%{ModuleB => :direct}) # inner: adds ModuleB |> Port.with_handler(%{ModuleA => :direct}) # outer: registers ModuleA |> Comp.run!() Note: `with_test_handler` and `with_fn_handler` do **not** merge with runtime registries — they replace the dispatch mode entirely, which is the expected behaviour for test stubs. ## Options * `:log` — when truthy, enables dispatch logging. Every Port dispatch records a `{mod, name, args, result}` 4-tuple in `Port.State.log`. Disabled by default (nil) for zero overhead in production. * `:output` — transform function `(result, %Port.State{}) -> output` called on scope exit. When logging is enabled, `state.log` contains the log entries in chronological order. ## Example # Plain implementations my_comp |> Port.with_handler(%{ MyApp.UserQueries => :direct, MyApp.Repository => MyApp.Repository.Ecto }) |> Comp.run!() # Effectful implementation — auto-detected via __port_effectful__?/0 my_comp |> Port.with_handler(%{ MyApp.Repository => MyApp.Repository.EffectfulImpl }) |> Throw.with_handler() |> Comp.run!() # With dispatch logging in tests {result, log} = my_comp |> Port.with_handler( %{MyApp.Repo => MyApp.Repo.Test}, log: true, output: fn result, state -> {result, state.log} end ) |> Throw.with_handler() |> Comp.run!() """ @spec with_handler(Types.computation(), registry(), keyword()) :: Types.computation() def with_handler(comp, registry \\ %{}, opts \\ []) do install_registry(comp, registry, opts) end @doc """ Install Port handler via catch clause syntax. Config is the registry map, or `{registry, opts}`: catch Port -> %{MyModule => :direct} Port -> {%{MyModule => :direct}, output: fn r, s -> {r, s} end} """ @impl Skuld.Comp.IInstall def __handle__(comp, {registry, opts}) when is_map(registry) and is_list(opts), do: with_handler(comp, registry, opts) def __handle__(comp, registry) when is_map(registry), do: with_handler(comp, registry) ############################################################################# ## Handler Installation - Test (Map-based) ############################################################################# @doc """ Install a test handler with canned responses. Provide a map of responses keyed by `Port.key/3`. Missing keys will throw `{:port_not_stubbed, key}` unless a `fallback:` function is provided. ## Options * `:fallback` - A function `(mod, name, args) -> result` to call when no exact key match is found. Useful for handling dynamic arguments while still using exact matching for known cases. * `:log` — enable dispatch logging (see `with_handler/3`) * `:output` - Transform `(result, %Port.State{}) -> output` on scope exit ## Example responses = %{ Port.key(MyApp.UserQueries, :find_by_id, [123]) => {:ok, %{name: "Alice"}}, Port.key(MyApp.UserQueries, :find_by_id, [456]) => {:error, :not_found} } my_comp |> Port.with_test_handler(responses) |> Throw.with_handler() |> Comp.run!() # With fallback for dynamic cases my_comp |> Port.with_test_handler(responses, fallback: fn MyApp.AuditQueries, _name, _args -> :ok mod, name, args -> raise "Unhandled: \#{inspect(mod)}.\#{name}(\#{inspect(args)})" end) |> Throw.with_handler() |> Comp.run!() """ @spec with_test_handler(Types.computation(), map(), keyword()) :: Types.computation() def with_test_handler(comp, responses, opts \\ []) when is_map(responses) do fallback = Keyword.get(opts, :fallback) resolver = if fallback do {:test_stub, responses, fallback} else {:test_stub, responses} end install_registry(comp, %{@default_key => resolver}, opts) end ############################################################################# ## Handler Installation - Test (Function-based) ############################################################################# @doc """ Install a function-based test handler. The handler function receives `(mod, name, args)` and can use Elixir's full pattern matching power including guards, pins, and wildcards. If no function clause matches, throws `{:port_not_handled, mod, name, args}`. ## Example handler = fn # Pin specific values MyApp.UserQueries, :find_by_id, [^expected_id] -> {:ok, %{id: expected_id, name: "Expected"}} # Match any value with wildcard MyApp.UserQueries, :find_by_id, [_any_id] -> {:ok, %{id: "default", name: "Default"}} # Match with guards MyApp.Queries, :paginate, [_query, limit] when limit > 100 -> {:error, :limit_too_high} # Match specific module, any function MyApp.AuditQueries, _function, _args -> :ok # Catch-all (optional) mod, fun, args -> raise "Unhandled: \#{inspect(mod)}.\#{fun}(\#{inspect(args)})" end my_comp |> Port.with_fn_handler(handler) |> Throw.with_handler() |> Comp.run!() ## Property-Based Testing Function handlers are ideal for property-based tests where exact values aren't known upfront: property "user lookup succeeds" do check all user_id <- uuid_generator() do handler = fn UserQueries, :find_by_id, [^user_id] -> {:ok, %{id: user_id, name: "Test User"}} end result = find_user(user_id) |> Port.with_fn_handler(handler) |> Throw.with_handler() |> Comp.run!() assert {:ok, _} = result end end """ @spec with_fn_handler(Types.computation(), fn_handler(), keyword()) :: Types.computation() def with_fn_handler(comp, handler_fn, opts \\ []) when is_function(handler_fn, 3) do install_registry(comp, %{@default_key => {:fn_dispatch, handler_fn}}, opts) end ############################################################################# ## Handler Installation - Test (Stateful Function-based) ############################################################################# @doc """ Install a stateful function-based test handler. The handler function receives `(mod, name, args, state)` and returns `{result, new_state}`. State is threaded across Port calls within the scope, enabling test doubles where writes are visible to subsequent reads. ## Options * `:log` — enable dispatch logging (see `with_handler/3`) * `:output` — transform `(result, %Port.State{}) -> output` on scope exit. The `state.handler_state` field contains the final handler state. ## Example # Stateful in-memory store: insert then read back handler = fn MyRepo, :insert, [record], state -> key = {record.__struct__, record.id} {{:ok, record}, Map.put(state, key, record)} MyRepo, :get, [schema, id], state -> key = {schema, id} {Map.get(state, key), state} end {result, final_state} = my_insert_then_read_comp |> Port.with_stateful_handler(%{}, handler, output: fn result, state -> {result, state.handler_state} end ) |> Throw.with_handler() |> Comp.run!() ## Property-Based Testing Stateful handlers pair naturally with property-based tests where the in-memory model serves as the oracle: property "insert then get returns the same record" do check all id <- integer(), name <- string(:alphanumeric) do record = %User{id: id, name: name} handler = fn Repo, :insert, [r], state -> {{:ok, r}, Map.put(state, {User, r.id}, r)} Repo, :get, [schema, id], state -> {Map.get(state, {schema, id}), state} end result = insert_then_get(record) |> Port.with_stateful_handler(%{}, handler) |> Throw.with_handler() |> Comp.run!() assert result == record end end """ @spec with_stateful_handler(Types.computation(), term(), stateful_handler(), keyword()) :: Types.computation() def with_stateful_handler(comp, initial_state, handler_fn, opts \\ []) when is_function(handler_fn, 4) do install_registry( comp, %{@default_key => {:stateful_dispatch, handler_fn}}, Keyword.put(opts, :initial_handler_state, initial_state) ) end ############################################################################# ## Shared Registry Installation ############################################################################# # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity defp install_registry(comp, registry, opts) do output = Keyword.get(opts, :output) suspend = Keyword.get(opts, :suspend) log_enabled = Keyword.has_key?(opts, :log) and opts[:log] != nil initial_handler_state = Keyword.get(opts, :initial_handler_state) state_key = @state_key comp |> Comp.scoped(fn env -> previous = Env.get_state(env, state_key) # Auto-detect effectful resolvers: plain module atoms where # __port_effectful__?/0 returns truthy are wrapped as {:effectful, module}. resolved_registry = Map.new(registry, fn {key, module} when is_atom(module) and module not in [:direct] -> Code.ensure_loaded(module) if function_exported?(module, :__port_effectful__?, 0) and module.__port_effectful__?() do {key, {:effectful, module}} else {key, module} end entry -> entry end) # Merge with any existing registry so nested handler installations # accumulate registrations rather than shadowing them. merged_registry = case previous do %State{registry: outer_registry} -> Map.merge(outer_registry, resolved_registry) _ -> resolved_registry end # Log is a list (enabled) or nil (disabled). # Inner :log option starts a fresh log; otherwise inherit outer's log. merged_log = if log_enabled do [] else case previous do %State{log: outer_log} -> outer_log _ -> nil end end new_state = %State{ registry: merged_registry, log: merged_log, handler_state: initial_handler_state } env_with_state = Env.put_state(env, state_key, new_state) # If :suspend option provided, compose into transform_suspend {modified_env, previous_transform} = if suspend do old_transform = Env.get_transform_suspend(env_with_state) new_transform = fn susp, e -> {susp1, e1} = old_transform.(susp, e) suspend.(susp1, e1) end {Env.with_transform_suspend(env_with_state, new_transform), old_transform} else {env_with_state, nil} end finally_k = fn value, e -> current_state = Env.get_state(e, state_key) # Restore previous state (or remove key if there was none). # Only propagate accumulated log entries to the outer scope when # this scope inherited the outer log (i.e. no fresh :log option). # When :log started a fresh log, entries stay in this scope only. restored_env = case previous do nil -> %{e | state: Map.delete(e.state, state_key)} %State{log: outer_log} when is_list(outer_log) and not log_enabled -> # Inherited outer log — carry forward accumulated entries. Env.put_state(e, state_key, %{previous | log: current_state.log}) _ -> Env.put_state(e, state_key, previous) end # Restore previous transform_suspend if we modified it restored_env = if previous_transform do Env.with_transform_suspend(restored_env, previous_transform) else restored_env end # Output callback receives (value, state) — log is in state.log # reversed to chronological order. transformed_value = if output do output_state = case current_state do %State{log: log} when is_list(log) -> %{current_state | log: Enum.reverse(log)} _ -> current_state end output.(value, output_state) else value end {transformed_value, restored_env} end {modified_env, finally_k} end) |> Comp.with_handler(@__sig__, &__MODULE__.handle/3) end ############################################################################# ## IHandler Implementation ############################################################################# @impl Skuld.Comp.IHandle def handle({@request_op, mod, name, args}, env, k) do %State{registry: registry, log: log} = Env.get_state!(env, @state_key) # Look up module-specific resolver, fall back to :__default__, then error resolver = case Map.fetch(registry, mod) do {:ok, r} -> {:ok, r} :error -> Map.fetch(registry, @default_key) end case resolver do {:ok, {:test_stub, responses}} -> dispatch_test_stub(responses, nil, mod, name, args, log, env, k) {:ok, {:test_stub, responses, fallback}} -> dispatch_test_stub(responses, fallback, mod, name, args, log, env, k) {:ok, {:fn_dispatch, handler_fn}} -> dispatch_fn(handler_fn, mod, name, args, log, env, k) {:ok, {:stateful_dispatch, handler_fn}} -> dispatch_stateful(handler_fn, mod, name, args, log, env, k) {:ok, runtime_resolver} -> dispatch_runtime(runtime_resolver, mod, name, args, log, env, k) :error -> emit_log_and_return( %ThrowResult{error: {:unknown_port_module, mod}}, mod, name, args, log, env ) end end ############################################################################# ## Dispatch Logic ############################################################################# defp dispatch_runtime(resolver, mod, name, args, log, env, k) do case invoke(resolver, mod, name, args) do {:computation, comp} -> # Effectful resolver: inline the computation, capturing result for logging if log do Comp.call(comp, env, fn result, env2 -> k.(result, append_log(env2, mod, name, args, result)) end) else Comp.call(comp, env, k) end {:value, result} -> emit_log_and_continue(result, mod, name, args, log, env, k) end rescue exception -> emit_log_and_return( %ThrowResult{error: {:port_failed, mod, name, exception}}, mod, name, args, log, env ) end defp dispatch_test_stub(responses, fallback, mod, name, args, log, env, k) do request_key = key(mod, name, args) case Map.fetch(responses, request_key) do {:ok, result} -> emit_log_and_continue(result, mod, name, args, log, env, k) :error when is_function(fallback, 3) -> # Try fallback function dispatch_fn(fallback, mod, name, args, log, env, k) :error -> # No match and no fallback emit_log_and_return( %ThrowResult{error: {:port_not_stubbed, request_key}}, mod, name, args, log, env ) end end defp dispatch_fn(handler_fn, mod, name, args, log, env, k) do result = handler_fn.(mod, name, args) emit_log_and_continue(result, mod, name, args, log, env, k) rescue e in FunctionClauseError -> # No matching clause - report what we received emit_log_and_return( %ThrowResult{error: {:port_not_handled, mod, name, args, e}}, mod, name, args, log, env ) e -> # Other error in handler emit_log_and_return( %ThrowResult{error: {:port_handler_error, mod, name, e}}, mod, name, args, log, env ) end defp dispatch_stateful(handler_fn, mod, name, args, log, env, k) do state_key = @state_key port_state = Env.get_state!(env, state_key) handler_state = port_state.handler_state {result, new_handler_state} = handler_fn.(mod, name, args, handler_state) # Unwrap DoubleDown.Contract.Dispatch.Defer — DD's fakes use Defer to # defer execution outside the NimbleOwnership lock. Skuld doesn't use # NimbleOwnership, so we invoke the deferred function immediately. result = unwrap_defer(result) updated_port_state = %{port_state | handler_state: new_handler_state} env_with_state = Env.put_state(env, state_key, updated_port_state) emit_log_and_continue(result, mod, name, args, log, env_with_state, k) rescue e in FunctionClauseError -> # No matching clause - report what we received emit_log_and_return( %ThrowResult{error: {:port_not_handled, mod, name, args, e}}, mod, name, args, log, env ) e -> # Other error in handler emit_log_and_return( %ThrowResult{error: {:port_handler_error, mod, name, e}}, mod, name, args, log, env ) end ############################################################################# ## Defer Unwrapping ############################################################################# # DoubleDown's stateful fakes (InMemory, OpenInMemory) use %Defer{} to # defer execution outside the NimbleOwnership lock. Skuld doesn't use # NimbleOwnership, so we invoke the deferred function immediately. if Code.ensure_loaded?(DoubleDown.Contract.Dispatch.Defer) do defp unwrap_defer(%DoubleDown.Contract.Dispatch.Defer{fun: fun}), do: fun.() end defp unwrap_defer(result), do: result ############################################################################# ## Logging Helpers ############################################################################# # Prepend a log entry to Port.State.log in the env. No effect dispatch, # just a direct env mutation — fast path for non-DB test performance. defp append_log(env, mod, name, args, result) do state_key = @state_key state = Env.get_state(env, state_key) updated = %{state | log: [{mod, name, args, result} | state.log]} Env.put_state(env, state_key, updated) end # For plain results: emit log (if log enabled) then continue with k. defp emit_log_and_continue(result, _mod, _name, _args, nil, env, k) do k.(result, env) end defp emit_log_and_continue(result, mod, name, args, _log, env, k) do k.(result, append_log(env, mod, name, args, result)) end # For error results (ThrowResult): emit log (if log enabled) then return the error. defp emit_log_and_return(throw_result, _mod, _name, _args, nil, env) do {throw_result, env} end defp emit_log_and_return(throw_result, mod, name, args, _log, env) do {throw_result, append_log(env, mod, name, args, throw_result)} end defp invoke(:direct, mod, name, args) do {:value, apply(mod, name, args)} end defp invoke({:effectful, module}, _mod, name, args) when is_atom(module) do {:computation, apply(module, name, args)} end defp invoke(fun, mod, name, args) when is_function(fun, 3) do {:value, fun.(mod, name, args)} end defp invoke({module, function}, mod, name, args) do {:value, apply(module, function, [mod, name, args])} end defp invoke(module, _mod, name, args) when is_atom(module) do {:value, apply(module, name, args)} end end