Current section
Files
Jump to
Current section
Files
lib/protoss.ex
defmodule Protoss do
@moduledoc """
Protoss is a utility library that allows you to define protocols implementations
which automatically delegate back to the module that is the caller of the protocol.
### Automatic delegation of protocol functions
The primary purpose of `Protoss` is to automatically delegate protocol functions.
As an example, here is standard elixir code for implementing a protocol:
```elixir
defprotocol Proto do
def unwrap(term)
end
defmodule MyStruct do
defstruct [:value]
defimpl Proto do
def unwrap(term), do: term.value
end
end
```
Protoss makes this easier to understand by hiding the `defimpl` boilerplate module.
This is a natural fit for Elixir's convention of having module functions whose
first parameter is the module's datatype.
```elixir
use Protoss
defprotocol Proto do
def unwrap(term)
end
defmodule MyStruct do
use Proto
defstruct [:value]
def unwrap(term), do: term.value
end
```
### Directly delegated functions.
Protoss also allows you to define functions that get directly delegated to the
implementation module, that are then logically associated with the protocol, and
whose existence is checked by the compiler using the protocol behaviour.
A common use case would be a `from_json` function which reifies json data into a
struct.
#### Example:
```elixir
use Protoss
defprotocol Proto do
def unwrap(term)
defdelegate from_json(module, json)
end
defmodule MyStruct do
use Proto
defstruct [:value]
def unwrap(term), do: term.value
# NOTE the arity of the function
def from_json(%{"value" => v}), do: %__MODULE__{value: v}
end
```
### Protocol body functions
Finally, protoss allows you to write arbitrary functions in the protocol body
that aren't necessarily part of the protocol itself, using the `after` keyword.
This could be useful to reduce code duplication if you have a common processing
step that must occur in conjunction with a pseudo-private protocol function,
or even if you simply have a thematically relevant function that you would like
to incorporate into the same namespace.
```elixir
use Protoss
defprotocol Proto do
def _unwrap_impl(term)
after
def unwrap(struct) do
struct
|> _unwrap_impl()
|> SomeOtherModule.process()
end
end
defmodule MyStruct do
use Proto
defstruct [:value]
def _unwrap_impl(term), do: term.value
end
```
"""
defmacro __using__(_) do
quote do
import Kernel, except: [defprotocol: 2]
import Protoss, only: [defprotocol: 2, defdelegate: 1]
end
end
defmacro defprotocol(proto_module, [{:do, body} | rest]) do
delegations = Macro.escape(scan_delegation(body))
after_content = Keyword.get(rest, :after, [])
after_callbacks = scan_callbacks(after_content)
after_code = Macro.escape(after_content)
extended =
quote do
@before_compile Protoss
Module.put_attribute(__MODULE__, :__protoss_delegations__, unquote(delegations))
Module.put_attribute(__MODULE__, :__protoss_after_content__, unquote(after_code))
Module.put_attribute(__MODULE__, :__protoss_after_callbacks__, unquote(after_callbacks))
unquote(body)
end
quote do
Kernel.defprotocol(unquote(proto_module), do: unquote(extended))
end
end
defmacro defdelegate(_), do: []
defmacro __before_compile__(%{module: module}) do
# this before_compile is called in the protocol body, so we should
# be generating at a minimum, the `using` content.
delegations =
module
|> Module.get_attribute(:__protoss_delegations__)
|> Enum.group_by(&elem(elem(&1, 0), 0), &{elem(elem(&1, 0), 1), elem(&1, 1)})
|> Map.put_new(:module, [])
protocol_delegations = Macro.escape(delegations.protocol)
module_delegations =
Enum.map(delegations.module, fn {name, [module | rest] = params} ->
quote do
def unquote(name)(unquote_splicing(params)) do
unquote(module).unquote(name)(unquote_splicing(rest))
end
end
end)
specs = Module.get_attribute(module, :spec)
module_delegations_callbacks =
Enum.map(delegations.module, fn {name, delegation_params} ->
spec =
Enum.flat_map(specs, fn
{:spec, {:"::", _, [{^name, _, spec_params} | _]} = spec_ast, _}
when length(spec_params) == length(delegation_params) ->
[spec_ast]
_ ->
[]
end)
case spec do
[] ->
params_types =
List.duplicate(
quote do
term()
end,
length(delegation_params) - 1
)
quote do
@callback unquote(name)(unquote_splicing(params_types)) :: term()
end
specs ->
Enum.map(specs, fn {:"::", meta1, [{^name, meta2, spec_params} | rest]} ->
new_callback = {:"::", meta1, [{name, meta2, tl(spec_params)} | rest]}
quote do
@callback unquote(new_callback)
end
end)
end
end)
after_content = Module.get_attribute(module, :__protoss_after_content__)
after_callbacks = Module.get_attribute(module, :__protoss_after_callbacks__)
quote do
import Protocol, only: []
import Kernel
unquote_splicing(module_delegations_callbacks)
unquote_splicing(module_delegations)
unquote(after_content)
defmacro __using__(_) do
protocol = __MODULE__
caller = __CALLER__.module
delegates =
Enum.map(unquote(protocol_delegations), fn
{fun, params} ->
quote do
defdelegate unquote(fun)(unquote_splicing(params)), to: unquote(caller)
end
end)
suppress_delegations =
Enum.map(unquote(Macro.escape(delegations.module)), fn
{fun, params} ->
Protoss._empty_function(fun, length(params) - 1)
end)
suppress_callbacks =
Enum.map(unquote(after_callbacks), fn
{fun, param_count} ->
Protoss._empty_function(fun, param_count)
end)
quote do
@behaviour unquote(protocol)
defimpl unquote(protocol) do
unquote(delegates)
# here we need to suppress callbacks that were declared as a part of basic protocols
unquote(suppress_delegations)
unquote(suppress_callbacks)
end
end
end
end
end
defp scan_delegation(ast) do
{_, defs} =
Macro.prewalk(ast, [], fn
{:def, _, [{name, _, args}]} = ast, so_far ->
{ast, [{{:protocol, name}, args} | so_far]}
{:defdelegate, _, [{name, _, args}]} = ast, so_far ->
{ast, [{{:module, name}, args} | so_far]}
ast, so_far ->
{ast, so_far}
end)
defs
end
defp scan_callbacks(ast) do
{_, cbs} =
Macro.prewalk(ast, [], fn
{:@, _, [{:callback, _, [{:"::", _, [{name, _, count} | _]}]}]}, so_far ->
{ast, [{name, length(count)} | so_far]}
ast, so_far ->
{ast, so_far}
end)
cbs
end
@doc false
def _empty_function(name, args_count) do
empty_args =
List.duplicate(
quote do
_
end,
args_count
)
quote do
def unquote(name)(unquote_splicing(empty_args)) do
raise "unreachable"
end
end
end
end