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
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
```
### Inheriting `using`
You may inherit the `__using__` macro in the `after` block of the protocol, simply
by including any `defmacro __using__(...)` header in the `after block.
All code generated by this `__using__` macro will be executed prior to defining
the implementation submodule.
"""
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__)
has_using? = has_using?(after_content)
using_override = if has_using? do
quote do
defoverridable __using__: 1
end
end
using_code = if has_using? do
quote do
super(opts)
end
end
quote do
import Protocol, only: []
import Kernel
unquote_splicing(module_delegations_callbacks)
unquote_splicing(module_delegations)
unquote(after_content)
unquote(using_override)
defmacro __using__(opts) do
protocol = __MODULE__
caller = __CALLER__.module
using_code = unquote(using_code)
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
unquote(using_code)
@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} | _]}]}]} = ast, 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
defp has_using?(ast) do
ast
|> Macro.prewalk(false, fn
{:defmacro, _, [{:__using__, _, [_]}, _]}, false -> {[], true}
other, false -> {other, false}
_, true -> {[], true}
end)
|> elem(1)
end
end