This should probably be a blog post, but just to conclude this topic here is the full solution to my use case:
defmodule Builder do
defmacro define_builder(name, opts \\ []) do
function_map = Keyword.get(opts, :function_map)
initialise_fn = Macro.escape(Keyword.get(opts, :initialise))
caller_module = __CALLER__.module
# define the block which gets called in the builder function
block =
quote do
caller_module = unquote(caller_module)
fn_map = unquote(function_map)
block_args = unquote({:passed_in_block, [], caller_module})
block_args =
case block_args do
[do: {:__block__, [], block_args}] -> block_args
_ -> block_args
end
context = unquote({:passed_in_context, [], caller_module})
context =
case unquote(initialise_fn) do
nil -> context
_ -> {{:., [], [unquote(initialise_fn)]}, [], [context]}
end
# go through each statement of the block, mapping function calls,
# and ensuring that the context is passed to
Enum.reduce(block_args, context, fn fn_arg, acc ->
Builder.remap_functions(caller_module, fn_map, acc, fn_arg)
end)
end
# defines a new function called 'name'
{
{:., [], [{:__aliases__, [alias: false], [:Kernel]}, :defmacro]},
[],
[
{name, [],
[{:passed_in_context, [], caller_module}, {:passed_in_block, [], caller_module}]},
[do: block]
]
}
end
# maps known function calls in the incoming quoted expression
def remap_functions(caller_module, function_map, context, {fn_call, fn_ctx, fn_args}) do
mapped_fn = Map.get(function_map, fn_call)
# remove nil fn_args - we just send the context in this case
fn_args = clean_fn_args(fn_args, context)
if mapped_fn do
{{:., fn_ctx, [{:__aliases__, fn_ctx, [caller_module]}, mapped_fn]}, fn_ctx, fn_args}
else
{fn_call, fn_ctx, fn_args}
end
end
defp clean_fn_args(args, context) do
case args do
args when is_nil(args) -> [context]
args -> [context] ++ args
end
end
end
defmodule BuildFunctions do
import Builder
define_builder(:build_house,
initialise: fn ctx -> {ctx, %{:brick_count => 0, :door_count => 0}} end,
function_map: %{lay_bricks: :house_lay_bricks, build_door: :house_build_door}
)
def house_lay_bricks({context, inventory}, count),
do: {inc_ops(context), inc_bricks(inventory, count)}
def house_build_door({context, inventory}, count),
do: {inc_ops(context), inc_door(inventory, count)}
defp inc_bricks(inventory, count), do: add_inventory(inventory, :brick_count, count)
defp inc_door(inventory, count), do: add_inventory(inventory, :door_count, count)
defp inc_ops(ops), do: %{ops | op_count: Map.get(ops, :op_count, 0) + 1}
defp add_inventory(inv, inv_type, count),
do: Map.put(inv, inv_type, Map.get(inv, inv_type) + count)
define_builder(:build_garden,
initialise: fn ctx -> {ctx, %{:flowers => %MapSet{}, :trees => %MapSet{}}} end,
function_map: %{tree: :garden_plant_tree, flower: :garden_plant_flower}
)
def garden_plant_tree({context, inventory}, type),
do: {inc_ops(context), add_type(inventory, :trees, type)}
def garden_plant_flower({context, inventory}, type),
do: {inc_ops(context), add_type(inventory, :flowers, type)}
defp add_type(inv, inv_type, type),
do: Map.put(inv, inv_type, Map.get(inv, inv_type) |> MapSet.put(type))
end
which is demonstrated by:
defmodule Runner do
require BuildFunctions
result =
BuildFunctions.build_house %{op_count: 0} do
lay_bricks(3)
lay_bricks(2)
build_door(1)
end
IO.puts(result == {%{op_count: 3}, %{brick_count: 5, door_count: 1}})
result =
BuildFunctions.build_garden %{op_count: 0} do
tree("oak")
flower("roses")
tree("willow")
flower("azalea")
end
IO.puts(
result ==
{%{op_count: 4},
%{flowers: MapSet.new(["azalea", "roses"]), trees: MapSet.new(["oak", "willow"])}}
)
end
What the builder function does is to rewrite the incoming block so that context is passed into and back from each statement (effectively piping) - thus avoiding the need to have GenServer provided state.