lib/typed_struct.ex

defmodule TypedStruct do
  @moduledoc """
  TypedStruct is a library for defining structs with a type without writing
  boilerplate code.

  ## Rationale

  To define a struct in Elixir, you probably want to define three things:

    * the struct itself, with default values,
    * the list of enforced keys,
    * its associated type.

  It ends up in something like this:

      defmodule Person do
        @moduledoc \"\"\"
        A struct representing a person.
        \"\"\"

        @enforce_keys [:name]
        defstruct name: nil,
                  age: nil,
                  happy?: true,
                  phone: nil

        @typedoc "A person"
        @type t() :: %__MODULE__{
                name: String.t(),
                age: non_neg_integer() | nil,
                happy?: boolean(),
                phone: String.t() | nil
              }
      end

  In the example above you can notice several points:

    * the keys are present in both the `defstruct` and type definition,
    * enforced keys must also be written in `@enforce_keys`,
    * if a key has no default value and is not enforced, its type should be
      nullable.

  If you want to add a field in the struct, you must therefore:

    * add the key with its default value in the `defstruct` list,
    * add the key with its type in the type definition.

  If the field is not optional, you should even add it to `@enforce_keys`. This
  is way too much work for lazy people like me, and moreover it can be
  error-prone.

  It would be way better if we could write something like this:

      defmodule Person do
        @moduledoc \"\"\"
        A struct representing a person.
        \"\"\"

        use TypedStruct

        typedstruct do
          @typedoc "A person"

          field :name, String.t(), enforce: true
          field :age, non_neg_integer()
          field :happy?, boolean(), default: true
          field :phone, String.t()
        end
      end

  Thanks to TypedStruct, this is now possible :)

  ## Usage

  ### Setup

  To use TypedStruct in your project, add this to your Mix dependencies:

      {:typed_struct, "~> #{Mix.Project.config()[:version]}"}

  If you do not plan to compile modules using TypedStruct at runtime, you can
  add `runtime: false` to the dependency tuple as TypedStruct is only used at
  build time.

  If you want to avoid `mix format` putting parentheses on field definitions,
  you can add to your `.formatter.exs`:

      [
        ...,
        import_deps: [:typed_struct]
      ]

  ### General usage

  To define a typed struct, use `TypedStruct`, then define your struct within a
  `typedstruct` block:

      defmodule MyStruct do
        # Use TypedStruct to import the typedstruct macro.
        use TypedStruct

        # Define your struct.
        typedstruct do
          # Define each field with the field macro.
          field :a_string, String.t()

          # You can set a default value.
          field :string_with_default, String.t(), default: "default"

          # You can enforce a field.
          field :enforced_field, integer(), enforce: true
        end
      end

  Each field is defined through the `field/2` macro.

  ### Options

  If you want to enforce all the keys by default, you can do:

      defmodule MyStruct do
        use TypedStruct

        # Enforce keys by default.
        typedstruct enforce: true do
          # This key is enforced.
          field :enforced_by_default, term()

          # You can override the default behaviour.
          field :not_enforced, term(), enforce: false

          # A key with a default value is not enforced.
          field :not_enforced_either, integer(), default: 1
        end
      end

  You can also generate an opaque type for the struct:

      defmodule MyOpaqueStruct do
        use TypedStruct

        # Generate an opaque type for the struct.
        typedstruct opaque: true do
          field :name, String.t()
        end
      end

  If you often define submodules containing only a struct, you can avoid
  boilerplate code:

      defmodule MyModule do
        use TypedStruct

        # You now have %MyModule.Struct{}.
        typedstruct module: Struct do
          field :field, term()
        end
      end

  ### Documentation

  To add a `@typedoc` to the struct type, just add the attribute in the
  `typedstruct` block:

      typedstruct do
        @typedoc "A typed struct"

        field :a_string, String.t()
        field :an_int, integer()
      end

  You can also document submodules this way:

      typedstruct module: MyStruct do
        @moduledoc "A submodule with a typed struct."
        @typedoc "A typed struct in a submodule"

        field :a_string, String.t()
        field :an_int, integer()
      end

  ### Plugins

  It is possible to extend the scope of TypedStruct by using its plugin
  interface, as described in `TypedStruct.Plugin`. For instance, to
  automatically generate lenses with the [Lens](https://github.com/obrok/lens)
  library, you can use
  [`TypedStructLens`](https://github.com/ejpcmac/typed_struct_lens) and do:

      defmodule MyStruct do
        use TypedStruct

        typedstruct do
          plugin TypedStructLens

          field :a_field, String.t()
          field :other_field, atom()
        end

        @spec change(t()) :: t()
        def change(data) do
          # a_field/0 is generated by TypedStructLens.
          lens = a_field()
          put_in(data, [lens], "Changed")
        end
      end

  ## What do I get?

  When defining an empty `typedstruct` block:

      defmodule Example do
        use TypedStruct

        typedstruct do
        end
      end

  you get an empty struct with its module type `t()`:

      defmodule Example do
        @enforce_keys []
        defstruct []

        @type t() :: %__MODULE__{}
      end

  Each `field` call adds information to the struct, `@enforce_keys` and the type
  `t()`.

  A field with no options adds the name to the `defstruct` list, with `nil` as
  default. The type itself is made nullable:

      defmodule Example do
        use TypedStruct

        typedstruct do
          field :name, String.t()
        end
      end

  becomes:

      defmodule Example do
        @enforce_keys []
        defstruct name: nil

        @type t() :: %__MODULE__{
                name: String.t() | nil
              }
      end

  The `default` option adds the default value to the `defstruct`:

      field :name, String.t(), default: "John Smith"

      # Becomes
      defstruct name: "John Smith"

  When set to `true`, the `enforce` option enforces the key by adding it to the
  `@enforce_keys` attribute.

      field :name, String.t(), enforce: true

      # Becomes
      @enforce_keys [:name]
      defstruct name: nil

  In both cases, the type has no reason to be nullable anymore by default. In
  one case the field is filled with its default value and not `nil`, and in the
  other case it is enforced. Both options would generate the following type:

      @type t() :: %__MODULE__{
            name: String.t() # Not nullable
          }

  Passing `opaque: true` replaces `@type` with `@opaque` in the struct type
  specification:

      typedstruct opaque: true do
        field :name, String.t()
      end

  generates the following type:

      @opaque t() :: %__MODULE__{
                name: String.t()
              }

  When passing `module: ModuleName`, the whole `typedstruct` block is wrapped in
  a module definition. This way, the following definition:

      defmodule MyModule do
        use TypedStruct

        typedstruct module: Struct do
          field :field, term()
        end
      end

  becomes:

      defmodule MyModule do
        defmodule Struct do
          @enforce_keys []
          defstruct field: nil

          @type t() :: %__MODULE__{
                  field: term() | nil
                }
        end
      end
  """

  @doc false
  defmacro __using__(_) do
    quote do
      import TypedStruct, only: [typedstruct: 1, typedstruct: 2]
    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.
    * `opaque` - if set to true, creates an opaque type for the struct.
    * `module` - if set, creates the struct in a submodule named `module`.

  ## 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
    if is_nil(opts[:module]) do
      quote do
        Module.eval_quoted(
          __ENV__,
          TypedStruct.__typedstruct__(
            unquote(Macro.escape(block)),
            unquote(opts)
          )
        )
      end
    else
      quote do
        defmodule unquote(opts[:module]) do
          Module.eval_quoted(
            __ENV__,
            TypedStruct.__typedstruct__(
              unquote(Macro.escape(block)),
              unquote(opts)
            )
          )
        end
      end
    end
  end

  @doc false
  def __typedstruct__(block, opts) do
    quote do
      Module.register_attribute(__MODULE__, :ts_plugins, accumulate: true)
      Module.register_attribute(__MODULE__, :ts_fields, accumulate: true)
      Module.register_attribute(__MODULE__, :ts_types, accumulate: true)
      Module.register_attribute(__MODULE__, :ts_enforce_keys, accumulate: true)
      Module.put_attribute(__MODULE__, :ts_enforce?, unquote(!!opts[:enforce]))

      # Create a scope to avoid leaks.
      (fn ->
         import TypedStruct
         unquote(block)
       end).()

      @enforce_keys @ts_enforce_keys
      defstruct @ts_fields

      TypedStruct.__type__(@ts_types, unquote(opts))

      Enum.each(@ts_plugins, fn {plugin, plugin_opts} ->
        if {:after_definition, 1} in plugin.__info__(:functions) do
          Module.eval_quoted(__MODULE__, plugin.after_definition(plugin_opts))
        end
      end)

      Module.delete_attribute(__MODULE__, :ts_enforce?)
      Module.delete_attribute(__MODULE__, :ts_enforce_keys)
      Module.delete_attribute(__MODULE__, :ts_types)
      Module.delete_attribute(__MODULE__, :ts_plugins)
    end
  end

  @doc false
  defmacro __type__(types, opts) do
    if Keyword.get(opts, :opaque, false) do
      quote bind_quoted: [types: types] do
        @opaque t() :: %__MODULE__{unquote_splicing(types)}
      end
    else
      quote bind_quoted: [types: types] do
        @type t() :: %__MODULE__{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 do
      TypedStruct.__field__(
        __MODULE__,
        unquote(name),
        unquote(Macro.escape(type)),
        unquote(opts)
      )

      Enum.each(@ts_plugins, fn {plugin, plugin_opts} ->
        if {:field, 3} in plugin.__info__(:functions) do
          Module.eval_quoted(
            __MODULE__,
            plugin.field(
              unquote(name),
              unquote(Macro.escape(type)),
              unquote(opts) ++ plugin_opts
            )
          )
        end
      end)
    end
  end

  @doc false
  def __field__(mod, name, type, opts) 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_types, {name, type_for(type, nullable?)})
    if enforce?, do: Module.put_attribute(mod, :ts_enforce_keys, name)
  end

  def __field__(_mod, name, _type, _opts) 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)
end