lib/typed_struct.ex

defmodule TypedStruct do
  @external_resource "README.md"
  @moduledoc "README.md"
             |> File.read!()
             |> String.split("<!-- @moduledoc -->")
             |> Enum.fetch!(1)

  @common_accumulating_attrs [
    :ts_plugins,
    :ts_plugin_fields,
    :ts_fields,
    :ts_types,
    :ts_enforce_keys
  ]

  @struct_accumulating_attrs @common_accumulating_attrs ++ [:ts_enforce_keys]
  @record_accumulating_attrs @common_accumulating_attrs

  @attrs_to_delete [:ts_enforce? | @struct_accumulating_attrs]

  @doc false
  defmacro __using__(_) do
    quote do
      import TypedStruct,
        only: [typedstruct: 1, typedstruct: 2, typedrecord: 2, typedrecord: 3]
    end
  end

  @doc """
  Defines a typed struct.

  Inside a `typedstruct` block, each field is defined through the `field/2`
  macro.

  ## Options

    * `enforce` - if set to true, sets `enforce: true` to all fields by default.
      This can be overridden by setting `enforce: false` or a default value on
      individual fields.
    * `visibility` - one of the values: `:public` (default), `:private`, `:opaque`.
    * `module` - if set, creates the struct in a submodule named `module`.
    * `opaque` - (deprecated) if set to true, creates an opaque type for the struct.

  ## Examples

      defmodule MyStruct do
        use TypedStruct

        typedstruct do
          field :field_one, String.t()
          field :field_two, integer(), enforce: true
          field :field_three, boolean(), enforce: true
          field :field_four, atom(), default: :hey
        end
      end

  The following is an equivalent using the *enforce by default* behaviour:

      defmodule MyStruct do
        use TypedStruct

        typedstruct enforce: true do
          field :field_one, String.t(), enforce: false
          field :field_two, integer()
          field :field_three, boolean()
          field :field_four, atom(), default: :hey
        end
      end

  You can create the struct in a submodule instead:

      defmodule MyModule do
        use TypedStruct

        typedstruct module: Struct do
          field :field_one, String.t()
          field :field_two, integer(), enforce: true
          field :field_three, boolean(), enforce: true
          field :field_four, atom(), default: :hey
        end
      end
  """
  defmacro typedstruct(opts \\ [], do: block) do
    ast = TypedStruct.__typedstruct__(block, opts)

    case opts[:module] do
      nil ->
        quote do
          # Create a lexical scope.
          (fn -> unquote(ast) end).()
        end

      module ->
        quote do
          defmodule unquote(module) do
            unquote(ast)
          end
        end
    end
  end

  @doc """
  Defines a typed record.

  Inside a `typedrecord` block, each field is defined through the `field/2`
  macro.

  ## Options

    * `tag` - if set, used as the `tag` parameter passed to `Record.defrecord/3`.
    * `visibility` - one of the values: `:public` (default), `:private`, `:opaque`.
    * `module` - if set, creates the struct in a submodule named `module`.
  """
  defmacro typedrecord(name, opts \\ [], do: block) when is_list(opts) do
    ast = TypedStruct.__typedrecord__(name, block, opts)

    case opts[:module] do
      nil ->
        quote do
          # Create a lexical scope.
          (fn -> unquote(ast) end).()
        end

      module ->
        quote do
          defmodule unquote(module) do
            unquote(ast)
          end
        end
    end
  end

  @doc false
  def __typedstruct__(block, opts) do
    quote do
      Enum.each(unquote(@struct_accumulating_attrs), fn attr ->
        Module.register_attribute(__MODULE__, attr, accumulate: true)
      end)

      Module.put_attribute(__MODULE__, :ts_enforce?, unquote(!!opts[:enforce]))
      @before_compile {unquote(__MODULE__), :__plugin_callbacks__}

      import TypedStruct
      unquote(block)

      @enforce_keys @ts_enforce_keys
      defstruct @ts_fields

      TypedStruct.__struct_type__(@ts_types, unquote(opts))
    end
  end

  @doc false
  def __typedrecord__(name, block, opts) do
    tag = Keyword.get(opts, :tag, name)

    quote do
      Enum.each(unquote(@record_accumulating_attrs), fn attr ->
        Module.register_attribute(__MODULE__, attr, accumulate: true)
      end)

      import TypedStruct
      require Record

      unquote(block)
      Record.defrecord(unquote(name), unquote(tag), Enum.reverse(@ts_fields))

      TypedStruct.__record_type__(
        @ts_types,
        unquote(opts),
        unquote(name),
        unquote(tag)
      )
    end
  end

  @doc false
  def __visibility__(opts) do
    # For backward compatibility support `opaque: true` (default to :public)
    Keyword.get(opts, :visibility) ||
      ((Keyword.get(opts, :opaque) == true && :opaque) || :public)
  end

  @doc false
  defmacro __struct_type__(types, opts) do
    case TypedStruct.__visibility__(opts) do
      :public ->
        quote bind_quoted: [types: types] do
          @type t() :: %__MODULE__{unquote_splicing(types)}
        end

      :opaque ->
        quote bind_quoted: [types: types] do
          @opaque t() :: %__MODULE__{unquote_splicing(types)}
        end

      :private ->
        quote bind_quoted: [types: types] do
          @typep t() :: %__MODULE__{unquote_splicing(types)}
        end
    end
  end

  @doc false
  defmacro __record_type__(types, opts, name, tag) do
    types =
      quote do
        :lists.reverse(for {_, v} <- unquote(types), do: v)
      end

    case TypedStruct.__visibility__(opts) do
      :public ->
        quote bind_quoted: [name: name, tag: tag, types: types] do
          @type unquote(name)() :: {unquote(tag), unquote_splicing(types)}
        end

      :opaque ->
        quote bind_quoted: [name: name, tag: tag, types: types] do
          @opaque unquote(name)() :: {unquote(tag), unquote_splicing(types)}
        end

      :private ->
        quote bind_quoted: [name: name, tag: tag, types: types] do
          @typep unquote(name)() :: {unquote(tag), unquote_splicing(types)}
        end
    end
  end

  @doc """
  Registers a plugin for the currently defined struct.

  ## Example

      typedstruct do
        plugin MyPlugin

        field :a_field, String.t()
      end

  For more information on how to define your own plugins, please see
  `TypedStruct.Plugin`. To use a third-party plugin, please refer directly to
  its documentation.
  """
  defmacro plugin(plugin, opts \\ []) do
    quote do
      Module.put_attribute(
        __MODULE__,
        :ts_plugins,
        {unquote(plugin), unquote(opts)}
      )

      require unquote(plugin)
      unquote(plugin).init(unquote(opts))
    end
  end

  @doc """
  Defines a field in a typed struct.

  ## Example

      # A field named :example of type String.t()
      field :example, String.t()

  ## Options

    * `default` - sets the default value for the field
    * `enforce` - if set to true, enforces the field and makes its type
      non-nullable
  """
  defmacro field(name, type, opts \\ []) do
    quote bind_quoted: [name: name, type: Macro.escape(type), opts: opts] do
      TypedStruct.__field__(name, type, opts, __ENV__)
    end
  end

  @doc false
  def __field__(name, type, opts, %Macro.Env{module: mod} = env)
      when is_atom(name) do
    if mod |> Module.get_attribute(:ts_fields) |> Keyword.has_key?(name) do
      raise ArgumentError, "the field #{inspect(name)} is already set"
    end

    has_default? = Keyword.has_key?(opts, :default)
    enforce_by_default? = Module.get_attribute(mod, :ts_enforce?)

    enforce? =
      if is_nil(opts[:enforce]),
        do: enforce_by_default? && !has_default?,
        else: !!opts[:enforce]

    nullable? = !has_default? && !enforce?

    Module.put_attribute(mod, :ts_fields, {name, opts[:default]})
    Module.put_attribute(mod, :ts_plugin_fields, {name, type, opts, env})
    Module.put_attribute(mod, :ts_types, {name, type_for(type, nullable?)})
    if enforce?, do: Module.put_attribute(mod, :ts_enforce_keys, name)
  end

  def __field__(name, _type, _opts, _env) do
    raise ArgumentError, "a field name must be an atom, got #{inspect(name)}"
  end

  # Makes the type nullable if the key is not enforced.
  defp type_for(type, false), do: type
  defp type_for(type, _), do: quote(do: unquote(type) | nil)

  @doc false
  defmacro __plugin_callbacks__(%Macro.Env{module: module}) do
    plugins = Module.get_attribute(module, :ts_plugins)
    fields = Module.get_attribute(module, :ts_plugin_fields) |> Enum.reverse()

    Enum.each(unquote(@attrs_to_delete), &Module.delete_attribute(module, &1))

    fields_block =
      for {plugin, plugin_opts} <- plugins,
          {name, type, field_opts, env} <- fields do
        plugin.field(name, type, field_opts ++ plugin_opts, env)
      end

    after_definition_block =
      for {plugin, plugin_opts} <- plugins do
        plugin.after_definition(plugin_opts)
      end

    quote do
      unquote_splicing(fields_block)
      unquote_splicing(after_definition_block)
    end
  end
end