defmodule Expression.Autodoc do
@moduledoc """
Extract `@expression_doc` attributes from modules defining callbacks
and automatically write doctests for those.
Also inserts an `expression_docs()` function which returns a list of
all functions and their defined expression docs.
The format is:
```elixir
@expression_doc doc: "Construct a date from year, month, and day integers",
expression: "@date(year, month, day)",
context: %{"year" => 2022, "month" => 1, "day" => 31},
result: "2022-01-31T00:00:00Z"
```
Where:
* `doc` is the explanatory text added to the doctest.
* `expression` is the expression we want to test
* `context` is the context the expression is tested against
* `result` is the result we're expecting to get and are asserting against
* `fake_result` can be optionally supplied when the returning result varies
depending on factors we do not control, like for `now()` for example.
When this is used, the ExDoc tests are faked and won't actually test
anything so use sparingly.
"""
defmacro __using__(_args) do
quote do
@expression_docs []
Module.register_attribute(__MODULE__, :expression_doc, accumulate: true)
@on_definition Expression.Autodoc
@before_compile Expression.Autodoc
import Expression.Autodoc
end
end
def __on_definition__(env, :def, name, args, _guards, _body),
do: annotate_method(env.module, name, args)
def __on_definition__(_env, _kind, _name, _args, _guards, _body), do: nil
def annotate_method(module, function, args) do
if expression_doc = Module.delete_attribute(module, :expression_doc) do
update_annotations(module, function, args, expression_doc)
end
end
def update_annotations(module, function, args, []) do
existing_expression_docs = Module.get_attribute(module, :expression_docs)
{_line_number, doc} = get_existing_docstring(module)
{function_name, function_type} = format_function_name(function)
Module.put_attribute(module, :expression_docs, [
{function_name, function_type, format_function_args(args), doc, []}
| existing_expression_docs
])
end
def update_annotations(module, function, args, expression_docs) do
existing_expression_docs = Module.get_attribute(module, :expression_docs)
{line_number, doc} = get_existing_docstring(module)
expression_doc_tests =
expression_docs
|> Enum.reverse()
|> Enum.with_index(1)
|> Enum.map_join("\n", fn {expression_doc, index} ->
doc = expression_doc[:doc]
expression = expression_doc[:expression]
code_expression = expression_doc[:code_expression] || expression_doc[:expression]
context = expression_doc[:context]
{doctest_prompt, result} =
if is_nil(expression_doc[:fake_result]) do
{"iex", expression_doc[:result]}
else
{"..$", expression_doc[:fake_result]}
end
"""
## Example #{index}:
#{if(doc, do: "\n> #{doc}\n", else: "")}
When used in the following Stack expression it returns a #{format_result(result)}#{format_context(context)}
```
> #{Enum.join(String.split(code_expression, "\n"), "\n> ")}
#{inspect(result)}
```
When used as an expression in text, prepend it with an `@`:
```expression
> "... @#{expression} ..."
"#{stringify(result)}"
```
#{generate_ex_doc(doctest_prompt, module, expression, context || %{}, result)}
---
"""
end)
updated_docs =
case doc do
nil -> expression_doc_tests
doc -> "#{doc}\n\n#{expression_doc_tests}"
end
Module.put_attribute(
module,
:doc,
{line_number, updated_docs}
)
{function_name, function_type} = format_function_name(function)
Module.put_attribute(module, :expression_docs, [
{function_name, function_type, format_function_args(args), doc,
format_docs(expression_docs)}
| existing_expression_docs
])
end
def generate_ex_doc(prompt \\ "iex", module, expression, context, result) do
"""
#{prompt}> import ExUnit.Assertions
#{prompt}> result = Expression.evaluate_block!(
...> #{inspect(expression)},
...> #{inspect(context || %{})},
...> #{inspect(module)}
...> )
#{generate_assert(prompt, result)}
#{prompt}> Expression.evaluate_as_string!(
...> #{inspect("@" <> expression)},
...> #{inspect(context || %{})},
...> #{inspect(module)}
...> )
#{inspect(stringify(result))}
"""
end
def generate_assert(prompt, result) when is_nil(result) or result == false do
Enum.join(["#{prompt}> refute result", "#{inspect(result)}"], "\n ")
end
def generate_assert(prompt, result) do
Enum.join(
[
"#{prompt}> assert #{inspect(result)} = result",
"#{inspect(result)}"
],
"\n "
)
end
def type_of(%Time{}), do: "Time"
def type_of(%Date{}), do: "Date"
def type_of(%DateTime{}), do: "DateTime"
def type_of(%Decimal{}), do: "Decimal"
def type_of(boolean) when is_boolean(boolean), do: "Boolean"
def type_of(nil) when is_nil(nil), do: "Null"
def type_of(integer) when is_integer(integer), do: "Integer"
def type_of(float) when is_float(float), do: "Float"
def type_of(binary) when is_binary(binary), do: "String"
def type_of(map) when is_map(map), do: "Map"
def type_of(list) when is_list(list),
do: "List with values " <> Enum.map_join(list, ", ", &type_of/1)
def stringify(%{"__value__" => value}), do: Expression.stringify(value)
def stringify(value), do: Expression.stringify(value)
def get_existing_docstring(module) do
case Module.get_attribute(module, :doc) do
{line_number, doc} -> {line_number, doc}
nil -> {0, nil}
end
end
def format_result(%{"__value__" => value} = result) when is_map(result) do
other_fields =
result
|> Map.drop(["__value__"])
|> Enum.map(fn {key, value} ->
"* *#{key}* of type **#{type_of(value)}**"
end)
"""
complex **#{type_of(value)}** type of default value:
```elixir
#{inspect(value)}
```
with the following fields:\n\n#{Enum.join(other_fields, "\n")}
"""
end
def format_result(result), do: " value of type **#{type_of(result)}**: `#{inspect(result)}`"
def format_context(nil), do: "."
def format_context(context) do
"""
when used with the following context:
```elixir
#{inspect(context)}
```
"""
end
def format_function_name(name) do
name = to_string(name)
cond do
String.ends_with?(name, "_vargs") -> {String.trim_trailing(name, "_vargs"), :vargs}
String.ends_with?(name, "_") -> {String.trim_trailing(name, "_"), :reserved}
true -> {name, :direct}
end
end
def format_function_args(args) do
args
|> Enum.map(&elem(&1, 0))
|> Enum.reject(&(&1 in [:ctx, :_ctx]))
|> Enum.map(&to_string/1)
end
def format_docs(docs) do
Enum.map(docs, &Enum.into(&1, %{}))
end
defmacro __before_compile__(_env) do
quote do
@doc """
Return a list of all functions annotated with @expression_docs
"""
def expression_docs do
Enum.reverse(@expression_docs)
end
end
end
end