Current section
Files
Jump to
Current section
Files
lib/type_struct.ex
defmodule TypeStruct do
@moduledoc ~S"""
Defines structs and their types together.
TypeStruct extends `defstruct` so fields can be declared as `name: type`,
with optional defaults written as `type \\ default`:
defmodule Point do
use TypeStruct
defstruct x: integer,
y: integer
end
It can also define nested struct modules:
defmodule Accounts do
use TypeStruct
defstruct User,
id: integer,
name: String.t() \\ "",
eye_color: :black | :blue | :brown | :green
defstruct Group,
id: integer,
name: String.t(),
users: [User.t()] \\ []
end
Fields without defaults are required through `@enforce_keys`. TypeStruct
defines a public `t/0` type by default, and accepts `type/1`, `typep/1`, or
`opaque/1` to customize the generated type.
"""
# Built-in types recognized by Elixir's typespec translator but not reported
# by `:erl_internal.is_type/2`.
# Last updated for Elixir 1.19.5.
# See https://hexdocs.pm/elixir/typespecs.html.
@elixir_builtin_types [
as_boolean: 1,
char_list: 0,
charlist: 0,
fun: 0,
keyword: 0,
keyword: 1,
nonempty_charlist: 0,
struct: 0,
var: 0
]
defmacro __using__(_opts) do
quote do
import TypeStruct
import Kernel, except: [defstruct: 1]
end
end
@doc ~S"""
Defines a struct and a matching `t/0` type in the current module.
defmodule Point do
use TypeStruct
defstruct x: integer, y: integer
end
Fields without defaults are added to `@enforce_keys`.
"""
defmacro defstruct(fields)
defmacro defstruct(keywords),
do: do_defstruct(keywords, default_type_definition())
@doc ~S"""
Defines either a nested struct module or a struct with a custom type.
defmodule Geometry do
use TypeStruct
defstruct Point, x: integer, y: integer
end
The example above defines `Geometry.Point` and `Geometry.Point.t/0`.
If the first argument is a type declaration, the struct is defined in the
current module with that type:
defstruct opaque(t), id: integer
defstruct typep(fields), id: integer
"""
defmacro defstruct(alias_or_type_definition, fields)
defmacro defstruct({:__aliases__, _meta, _args} = alias, keywords) do
do_defmodule_defstruct(alias, __CALLER__, keywords, default_type_definition())
end
defmacro defstruct(quoted_type, keywords) do
do_defstruct(keywords, parse_quoted_type(quoted_type))
end
@doc ~S"""
Defines a nested struct module with a custom type declaration.
defmodule Accounts do
use TypeStruct
defstruct User, opaque(t), id: integer
end
The example above defines `Accounts.User` and `@opaque t()`.
"""
defmacro defstruct(alias, type_definition, fields)
defmacro defstruct(alias, quoted_type, keywords) do
do_defmodule_defstruct(alias, __CALLER__, keywords, parse_quoted_type(quoted_type))
end
# Convenience function to keep the macro definitions tidy.
defp do_defmodule_defstruct(alias, caller, keywords, type_definition) do
alias_types? = caller.module != nil
quoted_defstruct =
do_defstruct(keywords, type_definition, alias_types?, caller.module)
quoted_alias_block = create_quoted_alias_block(caller.aliases)
quote do
typedoc =
with module when module != nil <- __MODULE__,
{_line, typedoc} <- Module.delete_attribute(module, :typedoc) do
typedoc
else
nil -> nil
end
defmodule unquote(alias) do
unquote(quoted_alias_block)
@typedoc typedoc
unquote(quoted_defstruct)
end
end
end
defp do_defstruct(keywords, type_definition),
do: do_defstruct(keywords, type_definition, false, nil)
defp do_defstruct(keywords, type_definition, alias_types?, caller_alias) do
model =
keywords
|> parse_keyword_list
|> maybe_map_module_to_types(alias_types?, caller_alias)
defstruct_args =
for {key, {_, _, default}} <- model, do: {key, default}
enforce_keys_args =
for {key, {_, true, _}} <- model, do: key
quoted_type = create_quoted_type(type_definition, model)
quote do
# Must be above struct definition.
@enforce_keys unquote(enforce_keys_args)
Kernel.defstruct(unquote(defstruct_args))
# Now we set the @type/@typep/@opaque attribute.
unquote(quoted_type)
end
end
defp maybe_map_module_to_types(model, false, _module), do: model
defp maybe_map_module_to_types(model, true, module) do
for {key, {type, required, default}} <- model do
type = maybe_prepend_module(type, module)
{key, {type, required, default}}
end
end
# Takes a quoted keyword list and returns a model in
# the format `[key: {type, required?, default}, ...]`
# where `type` is a quoted representation of the
# field's type, `required?` is a boolean, and `default`
# is a quoted representation of the field's default
# value.
defp parse_keyword_list(keywords) do
Enum.map(keywords, fn {key, value} ->
{key, parse_keyword_value(value)}
end)
end
defp parse_keyword_value({:\\, _meta, [type, default]}),
do: {type, false, default}
defp parse_keyword_value(type),
do: {type, true, nil}
# Type definitions are described using the format
# `{type_attribute, type_name}` where `type_attribute`
# is an atom, and `type_name` is a quoted
# representation of the type's name.
defp parse_quoted_type({:type, _meta, [name]}), do: {:type, name}
defp parse_quoted_type({:typep, _meta, [name]}), do: {:typep, name}
defp parse_quoted_type({:opaque, _meta, [name]}), do: {:opaque, name}
defp default_type_definition, do: {:type, {:t, [], Elixir}}
defp create_quoted_type({type_attribute, quoted_type_name}, model) do
type_alias_args =
Enum.map(model, fn {key, {type, _, _}} -> {key, type} end)
quoted_struct_type =
{:%, [], [{:__MODULE__, [], Elixir}, {:%{}, [], type_alias_args}]}
create_quoted_type(type_attribute, quoted_type_name, quoted_struct_type)
end
defp create_quoted_type(:type, quoted_name, quoted_struct_type),
do: quote(do: @type(unquote(quoted_name) :: unquote(quoted_struct_type)))
defp create_quoted_type(:typep, quoted_name, quoted_struct_type),
do: quote(do: @typep(unquote(quoted_name) :: unquote(quoted_struct_type)))
defp create_quoted_type(:opaque, quoted_name, quoted_struct_type),
do: quote(do: @opaque(unquote(quoted_name) :: unquote(quoted_struct_type)))
# Takes a `Macro.Env.aliases` list, aka
# `[{alias, module]}`, and returns a quoted block
# of `alias/2` calls.
defp create_quoted_alias_block(aliases) do
for {aliased, actual} <- aliases do
quote do
alias unquote(actual), as: unquote(aliased)
end
end
end
# Takes a quoted type and a module. Walks the quoted
# type and prepends the module to any type that isn't
# a built-in.
defp maybe_prepend_module(list, module) when is_list(list) do
Enum.map(list, &maybe_prepend_module(&1, module))
end
defp maybe_prepend_module({atom, meta, args}, module) when is_atom(atom) do
with true <- possible_type_name?(atom),
arity <- quoted_args_arity(args),
false <- is_builtin?(atom, arity) do
args = if args, do: maybe_prepend_module(args, module), else: []
module_atom_parts = split_module_into_atoms(module)
{{:., [], [{:__aliases__, [], module_atom_parts}, atom]}, [], args}
else
_ -> {atom, meta, maybe_prepend_module(args, module)}
end
end
defp maybe_prepend_module({node, meta, args}, module) when is_tuple(node) do
{maybe_prepend_module(node, module), meta, maybe_prepend_module(args, module)}
end
defp maybe_prepend_module(tuple, module) when is_tuple(tuple) do
tuple
|> Tuple.to_list()
|> maybe_prepend_module(module)
|> List.to_tuple()
end
defp maybe_prepend_module(value, _module) do
value
end
defp possible_type_name?(atom) do
atom |> Atom.to_string() |> String.match?(~r/^[a-z][a-zA-Z0-9_]*[?!]{0,1}$/)
end
defp quoted_args_arity(nil), do: 0
defp quoted_args_arity(list), do: length(list)
defp is_builtin?(atom, arity) do
:erl_internal.is_type(atom, arity) ||
Enum.member?(Keyword.get_values(@elixir_builtin_types, atom), arity)
end
# Converts `Foo.Bar` to `[:Foo, :Bar]` (for example).
defp split_module_into_atoms(module),
do: module |> Module.split() |> Enum.map(&String.to_atom/1)
end