Current section
Files
Jump to
Current section
Files
lib/makina/command/base.ex
defmodule Makina.Command.Base do
import Makina.Helpers
alias Makina.Error
use Corsa, disabled: true
@moduledoc false
@type command_info() :: Makina.Command.command_info()
@callbacks [:pre, :args, :valid_args, :call, :next, :post, :weight, :valid, :features]
##################################################################################################
# Macros
##################################################################################################
@spec __using__([]) :: Macro.t()
@doc """
This macro configures the module to allow base command definitions. Accepts global command
options, this means that options passed to `use` are passed to every command declared in the
module.
This macro registers the following module attributes:
| attribute | accumulate | initial value | description |
|---------------------|------------|---------------|-------------------------------------------|
| `:commands` | `true` | `[]` | names of the commands |
| `:commands_options` | `true` | `[]` | global options for commands |
| `:commands_options` | `true` | `options` | global options for commands |
| `:commands_info` | `true` | `[]` | information about the command enviroments |
| `:commands_code` | `true` | `[]` | stores the code of declared commands |
## Examples
iex> defmodule Example do
...> use Makina.Command.Base
...> end
"""
defmacro __using__(_options) do
context = __CALLER__.module
Module.register_attribute(context, :commands, accumulate: true)
Module.register_attribute(context, :commands_options, accumulate: true)
Module.register_attribute(context, :commands_info, accumulate: true)
Module.register_attribute(context, :commands_code, accumulate: true)
quote do
import unquote(__MODULE__), only: :macros
@before_compile unquote(__MODULE__)
end
end
@spec command(Macro.t(), [], Macro.t()) :: Macro.t()
@doc """
This macro is used to declare a command of a model, each command is stored in its own module. The
parameters of this macro are: the command declaration, a list of options and the body of the
command. The declaration contains the name of the command and a list of parameters, these can be
optionally typed, if no type is provided for some parameter it is defaulted to `any()`.
This macro reads the module attributes `:commands_options`.
This macro modifies the following module attributes:
| attribute | value |
|------------------|-------------------------|
| `:commands` | [atom()] |
| `:commands_info` | [{atom(), Macro.env()}] |
## Examples
iex> defmodule Example do
...> use Makina.Command.Base
...> command f(), [] do
...> pre true
...> args []
...> valid true
...> valid_args true
...> call :ok
...> next []
...> post true
...> weight 1
...> features []
...> end
...> end
iex> defmodule Example do
...> use Makina.Command.Base
...> command f(x, x), [] do
...> pre true
...> args []
...> valid true
...> valid_args true
...> call :ok
...> next []
...> post true
...> weight 1
...> features []
...> end
...> end
** (Makina.Error) arguments must have different names, repeated names `:x` in `MakinaDoctestTest.Example.f`
"""
defmacro command(decl, opts, [{:do, block}]) do
context = __CALLER__.module
# Command level options override global options.
options = opts ++ Module.get_attribute(context, :commands_options, [])
# Extracts information from the command declaration and the callbacks.
info =
case parse_command(decl, options, block, context, __CALLER__) do
{:ok, command} -> command
{:error, error} -> Error.throw_error(error, __CALLER__)
end
Module.put_attribute(context, :commands, info.name)
Module.put_attribute(context, :commands_info, {info.name, info})
Module.put_attribute(context, :commands_code, {info.name, block})
[]
end
@spec __before_compile__(Macro.Env.t()) :: Macro.t()
@doc """
This macro creates the command module and injects functions and types derived from commands
declarations.
This macro reads the module attributes: `:commands`, `:commands_code`, `:commands_info` and
`:attributes`.
## Examples
iex> defmodule A do
...> @before_compile Makina.Command.Base
...> def pre(_), do: :ok
...> end
** (Makina.Error) avoid naming functions using callback names: `[pre: 1]`
"""
defmacro __before_compile__(_env) do
context = __CALLER__.module
commands = Module.get_attribute(context, :commands, [])
commands_module = commands_module_name(context)
cmds = commands |> then(&if &1 == [], do: [quote(do: any())], else: &1)
options = Module.get_attribute(context, :commands_options, [])
checks = Keyword.get(options, :checks)
quote do
use Corsa, unquote((checks in [:specs, :all] && [{:disabled, false}]) || [{:disabled, true}])
@type command() :: unquote_splicing(list_union_type(cmds))
def __makina_info__(), do: %{commands: unquote(commands)}
end
|> then(&Makina.Module.create(commands_module, &1, __CALLER__))
macros = Makina.Command.Callback.__info__(:macros)
user_functions =
Module.definitions_in(context)
|> Enum.filter(fn fun -> fun in (macros ++ callbacks()) end)
unless user_functions == [] do
"avoid naming functions using callback names: `#{inspect(user_functions)}`"
|> Error.throw_error(__CALLER__)
end
# Extracts state attributes
attrs = Module.get_attribute(context, :attributes, []) |> Enum.uniq()
# If debug is enabled inserts the debug code in the module.
debug = Keyword.get(options, :debug, Application.get_env(:makina, :debug))
debug_module = Keyword.get(options, :debug_module, Application.get_env(:makina, :debug_module))
types = import_types(context)
defs = import_definitions(context)
for {name, block} <- Module.get_attribute(context, :commands_code) do
command_module = command_module_name(name, context)
%{env: env, arguments: arguments, result: result, callbacks: callbacks} =
Module.get_attribute(context, :commands_info) |> Keyword.get(name)
overlapping_attrs_and_args =
MapSet.intersection(MapSet.new(attrs), MapSet.new(Keyword.keys(arguments)))
|> Enum.to_list()
unless overlapping_attrs_and_args == [] do
"avoid using attribute names as command arguments, #{atoms_to_string(Enum.sort(overlapping_attrs_and_args))} in `#{inspect(context)}.#{name}` overlap(s) with its state attributes"
|> Error.throw_error(__CALLER__)
end
state_module = state_module_name(context)
quote do
use Corsa,
unquote((checks in [:specs, :all] && [{:disabled, false}]) || [{:disabled, true}])
unquote(debug)
unquote(debug_module)
def __makina_info__() do
%{
name: unquote(Macro.escape(name)),
module: unquote(command_module),
arguments: unquote(Macro.escape(arguments)),
result: unquote(Macro.escape(result)),
callbacks: unquote(Macro.escape(callbacks)),
env: unquote(Macro.escape(__CALLER__))
}
end
def arguments_order, do: unquote(Enum.map(arguments, &elem(&1, 0)))
use Makina.Command.Callback,
command: unquote(name),
arguments: unquote(arguments),
result: unquote(result),
state: unquote(attrs),
module: unquote(context),
symbolic_state: unquote(state_module).symbolic_state(),
dynamic_state: unquote(state_module).dynamic_state(),
symbolic_updates: unquote(state_module).symbolic_updates(),
dynamic_updates: unquote(state_module).dynamic_updates(),
abstract: unquote(Keyword.get(options, :abstract, false))
unquote(block)
unquote(defs)
unquote(types)
end
|> then(&Makina.Module.create(command_module, &1, env))
end
[]
end
##################################################################################################
# Helpers
##################################################################################################
@spec parse_command(Macro.t(), [any()], Macro.t(), module(), Macro.Env.t()) ::
{:ok, command_info()} | {:error, String.t()}
@doc """
This function parses a command declaration `name(argument1 :: type1, ... , argumentN :: typeN) ::
type` and returns the type information of the command.
## Examples
iex> decl = quote do: f(arg1 integer(), arg2) :: :ok
iex> opts = []
iex> cntx = Example
iex> block = quote do
...> pre true
...> call :ok
...> end
iex> {:ok, info} = parse_command(decl, opts, block, cntx, __ENV__)
iex> Map.keys(info)
[:env, :module, :name, :callbacks, :arguments, :result]
iex> decl = quote do: f(arg1 integer(), arg1) :: :ok
iex> parse_command(decl, opts, block, cntx, __ENV__)
{:error, "arguments must have different names, repeated names `:arg1` in `Example.f`"}
"""
def parse_command(decl, _options, block, context, env) do
with {:ok, name} <- get_name(decl),
{_, type_info} <- Macro.prewalk(decl, [], &extract_type_info/2),
{_, callbacks} <- Macro.prewalk(block, [], &get_callbacks/2),
type_info = Macro.prewalk(type_info, &Macro.expand(&1, env)),
{result, arguments} = Keyword.pop_first(type_info, name),
[] <-
Enum.group_by(arguments, fn {k, _v} -> k end)
|> Enum.filter(fn {_k, v} -> length(v) > 1 end) do
{:ok,
%{
name: name,
callbacks: callbacks,
arguments: arguments,
result: result,
module: context,
env: env
}}
else
error when is_list(error) ->
{:ok, name} = get_name(decl)
{:error,
"arguments must have different names, repeated names #{atoms_to_string(Enum.uniq(Keyword.keys(error)))} in `#{inspect(context)}.#{name}`"}
error ->
error
end
end
##################################################################################################
# AST Helpers
##################################################################################################
@spec get_name(Macro.t()) :: {:ok, atom()} | {:error, String.t()}
@doc """
This function extracts the name of a command declaration.
## Examples
iex> get_name(quote do: f(arg1, arg2))
{:ok, :f}
iex> get_name(quote do: [])
{:error, "invalid syntax in command"}
"""
def get_name({:"::", _, [{name, _, _}, _]}), do: {:ok, name}
def get_name({name, _, _}), do: {:ok, name}
def get_name(_), do: {:error, "invalid syntax in command"}
@spec get_type(Macro.t()) :: {:ok, Macro.t()} | {:error, String.t()}
@doc """
This function retrieves the type from a typed expression `expr :: type`. Returns `any()` if no
type annotation is provided.
## Examples
iex> get_type(quote do: arg :: integer())
{:ok, quote do: integer()}
iex> {:ok, type} = get_type(quote do: arg)
iex> type |> Macro.to_string()
quote do
Makina.Types.unknown()
end |> Macro.to_string()
"""
def get_type({:"::", _, [_, type]}), do: {:ok, type}
def get_type(_), do: {:ok, unknown_type()}
@spec remove_types(Macro.t()) :: Macro.t()
@doc """
This function removes the type from a typed expression `expr :: type` and returns just the
expression `expr`.
## Examples
iex> remove_types(quote do: arg :: integer())
quote do: arg
"""
def remove_types({:"::", _, [ast, _]}), do: ast
def remove_types(ast), do: ast
@spec extract_type_info(Macro.t(), Macro.t()) :: {Macro.t(), [{atom(), Macro.t()}]}
@doc """
This function extracts the type information from a expression which contains type annotations
(using `::`). It must be used with a function that traverses the AST, like `Macro.postwalk/1`.
## Examples
iex> decl = quote do: f(arg1 :: integer(), arg2) :: :ok
iex> {_, info} = Macro.prewalk(decl, [], &extract_type_info/2)
iex> info
[f: :ok, arg1: quote(do: integer()), arg2: quote(do: Makina.Types.unknown())]
"""
def extract_type_info(expr, acc) do
with {:ok, type} <- get_type(expr),
{:ok, value} <- get_name(expr) do
{remove_types(expr), acc ++ [{value, type}]}
else
_ -> {expr, acc}
end
end
@spec get_callbacks(Macro.t(), [atom()]) :: {Macro.t(), [atom()]}
@doc """
This function extracts the callbacks declared in a block. It must be used with a function that
traverses the AST, like `Macro.postwalk/prewalk`.
## Examples
iex> block = quote do
...> pre true
...> call :ok
...> next []
...> end
iex> {_, callbacks} = Macro.prewalk(block, [], &get_callbacks/2)
iex> callbacks
[:pre, :call, :next]
"""
def get_callbacks(ast = {name, _, block}, acc) when length(block) == 1 and name in @callbacks do
{ast, acc ++ [name]}
end
def get_callbacks(ast, acc), do: {ast, acc}
end