Current section
Files
Jump to
Current section
Files
lib/harnais/helper/helper.ex
defmodule Harnais.Helper do
@moduledoc ~S"""
`Harnais.Helper` is a collection of low-level test helpers, especially for doctests.
## Documentation Terms
In the documentation these terms, usually in *italics*, are used to mean the same thing.
### *form* and *forms*
A *form* is a quoted form (`Macro.t`). A *forms* is a list of zero, one or more *form*s.
## Bang Functions
The bang peers for the visible functions
(e.g. `harnais_helper_format_forms/2`) are now
invisible (`@doc false`) to avoid clutter in the function list.
"""
use Harnais.Helper.Attribute
import Harnais.Utility,
only: [
map_collate0_enum: 2,
opts_create_aliases_dict: 1,
opts_canonical_keys: 2,
form_validate: 1,
forms_validate: 1,
forms_normalise: 1,
forms_reduce: 1,
list_wrap_flat_just: 1
]
@type error :: Harnais.struct()
@harnais_helper_eval_form_kvs_aliases [
{@harnais_helper_key_forms_collection, [:forms]},
{@harnais_helper_key_dictionary, nil},
{@harnais_helper_key_transform, nil},
{@harnais_helper_key_binding, nil},
{@harnais_helper_key_expect_value, [:compare_value, :result, :compare_result]},
{@harnais_helper_key_expect_text, [:text, :compare_text]},
{@harnais_helper_key_expect_texts, [:texts, :compare_texts]},
{@harnais_helper_key_expect_form, [:compare_form, :expect_ast, :compare_ast]},
{@harnais_helper_key_expect_error, [:compare_error, :error]}
]
@harnais_helper_eval_form_dict_aliases @harnais_helper_eval_form_kvs_aliases
|> opts_create_aliases_dict
@doc false
@since "0.1.0"
def harnais_helper_eval_form_opts_canon_keys!(
opts,
dict \\ @harnais_helper_eval_form_dict_aliases
) do
with {:ok, opts} <- opts |> opts_canonical_keys(dict) do
opts
else
{:error, %{__struct__: _} = error} -> raise error
end
end
@doc false
@since "0.1.0"
def harnais_helper_eval_form_normalise_error(error, opts \\ [])
def harnais_helper_eval_form_normalise_error(error, _opts) do
cond do
is_binary(error) -> error
Exception.exception?(error) -> error |> Exception.message()
is_atom(error) -> error |> to_string
true -> error |> inspect
end
end
@doc false
@since "0.1.0"
def harnais_helper_eval_form_compare(actual, expect_key, opts \\ [])
def harnais_helper_eval_form_compare(actual, @harnais_helper_key_expect_text = expect_key, opts)
when is_list(opts) do
with {:ok, actual_clean} <- actual |> harnais_helper_clean_text do
# any result to compare?
case opts |> Keyword.has_key?(expect_key) do
true ->
with {:ok, expect_clean} <- opts |> Keyword.get(expect_key) |> harnais_helper_clean_text do
case expect_clean == actual_clean do
true ->
{:ok, {actual_clean, actual}}
_ ->
{:error,
%ArgumentError{
message:
"mismatch; expect_key #{inspect(expect_key)}; expect_text: #{
inspect(expect_clean)
}; actual_text: #{inspect(actual_clean)}"
}}
end
else
{:error, %{__exception__: true}} = result -> result
end
# nothing to do
_ ->
{:ok, {actual_clean, actual}}
end
else
{:error, %{__exception__: true}} = result -> result
end
end
def harnais_helper_eval_form_compare(ast, @harnais_helper_key_expect_texts = expect_key, opts)
when is_list(opts) do
with {:ok, actual_texts} <- ast |> helper_asts_clean_text do
# any result to compare?
case opts |> Keyword.has_key?(expect_key) do
true ->
with {:ok, expect_texts} <- opts |> Keyword.get(expect_key) |> helper_asts_clean_text do
case expect_texts == actual_texts do
true ->
{:ok, {actual_texts, ast}}
_ ->
{:error,
%ArgumentError{
message:
"mismatch; expect_key #{inspect(expect_key)}; expect_texts: #{
inspect(expect_texts)
}; actual_texts: #{inspect(actual_texts)}"
}}
end
else
{:error, %{__exception__: true}} = result -> result
end
# nothing to do
_ ->
{:ok, {actual_texts, ast}}
end
else
{:error, %{__exception__: true}} = result -> result
end
end
def harnais_helper_eval_form_compare(ast, @harnais_helper_key_expect_form = expect_key, opts)
when is_list(opts) do
# need to reduce the ast(s) first and eval that.
with {:ok, actual_form} <- ast |> forms_reduce do
actual_text = actual_form |> Macro.to_string()
# anything to compare?
case opts |> Keyword.has_key?(expect_key) do
true ->
expect_form = opts |> Keyword.get(expect_key)
case expect_form == actual_form do
true ->
{:ok, {actual_text, actual_form}}
_ ->
{:error,
%ArgumentError{
message:
"mismatch; expect_key #{inspect(expect_key)}; expect_form: #{
inspect(expect_form)
}; actual_form: #{inspect(actual_form)}"
}}
end
# nothing to do
_ ->
{:ok, {actual_text, actual_form}}
end
else
{:error, %{__exception__: true}} = result -> result
end
end
def harnais_helper_eval_form_compare(ast, @harnais_helper_key_binding = expect_key, opts)
when is_atom(expect_key) and is_list(opts) do
case opts |> Keyword.has_key?(expect_key) do
x when x in [nil, false] ->
{:ok, {nil, nil, ast}}
_ ->
binding = opts |> Keyword.get(expect_key)
# need to reduce the ast(s) first and eval that.
with {:ok, actual_form} <- ast |> forms_reduce,
{:ok, actual_text} <- actual_form |> harnais_helper_clean_text,
{actual_value, _binding} <- actual_form |> Code.eval_quoted(binding, __ENV__) do
# any compares?
with {:ok, _} <-
harnais_helper_eval_form_compare(
actual_value,
@harnais_helper_key_expect_value,
opts
),
{:ok, _} <-
harnais_helper_eval_form_compare(
actual_text,
@harnais_helper_key_expect_text,
opts
),
{:ok, _} <-
harnais_helper_eval_form_compare(
actual_form,
@harnais_helper_key_expect_form,
opts
) do
# compares ok
{:ok, {actual_value, [actual_text], actual_form}}
else
{:error, %{__exception__: true}} = result -> result
end
else
error -> {:error, %ArgumentError{message: "eval_failed; reason: #{inspect(error)}"}}
end
end
end
def harnais_helper_eval_form_compare(actual, expect_key, opts)
when is_atom(expect_key) and is_list(opts) do
# any result to compare?
case opts |> Keyword.has_key?(expect_key) do
true ->
expect = opts |> Keyword.get(expect_key)
case expect == actual do
true ->
{:ok, actual}
_ ->
{:error,
%ArgumentError{
message:
"mismatch; expect_key #{inspect(expect_key)}; expect_tests: #{inspect(expect)}; actual: #{
inspect(actual)
}"
}}
end
# nothing to do
_ ->
{:ok, actual}
end
end
@doc false
@since "0.1.0"
# returns {:ok, {result, texts, ast}} or {:error, error}.
# the returned ast will be the reduced one used for eval i.e. after any transforms.
# if no binding the result will be nil.
def harnais_helper_eval_form(ast, opts \\ [])
def harnais_helper_eval_form(nil, _opts) do
{:ok, {nil, [""], nil}}
end
def harnais_helper_eval_form(ast, opts) when is_list(opts) do
opts = opts |> harnais_helper_eval_form_opts_canon_keys!
with {:ok, ast} <- ast |> harnais_helper_transform(opts),
{:ok, {actual_texts, ast}} <-
ast |> harnais_helper_eval_form_compare(@harnais_helper_key_expect_texts, opts),
{:ok, {actual_value, _actual_text, actual_form}} <-
ast |> harnais_helper_eval_form_compare(@harnais_helper_key_binding, opts) do
{:ok, {actual_value, actual_texts, actual_form}}
else
# if an error and matches the expected error then return {:ok, {actual_error, nil, nil}}
{:error, error} ->
case opts |> Keyword.has_key?(@harnais_helper_key_expect_error) do
true ->
error
|> harnais_helper_eval_form_normalise_error
|> harnais_helper_eval_form_compare(@harnais_helper_key_expect_error, opts)
|> case do
# expected error matches
{:ok, actual_error} ->
{:ok, {actual_error, nil, nil}}
_ ->
{:error, error}
end
# passthru
_ ->
{:error, error}
end
end
end
@doc false
@since "0.1.0"
def harnais_helper_transform(ast, opts \\ [])
def harnais_helper_transform(ast, []) do
{:ok, ast}
end
def harnais_helper_transform(ast, opts) do
fun_transform =
cond do
Keyword.has_key?(opts, @harnais_helper_key_transform) ->
Keyword.get(opts, @harnais_helper_key_transform)
# default is to anonymise vars
true ->
&harnais_helper_transform_anonymise_vars/1
end
|> list_wrap_flat_just
|> case do
[transform] ->
transform
transforms ->
fn ast -> transforms |> Enum.reduce(ast, fn f, s -> f.(s) end) end
end
{:ok, fun_transform.(ast)}
end
@doc false
@since "0.1.0"
def harnais_helper_transform_anonymise_vars(ast) do
ast
|> Macro.postwalk(fn
{name, [], mod} when is_atom(name) and is_atom(mod) -> Macro.var(name, nil)
x -> x
end)
end
@code_edits_beg [
# dump too many leading or trailing space after \n
{~r/\A\n\s\s*/s, "\n "},
{~r/\n\s+\z/s, "\n"}
]
@code_edits_mid [
{~r/ case /, " case() "}
]
@code_edits_fin [
{~r/\-\>\n/, "-> "},
{~r/\n\|\>/, " |>"},
# dump too many leading or trailing space after \n
{~r/\A\n\s\s*/s, "\n "},
{~r/\n\s+\z/s, "\n"},
# tidy front
{~r/\A\(\n\s+/s, "("},
# tidy back
{~r/\n\s+\)\z/s, ")"}
]
defp harnais_helper_edits(code, edits) when is_binary(code) do
edits
|> Enum.reduce(code, fn {r, v}, s ->
Regex.replace(r, s, v)
end)
end
@doc false
@since "0.1.0"
def harnais_helper_clean_text(code)
def harnais_helper_clean_text(code) when is_binary(code) do
text =
code
|> harnais_helper_edits(@code_edits_beg)
|> String.split("\n")
|> Enum.reject(fn str -> String.length(str) == 0 end)
|> Enum.map(&String.trim/1)
|> Enum.map(fn str ->
str |> harnais_helper_edits(@code_edits_mid)
end)
|> Enum.reject(fn str -> String.length(str) == 0 end)
|> Enum.join("\n ")
|> (fn code ->
code |> harnais_helper_edits(@code_edits_fin)
end).()
{:ok, text}
end
def harnais_helper_clean_text(code) do
with {:ok, ast} <- code |> form_validate do
ast |> Macro.to_string() |> harnais_helper_clean_text
else
{:error, %{__exception__: true}} = result -> result
end
end
@doc false
@since "0.1.0"
def helper_asts_clean_text(asts) do
asts
|> List.wrap()
|> Enum.reject(&is_nil/1)
|> Enum.reduce_while(
[],
fn ast, texts ->
case ast |> harnais_helper_clean_text do
{:ok, text} -> {:cont, [text | texts]}
{:error, %{__struct__: _}} = result -> {:halt, result}
end
end
)
|> case do
{:error, %{__exception__: true}} = result -> result
texts -> {:ok, texts |> Enum.reverse()}
end
end
@doc false
@since "0.1.0"
def harnais_helper_compare_form(actual, expect) do
with {:ok, actual_code} <- actual |> harnais_helper_clean_text,
{:ok, expect_code} <- expect |> harnais_helper_clean_text do
case Kernel.==(actual_code, expect_code) do
true ->
{:ok, actual}
_ ->
{:error,
%ArgumentError{
message:
"expect_actual_mismatch; expect_code #{inspect(expect_code)}; actual_code #{
inspect(actual_code)
}"
}}
end
else
{:error, %{__exception__: true}} = result -> result
end
end
@doc false
@since "0.1.0"
def harnais_helper_compare_form!(actual_code, expect_code) do
with {:ok, actual} <- harnais_helper_compare_form(actual_code, expect_code) do
actual
else
{:error, error} -> raise error
end
end
@doc ~S"""
`harnais_helper_show_forms/1` takes a *forms*, normalises
(`Harnais.Utility.forms_normalise/1`) them, converts each form to a text
string (`Macro.to_string/1`), "cleans" each text string and returns
`{:ok, texts}`.
## Examples
iex> [quote(do: x = x + 1),
...> quote(do: x = x * x ),
...> quote(do: x=x-1 )
...> ] |> harnais_helper_show_forms
{:ok, ["x = x + 1", "x = x * x", "x = x - 1"]}
iex> [quote(do: x = x + 1),
...> quote(do: x = x * x),
...> quote(do: x = x - 1)
...> ] |> harnais_helper_show_forms!
["x = x + 1", "x = x * x", "x = x - 1"]
"""
@since "0.1.0"
@spec harnais_helper_show_forms(any, any) :: {:ok, [String.t()]} | {:error, error}
def harnais_helper_show_forms(forms, _opts \\ []) do
with {:ok, forms} <- forms |> forms_validate do
forms
|> map_collate0_enum(&harnais_helper_clean_text/1)
else
{:error, %{__exception__: true}} = result -> result
end
end
@doc false
@since "0.1.0"
@spec harnais_helper_show_forms!(any, any) :: [String.t()] | no_return
def harnais_helper_show_forms!(forms, opts \\ []) do
with {:ok, forms} <- forms |> harnais_helper_show_forms(opts) do
forms
else
{:error, error} -> raise error
end
end
@doc ~S"""
`harnais_helper_format_forms/1` takes a *forms*, and optional *opts*, and formats each *form*.
The *forms* are first normalised (`Harnais.Utility.forms_normalise/1`) and
each converted to a text string (`Macro.to_string/1`).
Each text string is passed, together with the *opts*, to
the Elixir code formatter (`Code.format_string!/2`).
The returned iodata from the formatter is joined (`Enum.join/1`) and split on line breaks (`\n`)
returning `{:ok, lines}`.
## Examples
iex> [quote(do: x = x + 1),
...> quote(do: x = x * x ),
...> quote(do: x=x-1 )
...> ] |> harnais_helper_format_forms
{:ok, ["x = x + 1", "x = x * x", "x = x - 1"]}
iex> [quote(do: x = x + 1),
...> quote(do: x = x * x),
...> quote(do: x = x - 1)
...> ] |> harnais_helper_format_forms!
["x = x + 1", "x = x * x", "x = x - 1"]
iex> quote do
...> def what_is_x(x) do
...> x
...> |> case do
...> x when is_atom(x) -> :atom
...> x when is_binary(x) ->
...> :string
...> x when is_integer(x) ->
...> # x is an integer
...> :integer
...> _ -> :no_idea
...> end
...> end
...> end
...> |> harnais_helper_format_forms!
["def(what_is_x(x)) do",
" x",
" |> case do",
" x when is_atom(x) ->",
" :atom",
"",
" x when is_binary(x) ->",
" :string",
"",
" x when is_integer(x) ->",
" :integer",
"",
" _ ->",
" :no_idea",
" end",
"end"]
"""
@since "0.1.0"
@spec harnais_helper_format_forms(any, any) :: {:ok, [String.t()]} | {:error, error}
def harnais_helper_format_forms(forms, opts \\ []) do
with {:ok, forms} <- forms |> forms_normalise do
forms =
forms
|> Enum.flat_map(fn form ->
form
|> Macro.to_string()
|> Code.format_string!(opts)
|> Enum.join()
|> String.split("\n")
end)
{:ok, forms}
else
{:error, %{__exception__: true}} = result -> result
end
end
@doc false
@since "0.1.0"
@spec harnais_helper_format_forms!(any, any) :: [String.t()] | no_return
def harnais_helper_format_forms!(forms, opts \\ []) do
with {:ok, forms} <- forms |> harnais_helper_format_forms(opts) do
forms
else
{:error, error} -> raise error
end
end
@doc false
@doc ~S"""
`harnais_helper_test_forms/1` takes a *forms* and an *opts* with a *binding*.
The *forms* are normalised (`Harnais.Utility.forms_normalise/1`) and
evaluated with the *binding* (`Code.eval_quoted/3`) to generate the `eval_result`.
The *forms* are passed through `harnais_helper_show_forms/1` to generate the `forms_texts`.
The result of both are combined to returns `{:ok, {eval_result, forms_texts}}`.
## Examples
iex> [quote(do: x = x + 1),
...> quote(do: x = x * x),
...> quote(do: x = x - 1)
...> ] |> harnais_helper_test_forms(binding: [x: 7])
{:ok, {63, ["x = x + 1", "x = x * x", "x = x - 1"]}}
iex> [quote(do: x = x + 1),
...> quote(do: x = x * x ),
...> quote(do: x=x-1 )
...> ] |> harnais_helper_test_forms(binding: [x: 1])
{:ok, {3, ["x = x + 1", "x = x * x", "x = x - 1"]}}
"""
@since "0.1.0"
@spec harnais_helper_test_forms(any, any) :: {:ok, {any, [String.t()]}} | {:error, error}
def harnais_helper_test_forms(forms, opts \\ []) do
with {:ok, {eval_value, text_forms, _reduced_form}} <-
forms
|> harnais_helper_eval_form(opts) do
{:ok, {eval_value, text_forms}}
else
{:error, %{__exception__: true}} = result -> result
end
end
@doc false
@since "0.1.0"
@spec harnais_helper_test_forms!(any, any) :: {any, [String.t()]} | no_return
def harnais_helper_test_forms!(forms, opts \\ []) do
forms
|> harnais_helper_test_forms(opts)
|> case do
{:ok, {result, text_forms}} -> {result, text_forms}
{:error, error} -> raise error
end
end
@doc false
@since "0.1.0"
def harnais_helper_test_forms_result_texts(forms, opts \\ []) do
with {:ok, {result, text_forms, _reduced_form}} <- forms |> harnais_helper_test_forms(opts) do
{:ok, {result, text_forms}}
else
{:error, %{__exception__: true}} = result -> result
end
end
@doc false
@since "0.1.0"
def harnais_helper_test_forms_texts(forms, opts \\ []) do
with {:ok, {_result, text_forms, _reduced_form}} <- forms |> harnais_helper_test_forms(opts) do
{:ok, text_forms}
else
{:error, %{__exception__: true}} = result -> result
end
end
@doc false
@since "0.1.0"
defmacro __using__(_opts \\ []) do
quote do
import Harnais.Helper
end
end
end