Literal values in records should not be escaped. This lead to structs (and other non-literals in AST) being returned as AST, which was unexpected and incorrect.
437 lines
14 KiB
Elixir
437 lines
14 KiB
Elixir
defmodule Record do
|
|
@moduledoc """
|
|
Module to work with, define, and import records.
|
|
|
|
Records are simply tuples where the first element is an atom:
|
|
|
|
iex> Record.is_record {User, "john", 27}
|
|
true
|
|
|
|
This module provides conveniences for working with records at
|
|
compilation time, where compile-time field names are used to
|
|
manipulate the tuples, providing fast operations on top of
|
|
the tuples' compact structure.
|
|
|
|
In Elixir, records are used mostly in two situations:
|
|
|
|
1. to work with short, internal data
|
|
2. to interface with Erlang records
|
|
|
|
The macros `defrecord/3` and `defrecordp/3` can be used to create records
|
|
while `extract/2` and `extract_all/1` can be used to extract records from
|
|
Erlang files.
|
|
|
|
## Types
|
|
|
|
Types can be defined for tuples with the `record/2` macro (only available in
|
|
typespecs). This macro will expand to a tuple as seen in the example below:
|
|
|
|
defmodule MyModule do
|
|
require Record
|
|
Record.defrecord :user, name: "john", age: 25
|
|
|
|
@type user :: record(:user, name: String.t, age: integer)
|
|
# expands to: "@type user :: {:user, String.t, integer}"
|
|
end
|
|
|
|
"""
|
|
|
|
@doc """
|
|
Extracts record information from an Erlang file.
|
|
|
|
Returns a quoted expression containing the fields as a list
|
|
of tuples.
|
|
|
|
`name`, which is the name of the extracted record, is expected to be an atom
|
|
*at compile time*.
|
|
|
|
## Options
|
|
|
|
This function accepts the following options, which are exclusive to each other
|
|
(i.e., only one of them can be used in the same call):
|
|
|
|
* `:from` - (binary representing a path to a file) path to the Erlang file
|
|
that contains the record definition to extract; with this option, this
|
|
function uses the same path lookup used by the `-include` attribute used in
|
|
Erlang modules.
|
|
* `:from_lib` - (binary representing a path to a file) path to the Erlang
|
|
file that contains the record definition to extract; with this option,
|
|
this function uses the same path lookup used by the `-include_lib`
|
|
attribute used in Erlang modules.
|
|
|
|
These options are expected to be literals (including the binary values) at
|
|
compile time.
|
|
|
|
## Examples
|
|
|
|
iex> Record.extract(:file_info, from_lib: "kernel/include/file.hrl")
|
|
[size: :undefined, type: :undefined, access: :undefined, atime: :undefined,
|
|
mtime: :undefined, ctime: :undefined, mode: :undefined, links: :undefined,
|
|
major_device: :undefined, minor_device: :undefined, inode: :undefined,
|
|
uid: :undefined, gid: :undefined]
|
|
|
|
"""
|
|
def extract(name, opts) when is_atom(name) and is_list(opts) do
|
|
Record.Extractor.extract(name, opts)
|
|
end
|
|
|
|
@doc """
|
|
Extracts all records information from an Erlang file.
|
|
|
|
Returns a keyword list of `{record_name, fields}` tuples where `record_name`
|
|
is the name of an extracted record and `fields` is a list of `{field, value}`
|
|
tuples representing the fields for that record.
|
|
|
|
## Options
|
|
|
|
This function accepts the following options, which are exclusive to each other
|
|
(i.e., only one of them can be used in the same call):
|
|
|
|
* `:from` - (binary representing a path to a file) path to the Erlang file
|
|
that contains the record definitions to extract; with this option, this
|
|
function uses the same path lookup used by the `-include` attribute used in
|
|
Erlang modules.
|
|
* `:from_lib` - (binary representing a path to a file) path to the Erlang
|
|
file that contains the record definitions to extract; with this option,
|
|
this function uses the same path lookup used by the `-include_lib`
|
|
attribute used in Erlang modules.
|
|
|
|
These options are expected to be literals (including the binary values) at
|
|
compile time.
|
|
"""
|
|
def extract_all(opts) when is_list(opts) do
|
|
Record.Extractor.extract_all(opts)
|
|
end
|
|
|
|
@doc """
|
|
Checks if the given `data` is a record of kind `kind`.
|
|
|
|
This is implemented as a macro so it can be used in guard clauses.
|
|
|
|
## Examples
|
|
|
|
iex> record = {User, "john", 27}
|
|
iex> Record.is_record(record, User)
|
|
true
|
|
|
|
"""
|
|
defmacro is_record(data, kind) do
|
|
case Macro.Env.in_guard?(__CALLER__) do
|
|
true ->
|
|
quote do
|
|
is_atom(unquote(kind)) and is_tuple(unquote(data)) and tuple_size(unquote(data)) > 0 and
|
|
elem(unquote(data), 0) == unquote(kind)
|
|
end
|
|
false ->
|
|
quote do
|
|
result = unquote(data)
|
|
kind = unquote(kind)
|
|
is_atom(kind) and is_tuple(result) and tuple_size(result) > 0 and elem(result, 0) == kind
|
|
end
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Checks if the given `data` is a record.
|
|
|
|
This is implemented as a macro so it can be used in guard clauses.
|
|
|
|
## Examples
|
|
|
|
iex> record = {User, "john", 27}
|
|
iex> Record.is_record(record)
|
|
true
|
|
iex> tuple = {}
|
|
iex> Record.is_record(tuple)
|
|
false
|
|
|
|
"""
|
|
defmacro is_record(data) do
|
|
case Macro.Env.in_guard?(__CALLER__) do
|
|
true ->
|
|
quote do
|
|
is_tuple(unquote(data)) and tuple_size(unquote(data)) > 0 and
|
|
is_atom(elem(unquote(data), 0))
|
|
end
|
|
false ->
|
|
quote do
|
|
result = unquote(data)
|
|
is_tuple(result) and tuple_size(result) > 0 and is_atom(elem(result, 0))
|
|
end
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Defines a set of macros to create, access, and pattern match
|
|
on a record.
|
|
|
|
The name of the generated macros will be `name` (which has to be an
|
|
atom). `tag` is also an atom and is used as the "tag" for the record (i.e.,
|
|
the first element of the record tuple); by default (if `nil`), it's the same
|
|
as `name`. `kv` is a keyword list of `name: default_value` fields for the
|
|
new record.
|
|
|
|
The following macros are generated:
|
|
|
|
* `name/0` to create a new record with default values for all fields
|
|
* `name/1` to create a new record with the given fields and values or to
|
|
convert the given record to a keyword list
|
|
* `name/2` to update an existing record with the given fields and values
|
|
or to access a given field in a given record
|
|
|
|
All these macros are public macros (as defined by `defmacro`).
|
|
|
|
See the "Examples" section for examples on how to use these macros.
|
|
|
|
## Examples
|
|
|
|
defmodule User do
|
|
require Record
|
|
Record.defrecord :user, [name: "meg", age: "25"]
|
|
end
|
|
|
|
In the example above, a set of macros named `user` but with different
|
|
arities will be defined to manipulate the underlying record.
|
|
|
|
# To create records
|
|
record = user() #=> {:user, "meg", 25}
|
|
record = user(age: 26) #=> {:user, "meg", 26}
|
|
|
|
# To get a field from the record
|
|
user(record, :name) #=> "meg"
|
|
|
|
# To update the record
|
|
user(record, age: 26) #=> {:user, "meg", 26}
|
|
|
|
# Convert a record to a keyword list
|
|
user(record) #=> [name: "meg", age: 26]
|
|
|
|
The generated macros can also be used in order to pattern match on records and
|
|
to bind variables during the match:
|
|
|
|
record = user() #=> {:user, "meg", 25}
|
|
|
|
user(name: name) = record
|
|
name #=> "meg"
|
|
|
|
By default, Elixir uses the record name as the first element of the tuple (the
|
|
"tag"). However, a different tag can be specified when defining a record:
|
|
|
|
defmodule User do
|
|
require Record
|
|
Record.defrecord :user, User, name: nil
|
|
end
|
|
|
|
require User
|
|
User.user() #=> {User, nil}
|
|
|
|
## Defining extracted records with anonymous functions in the values
|
|
|
|
If a record defines an anonymous function in the default values, an
|
|
`ArgumentError` will be raised. This can happen unintentionally when defining
|
|
a record after extracting it from an Erlang library that uses anonymous
|
|
functions for defaults.
|
|
|
|
Record.defrecord :my_rec, Record.extract(...)
|
|
#=> ** (ArgumentError) invalid value for record field fun_field,
|
|
cannot escape #Function<12.90072148/2 in :erl_eval.expr/5>.
|
|
|
|
To work around this error, redefine the field with your own &M.f/a function,
|
|
like so:
|
|
|
|
defmodule MyRec do
|
|
require Record
|
|
Record.defrecord :my_rec, Record.extract(...) |> Keyword.merge(fun_field: &__MODULE__.foo/2)
|
|
def foo(bar, baz), do: IO.inspect({bar, baz})
|
|
end
|
|
|
|
"""
|
|
defmacro defrecord(name, tag \\ nil, kv) do
|
|
quote bind_quoted: [name: name, tag: tag, kv: kv] do
|
|
tag = tag || name
|
|
fields = Record.__fields__(:defrecord, kv)
|
|
|
|
defmacro(unquote(name)(args \\ [])) do
|
|
Record.__access__(unquote(tag), unquote(fields), args, __CALLER__)
|
|
end
|
|
|
|
defmacro(unquote(name)(record, args)) do
|
|
Record.__access__(unquote(tag), unquote(fields), record, args, __CALLER__)
|
|
end
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Same as `defrecord/3` but generates private macros.
|
|
"""
|
|
defmacro defrecordp(name, tag \\ nil, kv) do
|
|
quote bind_quoted: [name: name, tag: tag, kv: kv] do
|
|
tag = tag || name
|
|
fields = Record.__fields__(:defrecordp, kv)
|
|
|
|
defmacrop(unquote(name)(args \\ [])) do
|
|
Record.__access__(unquote(tag), unquote(fields), args, __CALLER__)
|
|
end
|
|
|
|
defmacrop(unquote(name)(record, args)) do
|
|
Record.__access__(unquote(tag), unquote(fields), record, args, __CALLER__)
|
|
end
|
|
end
|
|
end
|
|
|
|
# Normalizes of record fields to have default values.
|
|
@doc false
|
|
def __fields__(type, fields) do
|
|
:lists.map(fn
|
|
{key, val} when is_atom(key) ->
|
|
try do
|
|
Macro.escape(val)
|
|
rescue
|
|
e in [ArgumentError] ->
|
|
raise ArgumentError, "invalid value for record field #{key}, " <> Exception.message(e)
|
|
else
|
|
val -> {key, val}
|
|
end
|
|
key when is_atom(key) ->
|
|
{key, nil}
|
|
other ->
|
|
raise ArgumentError, "#{type} fields must be atoms, got: #{inspect other}"
|
|
end, fields)
|
|
end
|
|
|
|
# Callback invoked from record/0 and record/1 macros.
|
|
@doc false
|
|
def __access__(atom, fields, args, caller) do
|
|
cond do
|
|
is_atom(args) ->
|
|
index(atom, fields, args)
|
|
Keyword.keyword?(args) ->
|
|
create(atom, fields, args, caller)
|
|
true ->
|
|
case Macro.expand(args, caller) do
|
|
{:{}, _, [^atom | list]} when length(list) == length(fields) ->
|
|
record = List.to_tuple([atom | list])
|
|
Record.__keyword__(atom, fields, record)
|
|
{^atom, arg} when length(fields) == 1 ->
|
|
Record.__keyword__(atom, fields, {atom, arg})
|
|
_ ->
|
|
quote do: Record.__keyword__(unquote(atom), unquote(fields), unquote(args))
|
|
end
|
|
end
|
|
end
|
|
|
|
# Callback invoked from the record/2 macro.
|
|
@doc false
|
|
def __access__(atom, fields, record, args, caller) do
|
|
cond do
|
|
is_atom(args) ->
|
|
get(atom, fields, record, args)
|
|
Keyword.keyword?(args) ->
|
|
update(atom, fields, record, args, caller)
|
|
true ->
|
|
msg = "expected arguments to be a compile time atom or keywords, got: #{Macro.to_string args}"
|
|
raise ArgumentError, msg
|
|
end
|
|
end
|
|
|
|
# Gets the index of field.
|
|
defp index(atom, fields, field) do
|
|
if index = find_index(fields, field, 0) do
|
|
index - 1 # Convert to Elixir index
|
|
else
|
|
raise ArgumentError, "record #{inspect atom} does not have the key: #{inspect field}"
|
|
end
|
|
end
|
|
|
|
# Creates a new record with the given default fields and keyword values.
|
|
defp create(atom, fields, keyword, caller) do
|
|
in_match = Macro.Env.in_match?(caller)
|
|
keyword = apply_underscore(fields, keyword)
|
|
|
|
{match, remaining} =
|
|
Enum.map_reduce(fields, keyword, fn({field, default}, each_keyword) ->
|
|
new_fields =
|
|
case Keyword.fetch(each_keyword, field) do
|
|
{:ok, value} -> value
|
|
:error when in_match -> {:_, [], nil}
|
|
:error -> Macro.escape(default)
|
|
end
|
|
|
|
{new_fields, Keyword.delete(each_keyword, field)}
|
|
end)
|
|
|
|
case remaining do
|
|
[] ->
|
|
{:{}, [], [atom | match]}
|
|
_ ->
|
|
keys = for {key, _} <- remaining, do: key
|
|
raise ArgumentError, "record #{inspect atom} does not have the key: #{inspect hd(keys)}"
|
|
end
|
|
end
|
|
|
|
# Updates a record given by var with the given keyword.
|
|
defp update(atom, fields, var, keyword, caller) do
|
|
if Macro.Env.in_match?(caller) do
|
|
raise ArgumentError, "cannot invoke update style macro inside match"
|
|
end
|
|
|
|
keyword = apply_underscore(fields, keyword)
|
|
|
|
Enum.reduce keyword, var, fn({key, value}, acc) ->
|
|
index = find_index(fields, key, 0)
|
|
if index do
|
|
quote do
|
|
:erlang.setelement(unquote(index), unquote(acc), unquote(value))
|
|
end
|
|
else
|
|
raise ArgumentError, "record #{inspect atom} does not have the key: #{inspect key}"
|
|
end
|
|
end
|
|
end
|
|
|
|
# Gets a record key from the given var.
|
|
defp get(atom, fields, var, key) do
|
|
index = find_index(fields, key, 0)
|
|
if index do
|
|
quote do
|
|
:erlang.element(unquote(index), unquote(var))
|
|
end
|
|
else
|
|
raise ArgumentError, "record #{inspect atom} does not have the key: #{inspect key}"
|
|
end
|
|
end
|
|
|
|
defp find_index([{k, _} | _], k, i), do: i + 2
|
|
defp find_index([{_, _} | t], k, i), do: find_index(t, k, i + 1)
|
|
defp find_index([], _k, _i), do: nil
|
|
|
|
# Returns a keyword list of the record
|
|
@doc false
|
|
def __keyword__(atom, fields, record) do
|
|
if is_record(record, atom) do
|
|
[_tag | values] = Tuple.to_list(record)
|
|
join_keyword(fields, values, [])
|
|
else
|
|
msg = "expected argument to be a literal atom, literal keyword or a #{inspect atom} record, got runtime: #{inspect record}"
|
|
raise ArgumentError, msg
|
|
end
|
|
end
|
|
|
|
defp join_keyword([{field, _default} | fields], [value | values], acc),
|
|
do: join_keyword(fields, values, [{field, value} | acc])
|
|
defp join_keyword([], [], acc),
|
|
do: :lists.reverse(acc)
|
|
|
|
defp apply_underscore(fields, keyword) do
|
|
case Keyword.fetch(keyword, :_) do
|
|
{:ok, default} ->
|
|
fields
|
|
|> Enum.map(fn {k, _} -> {k, default} end)
|
|
|> Keyword.merge(keyword)
|
|
|> Keyword.delete(:_)
|
|
:error ->
|
|
keyword
|
|
end
|
|
end
|
|
end
|