defmodule TypedStruct.Plugin do
@moduledoc """
This module defines the plugin interface for TypedStruct.
## Rationale
Sometimes you may want to define helpers on your structs, for all their fields
or for the struct as a whole. This plugin interface lets you integrate your
own needs with TypedStruct.
## Plugin definition
A TypedStruct plugin is a module that implements `TypedStruct.Plugin`. Three
callbacks are available to you for injecting code at different steps:
* `c:init/1` lets you inject code where the `TypedStruct.plugin/2` macro is
called,
* `c:field/4` lets you inject code on each field definition,
* `c:after_definition/1` lets you insert code after the struct and its type
have been defined.
`use`-ing this module will inject default implementations of all
three, so you only have to implement those you care about.
### Example
As an example, let’s define a plugin that allows users to add an optional
description to their structs and fields. This plugin also takes an `upcase`
option. If set to `true`, all the descriptions are then upcased. It would be
used this way:
defmodule MyStruct do
use TypedStruct
typedstruct do
# We import the plugin with the upcase option set to `true`.
plugin DescribedStruct, upcase: true
# We can now set a description for the struct.
description "My struct"
# We can also set a description on a field.
field :a_field, String.t(), description: "A field"
field :second_field, boolean()
end
end
Once compiled, we would optain:
iex> MyStruct.struct_description()
"MY STRUCT"
iex> MyStruct.field_description(:a_field)
"A FIELD"
Follows the plugin definition:
defmodule DescribedStruct do
use TypedStruct.Plugin
# The init macro lets you inject code where the plugin macro is called.
# You can think a bit of it like a `use` but for the scope of the
# typedstruct block.
@impl true
@spec init(keyword()) :: Macro.t()
defmacro init(opts) do
quote do
# Let’s import our custom `description` macro defined below so our
# users can use it when defining their structs.
import TypedStructDemoPlugin, only: [description: 1]
# Let’s also store the upcase option in an attribute so we can
# access it from the code injected by our `description/1` macro.
@upcase unquote(opts)[:upcase]
end
end
# This is a public macro our users can call in their typedstruct blocks.
@spec description(String.t()) :: Macro.t()
defmacro description(description) do
quote do
# Here we simply evaluate the result of __description__/2. We need
# this indirection to be able to use @upcase after is has been
# evaluated, but still in the code generation process. This way, we
# can upcase the strings *at build time* if needed. It’s just a tiny
# refinement :-)
Module.eval_quoted(
__MODULE__,
TypedStructDemoPlugin.__description__(__MODULE__, unquote(description))
)
end
end
@spec __description__(module(), String.t()) :: Macro.t()
def __description__(module, description) do
# Maybe upcase the description at build time.
description =
module
|> Module.get_attribute(:upcase)
|> maybe_upcase(description)
quote do
# Let’s just generate a constant function that returns the
# description.
def struct_description, do: unquote(description)
end
end
# The field callback is called for each field defined in the typedstruct
# block. You get exactly what the user has passed to the field macro,
# plus options from every plugin init. The `env` variable contains the
# environment as it stood at the moment of the corresponding
# `TypedStruct.field/3` call.
@impl true
@spec field(atom(), any(), keyword(), Macro.Env.t()) :: Macro.t()
def field(name, _type, opts, _env) do
# Same as for the struct description, we want to upcase at build time
# if necessary. As we do not have access to the module here, we cannot
# access @upcase. This is not an issue since the option is
# automatically added to `opts`, in addition to the options passed to
# the field macro.
description = maybe_upcase(opts[:upcase], opts[:description] || "")
quote do
# We define a clause matching the field name returning its optional
# description.
def field_description(unquote(name)), do: unquote(description)
end
end
defp maybe_upcase(true, description), do: String.upcase(description)
defp maybe_upcase(_, description), do: description
# The after_definition callback is called after the struct and its type
# have been defined, at the end of the `typedstruct` block.
@impl true
@spec after_definition(opts :: keyword()) :: Macro.t()
def after_definition(_opts) do
quote do
# Here we just clean the @upcase attribute so that it does not
# pollute our user’s modules.
Module.delete_attribute(__MODULE__, :upcase)
end
end
end
"""
@doc """
Injects code where `TypedStruct.plugin/2` is called.
"""
@macrocallback init(opts :: keyword()) :: Macro.t()
@doc deprecated: "Use TypedStruct.Plugin.field/4 instead"
@callback field(name :: atom(), type :: any(), opts :: keyword()) ::
Macro.t()
@doc """
Injects code after each field definition.
`name` and `type` are the exact values passed to the `TypedStruct.field/3`
macro in the `typedstruct` block. `opts` is the concatenation of the options
passed to the `field` macro and those from the plugin init. `env` is the
environment at the time of each field definition.
"""
@callback field(
name :: atom(),
type :: any(),
opts :: keyword(),
env :: Macro.Env.t()
) ::
Macro.t()
@doc """
Injects code after the struct and its type have been defined.
"""
@callback after_definition(opts :: keyword()) :: Macro.t()
@optional_callbacks [field: 3, field: 4]
@doc false
defmacro __using__(_opts) do
quote do
@behaviour TypedStruct.Plugin
@before_compile {unquote(__MODULE__), :maybe_define_field_4}
@doc false
defmacro init(_opts), do: nil
@doc false
def after_definition(_opts), do: nil
defoverridable init: 1, after_definition: 1
end
end
@doc false
defmacro maybe_define_field_4(env) do
case {Module.defines?(env.module, {:field, 3}, :def),
Module.defines?(env.module, {:field, 4}, :def)} do
{false, false} ->
# If none is present, let’s define a default implementation for field/4.
quote do
@doc false
def field(_name, _type, _opts, _env), do: nil
end
{false, true} ->
# If field/4 is present, allright.
nil
{true, false} ->
# If field/3 is present, let’s define field/4 from it for compatibility.
IO.warn([
Atom.to_string(env.module),
" defines field/3, which is deprecated. Please use field/4 instead"
])
quote do
@doc false
def field(name, type, opts, _env) do
field(name, type, opts)
end
end
{true, true} ->
# If both are present, this is an issue.
IO.warn([
Atom.to_string(env.module),
" defines both field/3 and field/4 callbacks.",
" Only field/4 will be invoked"
])
end
end
end