defmodule ESpec.DocExample do
@moduledoc """
Defines the 'extract' method with parse module content and return `%ESpec.DocExample{}` structs.
The struct is used by 'ESpec.DocTest' module to build the specs.
"""
@doc """
DocExample struct:
lhs - console input,
rhs - console output,
fun_arity - {fun, arity} tuple,
line - line where function is definde,
type - define the doc spec type (:test, :error or :inspect).
Read 'ESpec.DocTest' doc for more info.
"""
defstruct lhs: nil, rhs: nil, fun_arity: nil, line: nil, type: :test
defmodule(Error, do: defexception([:message]))
@doc "Extract module docs and returns a list of %ESpec.DocExample{} structs"
def extract(module) do
case apply(Code, :fetch_docs, [module]) do
{:docs_v1, anno, _, _, moduledoc, _, docs} ->
moduledocs = extract_from_moduledoc(anno, moduledoc)
docs =
for doc <- Enum.sort(docs),
doc <- extract_from_doc(doc),
do: doc
(moduledocs ++ docs)
|> Enum.flat_map(&to_struct/1)
{:error, reason} ->
raise Error,
module: module,
message:
"could not retrieve the documentation for module #{inspect(module)}. Reason: #{reason}"
end
end
def to_struct(%{exprs: list, fun_arity: fun_arity, line: line}) do
Enum.map(list, &item_to_struct(&1, fun_arity, line))
end
defp item_to_struct({lhs, {:test, rhs}}, fun_arity, line) do
%__MODULE__{
lhs: String.trim(lhs),
rhs: String.trim(rhs),
fun_arity: fun_arity,
line: line,
type: :test
}
end
defp item_to_struct({lhs, {:error, error_module, error_message}}, fun_arity, line) do
%__MODULE__{
lhs: String.trim(lhs),
rhs: {error_module, error_message},
fun_arity: fun_arity,
line: line,
type: :error
}
end
defp item_to_struct({lhs, {:inspect, string}}, fun_arity, line) do
%__MODULE__{
lhs: String.trim(lhs),
rhs: string,
fun_arity: fun_arity,
line: line,
type: :inspect
}
end
defp extract_from_moduledoc(_, doc) when doc in [:none, :hidden], do: []
defp extract_from_moduledoc(anno, %{"en" => doc}) do
for test <- extract_tests(:erl_anno.line(anno), doc) do
%{test | fun_arity: {:moduledoc, 0}}
end
end
defp extract_from_moduledoc(_anno, %{}), do: []
defp extract_from_doc({{kind, _, _}, _, _, doc, _})
when kind not in [:function, :macro] or doc in [:none, :hidden],
do: []
defp extract_from_doc({{_, name, arity}, anno, _, %{"en" => doc}, _}) do
line = :erl_anno.line(anno)
for test <- extract_tests(line, doc) do
%{test | fun_arity: {name, arity}}
end
end
defp extract_from_doc({_, _, _, _, doc}) when doc in [false, nil], do: []
defp extract_from_doc({fa, line, _, _, doc}) do
for test <- extract_tests(line, doc) do
%{test | fun_arity: fa}
end
end
defp extract_tests(line, doc) do
lines = String.split(doc, "\n", trim: false) |> adjust_indent
extract_tests(lines, line, "", "", [], true)
end
defp adjust_indent(lines) do
adjust_indent(lines, [], 0, :text)
end
defp adjust_indent([], adjusted_lines, _indent, _) do
Enum.reverse(adjusted_lines)
end
@iex_prompt ["iex>", "iex("]
@dot_prompt ["...>", "...("]
defp adjust_indent([line | rest], adjusted_lines, indent, :text) do
case String.starts_with?(String.trim_leading(line), @iex_prompt) do
true -> adjust_indent([line | rest], adjusted_lines, get_indent(line, indent), :prompt)
false -> adjust_indent(rest, adjusted_lines, indent, :text)
end
end
defp adjust_indent([line | rest], adjusted_lines, indent, check)
when check in [:prompt, :after_prompt] do
stripped_line = strip_indent(line, indent)
case String.trim_leading(line) do
"" ->
raise Error, message: "expected non-blank line to follow iex> prompt"
^stripped_line ->
:ok
_ ->
raise Error,
message:
"indentation level mismatch: #{inspect(line)}, should have been #{indent} spaces"
end
if String.starts_with?(stripped_line, @iex_prompt ++ @dot_prompt) do
adjust_indent(rest, [stripped_line | adjusted_lines], indent, :after_prompt)
else
next = if check == :prompt, do: :after_prompt, else: :code
adjust_indent(rest, [stripped_line | adjusted_lines], indent, next)
end
end
defp adjust_indent([line | rest], adjusted_lines, indent, :code) do
stripped_line = strip_indent(line, indent)
cond do
stripped_line == "" ->
adjust_indent(rest, [stripped_line | adjusted_lines], 0, :text)
String.starts_with?(String.trim_leading(line), @iex_prompt) ->
adjust_indent([line | rest], adjusted_lines, indent, :prompt)
true ->
adjust_indent(rest, [stripped_line | adjusted_lines], indent, :code)
end
end
defp get_indent(line, current_indent) do
case Regex.run(~r/iex/, line, return: :index) do
[{pos, _len}] -> pos
nil -> current_indent
end
end
defp strip_indent(line, indent) do
length = byte_size(line) - indent
if length > 0 do
:binary.part(line, indent, length)
else
""
end
end
defp extract_tests([], _line, "", "", [], _) do
[]
end
defp extract_tests([], _line, "", "", acc, _) do
Enum.reverse(reverse_last_test(acc))
end
# End of input and we've still got a test pending.
defp extract_tests([], _, expr_acc, expected_acc, [test = %{exprs: exprs} | t], _) do
test = %{test | exprs: [{expr_acc, {:test, expected_acc}} | exprs]}
Enum.reverse(reverse_last_test([test | t]))
end
# We've encountered the next test on an adjacent line. Put them into one group.
defp extract_tests(
[<<"iex>", _::binary>> | _] = list,
line,
expr_acc,
expected_acc,
[test = %{exprs: exprs} | t],
newtest
)
when expr_acc != "" and expected_acc != "" do
test = %{test | exprs: [{expr_acc, {:test, expected_acc}} | exprs]}
extract_tests(list, line, "", "", [test | t], newtest)
end
# Store expr_acc and start a new test case.
defp extract_tests([<<"iex>", string::binary>> | lines], line, "", expected_acc, acc, true) do
acc = reverse_last_test(acc)
test = %{line: line, fun_arity: nil, exprs: []}
extract_tests(lines, line, string, expected_acc, [test | acc], false)
end
# Store expr_acc.
defp extract_tests([<<"iex>", string::binary>> | lines], line, "", expected_acc, acc, false) do
extract_tests(lines, line, string, expected_acc, acc, false)
end
# Still gathering expr_acc. Synonym for the next clause.
defp extract_tests(
[<<"iex>", string::binary>> | lines],
line,
expr_acc,
expected_acc,
acc,
newtest
) do
extract_tests(lines, line, expr_acc <> "\n" <> string, expected_acc, acc, newtest)
end
# Still gathering expr_acc. Synonym for the previous clause.
defp extract_tests(
[<<"...>", string::binary>> | lines],
line,
expr_acc,
expected_acc,
acc,
newtest
)
when expr_acc != "" do
extract_tests(lines, line, expr_acc <> "\n" <> string, expected_acc, acc, newtest)
end
# Expression numbers are simply skipped.
defp extract_tests(
[<<"iex(", _::8, string::binary>> | lines],
line,
expr_acc,
expected_acc,
acc,
newtest
) do
extract_tests(
["iex" <> skip_iex_number(string) | lines],
line,
expr_acc,
expected_acc,
acc,
newtest
)
end
# Expression numbers are simply skipped redux.
defp extract_tests(
[<<"...(", _::8, string::binary>> | lines],
line,
expr_acc,
expected_acc,
acc,
newtest
) do
extract_tests(
["..." <> skip_iex_number(string) | lines],
line,
expr_acc,
expected_acc,
acc,
newtest
)
end
# Skip empty or documentation line.
defp extract_tests([_ | lines], line, "", "", acc, _) do
extract_tests(lines, line, "", "", acc, true)
end
# Encountered an empty line, store pending test
defp extract_tests(["" | lines], line, expr_acc, expected_acc, [test = %{exprs: exprs} | t], _) do
test = %{test | exprs: [{expr_acc, {:test, expected_acc}} | exprs]}
extract_tests(lines, line, "", "", [test | t], true)
end
# Exception test.
defp extract_tests(
[<<"** (", string::binary>> | lines],
line,
expr_acc,
"",
[test = %{exprs: exprs} | t],
newtest
) do
test = %{test | exprs: [{expr_acc, extract_error(string, "")} | exprs]}
extract_tests(lines, line, "", "", [test | t], newtest)
end
# Finally, parse expected_acc.
defp extract_tests(
[expected | lines],
line,
expr_acc,
expected_acc,
[test = %{exprs: exprs} | t] = acc,
newtest
) do
if expected =~ ~r/^#[A-Z][\w\.]*<.*>$/ do
expected = expected_acc <> "\n" <> inspect(expected)
test = %{test | exprs: [{expr_acc, {:inspect, expected}} | exprs]}
extract_tests(lines, line, "", "", [test | t], newtest)
else
extract_tests(lines, line, expr_acc, expected_acc <> "\n" <> expected, acc, newtest)
end
end
defp extract_error(<<")", t::binary>>, acc) do
{:error, Module.concat([acc]), String.trim(t)}
end
defp extract_error(<<h, t::binary>>, acc) do
extract_error(t, <<acc::binary, h>>)
end
defp skip_iex_number(<<")", ">", string::binary>>) do
">" <> string
end
defp skip_iex_number(<<_::8, string::binary>>) do
skip_iex_number(string)
end
defp reverse_last_test([]), do: []
defp reverse_last_test([test = %{exprs: exprs} | t]) do
test = %{test | exprs: Enum.reverse(exprs)}
[test | t]
end
end