lib/typed_struct/plugin.ex

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