defmodule TypedStruct do @external_resource "README.md" @moduledoc "README.md" |> File.read!() |> String.split("") |> 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`. * `new` - if set to `true`, it will generate `__MODULE__.new(attrs \\ %{}`. * `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)) if unquote(!!opts[:new]) do # @doc "Initialize the struct with given attributes" # @spec new(map()) :: unquote(__MODULE__).t def new(attrs \\ %{}), do: struct(__MODULE__, attrs) end 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