import Croma.Defun
alias Croma.Result, as: R
defmodule Croma.TypeGen do
@moduledoc """
Module that defines macros for ad-hoc (in other words "in-line") module definitions.
"""
@doc """
Creates a new module that represents a nilable type, based on the given type module `module`.
Using the given type module `nilable/1` generates a new module that defines:
- `@type t :: nil | module.t`
- `@spec valid?(term) :: boolean`
- `@spec default() :: nil`
- If the given module exports `new/1`
- `@spec new(term) :: Croma.Result.t(t)`
- `@spec new!(term) :: t`
This is useful in defining a struct with nilable fields using `Croma.Struct`.
## Examples
iex> use Croma
...> defmodule I do
...> use Croma.SubtypeOfInt, min: 0
...> end
...> defmodule S do
...> use Croma.Struct, fields: [not_nilable_int: I, nilable_int: Croma.TypeGen.nilable(I)]
...> end
...> S.new(%{not_nilable_int: 0, nilable_int: nil})
%S{nilable_int: nil, not_nilable_int: 0}
"""
defmacro nilable(module) do
nilable_impl(Macro.expand(module, __CALLER__), Macro.Env.location(__CALLER__))
end
defp nilable_impl(mod, location) do
module_body = Macro.escape(nilable_module_body(mod))
quote bind_quoted: [mod: mod, module_body: module_body, location: location] do
name = Module.concat(Croma.TypeGen.Nilable, mod)
Croma.TypeGen.ensure_module_defined(name, module_body, location)
name
end
end
defp nilable_module_body(mod) do
quote bind_quoted: [mod: mod] do
@moduledoc false
@mod mod
@type t :: nil | unquote(@mod).t
defun valid?(value :: term) :: boolean do
nil -> true
v -> @mod.valid?(v)
end
# Invoking `module_info/1` on `mod` automatically compiles and loads the module if necessary.
if {:new, 1} in @mod.module_info(:exports) do
defun new(value :: term) :: R.t(t) do
nil -> {:ok, nil}
v -> @mod.new(v) |> R.map_error(fn reason -> R.ErrorReason.add_context(reason, __MODULE__) end)
end
defun new!(term :: term) :: t do
new(term) |> R.get!()
end
end
defun default() :: t, do: nil
end
end
@doc """
An ad-hoc version of `Croma.SubtypeOfList`.
Options:
- `:define_default0?` - Boolean value that indicates whether to define `default/0` (which simply returns `[]`). Defaults to `true`.
"""
defmacro list_of(module, options \\ []) do
list_of_impl(Macro.expand(module, __CALLER__), Macro.Env.location(__CALLER__), options)
end
defp list_of_impl(mod, location, options) do
module_body = Macro.escape(list_of_module_body(mod, options))
quote bind_quoted: [mod: mod, module_body: module_body, location: location, options: options] do
prefix = if Keyword.get(options, :define_default0?, true), do: Croma.TypeGen.ListOf, else: Croma.TypeGen.ListOfNoDefault0
name = Module.concat(prefix, mod)
Croma.TypeGen.ensure_module_defined(name, module_body, location)
name
end
end
defp list_of_module_body(mod, options) do
quote bind_quoted: [mod: mod, options: options] do
@moduledoc false
@mod mod
@type t :: [unquote(@mod).t]
defun valid?(list :: term) :: boolean do
l when is_list(l) -> Enum.all?(l, &@mod.valid?/1)
_ -> false
end
# Invoking `module_info/1` on `mod` automatically compiles and loads the module if necessary.
if {:new, 1} in @mod.module_info(:exports) do
defun new(list :: term) :: R.t(t) do
l when is_list(l) -> Enum.map(l, &@mod.new/1) |> R.sequence()
_ -> {:error, {:invalid_value, [__MODULE__]}}
end
defun new!(term :: term) :: t do
new(term) |> R.get!()
end
end
if Keyword.get(options, :define_default0?, true) do
defun default() :: t, do: []
end
end
end
@doc """
Creates a new module that represents a sum type of the given types.
The argument must be a list of type modules.
Note that the specified types should be mutually disjoint;
otherwise `new/1` can return unexpected results depending on the order of the type modules.
"""
defmacro union(modules) do
ms = Enum.map(modules, fn m -> Macro.expand(m, __CALLER__) end)
if Enum.empty?(ms), do: raise "Empty union is not allowed"
union_impl(ms, Macro.Env.location(__CALLER__))
end
defp union_impl(modules, location) do
module_body = Macro.escape(union_module_body(modules))
quote bind_quoted: [modules: modules, module_body: module_body, location: location] do
hash = Enum.map(modules, &Atom.to_string/1) |> :erlang.md5() |> Base.encode16()
name = Module.concat(Croma.TypeGen.Union, hash)
Croma.TypeGen.ensure_module_defined(name, module_body, location)
name
end
end
defp union_module_body(modules) do
quote bind_quoted: [modules: modules] do
@moduledoc false
@modules modules
@type t :: unquote(Enum.map(@modules, fn m -> quote do: unquote(m).t end) |> Croma.TypeUtil.list_to_type_union())
defun valid?(value :: term) :: boolean do
Enum.any?(@modules, fn mod -> mod.valid?(value) end)
end
module_flag_pairs = Enum.map(@modules, fn m -> {m, {:new, 1} in m.module_info(:exports)} end)
Enum.each(module_flag_pairs, fn {mod, has_new1} ->
if has_new1 do
defp call_new_or_validate(unquote(mod), v) do
unquote(mod).new(v)
end
else
defp call_new_or_validate(unquote(mod), v) do
Croma.Result.wrap_if_valid(v, unquote(mod))
end
end
end)
defun new(v :: term) :: R.t(t) do
new_impl(v, @modules) |> R.map_error(fn _ -> {:invalid_value, [__MODULE__]} end)
end
defp new_impl(v, [m]) do
call_new_or_validate(m, v)
end
defp new_impl(v, [m | ms]) do
require R
call_new_or_validate(m, v) |> R.or_else(new_impl(v, ms))
end
defun new!(term :: term) :: t do
new(term) |> R.get!()
end
end
end
@doc """
Creates a new module that simply represents a type whose sole member is the given value.
Only atoms and integers are supported.
"""
defmacro fixed(value) do
fixed_impl(value, Macro.Env.location(__CALLER__))
end
defp fixed_impl(value, location) when is_atom(value) or is_integer(value) do
module_body = Macro.escape(fixed_module_body(value))
quote bind_quoted: [value: value, module_body: module_body, location: location] do
hash = :erlang.term_to_binary(value) |> :erlang.md5() |> Base.encode16()
name = Module.concat(Croma.TypeGen.Fixed, hash)
Croma.TypeGen.ensure_module_defined(name, module_body, location)
name
end
end
defp fixed_module_body(value) do
quote bind_quoted: [value: value] do
@moduledoc false
@value value
@type t :: unquote(@value)
defun valid?(v :: term) :: boolean do
v == @value
end
defun default() :: t, do: @value
end
end
@doc false
def ensure_module_defined(name, quoted_expr, location) do
# Skip creating module if its beam file is already generated by previous compilation
if :code.which(name) == :non_existing do
# Use processes' registered names (just because it's easy) to remember whether already defined or not
# (Using `module_info` leads to try-rescue, which results in compilation error:
# see https://github.com/elixir-lang/elixir/issues/4055)
case Agent.start(fn -> nil end, [name: name]) do
{:ok , _pid } -> Module.create(name, quoted_expr, location)
{:error, _already_defined} -> nil
end
end
end
@doc false
def define_nilable_and_list_of(mod) do
location = Macro.Env.location(__ENV__)
q1 = nilable_impl(mod, location)
q2 = list_of_impl(mod, location, [define_default0?: true ])
q3 = list_of_impl(mod, location, [define_default0?: false])
Code.eval_quoted(q1, [], __ENV__)
Code.eval_quoted(q2, [], __ENV__)
Code.eval_quoted(q3, [], __ENV__)
end
end
# Predefine some type modules to avoid warnings when generated by multiple mix projects
defmodule Croma.PredefineVariantsOfBuiltinTypes do
@moduledoc false
Croma.BuiltinType.all() |> Enum.each(&Croma.TypeGen.define_nilable_and_list_of/1)
end