defmodule Algae do
@moduledoc """
Builder DSL to handle common ADT definition use cases
"""
import Algae.Internal
@type ast() :: {atom(), any(), any()}
@doc ~S"""
Build a product type
Includes:
* Struct
* Type definition
* Constructor function (for piping and defaults)
* Implicit defaults for simple values
## Definition
For convenveniece, several variants of the DSL are available.
### Standard
defmodule Player do
# =============== #
# Data Definition #
# =============== #
defdata do
name :: String.t()
hit_points :: non_neg_integer()
experience :: non_neg_integer()
end
# =================== #
# Rest of Module #
# (business as usual) #
# =================== #
@spec attack(t(), t()) :: {t(), t()}
def attack(%{experience: xp} = player, %{hit_points: hp} = target) do
{
%{player | experience: xp + 50},
%{target | hit_points: hp - 10}
}
end
end
#=> %Player{name: "Sir Bob", hit_points: 10, experience: 500}
### Single Field Shorthand
Without any fields specified, Algae will default to a single field with
the same name as the module (essentially a "wrapper type"). You must still
provide the type for this field, however.
Embedded in another module:
defmodule Id do
defdata any()
end
%Id{}
#=> %Id{id: nil}
Standalone:
defdata Wrapper :: any()
%Wrapper{}
#=> %Wrapper{wrapper: nil}
## Constructor
A helper function, especially useful for piping. The order of arguments is
the same as the order that they are defined in.
defmodule Person do
defdata do
name :: String.t()
age :: non_neg_integer()
end
end
Person.new("Rachel Weintraub")
#=> %Person{
# name: "Rachel Weintraub",
# age: 0
# }
### Constructor Defaults
Fields will automatically default to a sensible value (a typical "zero" for
that datatype). For example, `non_neg_integer()` will default to `0`,
and `String.t()` will default to `""`.
You may also overwrite these defaults with the `\\\\` syntax.
defmodule Pet do
defdata do
name :: String.t()
leg_count :: non_neg_integer() \\\\ 4
end
end
Pet.new("Crookshanks")
#=> %Pet{
# name: "Crookshanks",
# leg_count: 4
# }
Pet.new("Paul the Psychic Octopus", 8)
#=> %Pet{
# name: "Paul the Psychic Octopus",
# leg_count: 8
# }
This overwriting syntax is _required_ for complex types:
defdata Grocery do
item :: {String.t(), integer(), boolean()} \\\\ {"Apple", 4, false}
end
Grocery.new()
#=> %Grocery{
# item: {"Apple", 4, false}
# }
### Overwrite Constructor
The `new` constructor function may be overwritten.
iex> defmodule Constant do
...> defdata fun()
...>
...> def new(value), do: %Constant{constant: fn _ -> value end}
...> end
...>
...> fourty_two = Constant.new(42)
...> fourty_two.constant.(33)
42
## Empty Tag
An empty type (with no fields) is definable using the `none`() type
defmodule Nothing do
defdata none()
end
Nothing.new()
#=> %Nothing{}
"""
defmacro defdata(ast) do
caller_module = __CALLER__.module
case ast do
{:none, _, _} = _type ->
embedded_data_ast()
{:\\, _, [{:::, _, [module_ctx, type]}, default]} ->
caller_module
|> modules(module_ctx)
|> data_ast(default, type)
{:\\, _, [type, default]} ->
caller_module
|> List.wrap()
|> embedded_data_ast(default, type)
{:::, _, [module_ctx, {:none, _, _} = type]} ->
caller_module
|> modules(module_ctx)
|> data_ast(type)
{:::, _, [module_ctx, type]} ->
caller_module
|> modules(module_ctx)
|> data_ast(default_value(type), type)
{_, _, _} = type ->
data_ast(caller_module, type)
[do: {:__block__, _, lines}] ->
data_ast(lines, __CALLER__)
[do: line] ->
data_ast([line], __CALLER__)
end
end
defmacro defdata(module_ctx, do: body) do
module_name =
__CALLER__.module
|> modules(module_ctx)
|> Module.concat()
inner =
body
|> case do
{:__block__, _, lines} -> lines
line -> List.wrap(line)
end
|> data_ast(__CALLER__)
quote do
defmodule unquote(module_name) do
unquote(inner)
end
end
end
@doc """
Build a sum (coproduct) type from product types
defmodule Light do
# ============== #
# Sum Definition #
# ============== #
defsum do
defdata Red :: none()
defdata Yellow :: none()
defdata Green :: none()
end
# =================== #
# Rest of Module #
# (business as usual) #
# =================== #
def from_number(1), do: %Light.Red{}
def from_number(2), do: %Light.Yellow{}
def from_number(3), do: %Light.Green{}
end
Light.new()
#=> %Light.Red{}
## Embedded Products
Data with multiple fileds can be defined directly as part of a sum
defmodule Pet do
defsum do
defdata Cat do
name :: String.t()
claw_sharpness :: String.t()
end
defdata Dog do
name :: String.t()
bark_loudness :: non_neg_integer()
end
end
end
## Default Constructor
The first `defdata`'s constructor will be the default constructor for the sum
defmodule Maybe do
defsum do
defdata Nothing :: none()
defdata Just :: any()
end
end
Maybe.new()
#=> %Maybe.Nothing{}
## Tagged Unions
Sums join existing types with tags: new types to help distibguish the context
that they are in (the sum type)
defdata Book :: String.t() \\\\ "War and Peace"
defdata Video :: String.t() \\\\ "2001: A Space Odyssey"
defmodule Media do
defsum do
defdata Paper :: Book.t()
defdata Film :: Video.t() \\\\ Video.new("A Clockwork Orange")
end
end
media = Media.new()
#=> %Paper{
# paper: %Book{
# book: "War and Peace"
# }
# }
"""
@spec defsum([do: {:__block__, [any()], ast()}]) :: ast()
defmacro defsum(do: {:__block__, _, [first | _] = parts} = block) do
module_ctx = __CALLER__.module
types = or_types(parts, module_ctx)
default_module =
module_ctx
|> List.wrap()
|> Kernel.++(submodule_name(first))
|> Module.concat()
quote do
@type t :: unquote(types)
unquote(block)
@spec new() :: t()
def new, do: unquote(default_module).new()
defoverridable [new: 0]
end
end
end