Current section
Files
Jump to
Current section
Files
lib/hammox.ex
defmodule Hammox do
@moduledoc """
Hammox is a library for rigorous unit testing using mocks, explicit
behaviours and contract tests.
See the [README](readme.html) page for usage guide and examples.
Most of the functions in this module come from
[Mox](https://hexdocs.pm/mox/Mox.html) for backwards compatibility. As of
v0.1.0, the only Hammox-specific functions are `protect/2` and `protect/3`.
"""
alias Hammox.Utils
defmodule TypeMatchError do
@moduledoc false
defexception [:message]
@impl true
def exception({:error, reasons}) do
%__MODULE__{
message: "\n" <> message_string(reasons)
}
end
defp human_reason({:arg_type_mismatch, name, index, value, type}) do
"#{Ordinal.ordinalize(index + 1)} argument value #{inspect(value)} does not match #{
Ordinal.ordinalize(index + 1)
} parameter#{if name, do: " \"" <> to_string(name) <> "\""}'s type #{type_to_string(type)}."
end
defp human_reason({:return_type_mismatch, value, type}) do
"Returned value #{inspect(value)} does not match type #{type_to_string(type)}."
end
defp human_reason({:tuple_elem_type_mismatch, index, elem, elem_type}) do
"#{Ordinal.ordinalize(index + 1)} tuple element #{inspect(elem)} does not match #{
Ordinal.ordinalize(index + 1)
} element type #{type_to_string(elem_type)}."
end
defp human_reason({:elem_type_mismatch, index, elem, elem_type}) do
"Element #{inspect(elem)} at index #{index} does not match element type #{
type_to_string(elem_type)
}."
end
defp human_reason({:empty_list_type_mismatch, type}) do
"Got an empty list but expected #{type_to_string(type)}."
end
defp human_reason({:proper_list_type_mismatch, type}) do
"Got a proper list but expected #{type_to_string(type)}."
end
defp human_reason({:improper_list_type_mismatch, type}) do
"Got an improper list but expected #{type_to_string(type)}."
end
defp human_reason({:improper_list_terminator_type_mismatch, terminator, terminator_type}) do
"Improper list terminator #{inspect(terminator)} does not match terminator type #{
type_to_string(terminator_type)
}."
end
defp human_reason({:function_arity_type_mismatch, expected, actual}) do
"Expected function to have arity #{expected} but got #{actual}."
end
defp human_reason({:type_mismatch, value, type}) do
"Value #{inspect(value)} does not match type #{type_to_string(type)}."
end
defp human_reason({:map_key_type_mismatch, key, key_types}) when is_list(key_types) do
"Map key #{inspect(key)} does not match any of the allowed map key types #{
key_types
|> Enum.map(&type_to_string/1)
|> Enum.join(", ")
}."
end
defp human_reason({:map_key_type_mismatch, key, key_type}) do
"Map key #{inspect(key)} does not match map key type #{type_to_string(key_type)}."
end
defp human_reason({:map_value_type_mismatch, key, value, value_types})
when is_list(value_types) do
"Map value #{inspect(value)} for key #{inspect(key)} does not match any of the allowed map value types #{
value_types
|> Enum.map(&type_to_string/1)
|> Enum.join(", ")
}."
end
defp human_reason({:map_value_type_mismatch, key, value, value_type}) do
"Map value #{inspect(value)} for key #{inspect(key)} does not match map value type #{
type_to_string(value_type)
}."
end
defp human_reason({:required_field_unfulfilled_map_type_mismatch, entry_type}) do
"Could not find a map entry matching #{type_to_string(entry_type)}."
end
defp human_reason({:struct_name_type_mismatch, expected_struct_name}) do
"Expected the value to be #{Utils.module_to_string(expected_struct_name)} struct."
end
defp human_reason({:module_fetch_failure, module_name}) do
"Could not load module #{Utils.module_to_string(module_name)}."
end
defp human_reason({:remote_type_fetch_failure, {module_name, type_name, arity}}) do
"Could not find type #{type_name}/#{arity} in #{Utils.module_to_string(module_name)}."
end
defp human_reason({:protocol_type_mismatch, value, protocol_name}) do
"Value #{inspect(value)} does not implement the #{protocol_name} protocol."
end
defp message_string(reasons) when is_list(reasons) do
reasons
|> Enum.zip(0..length(reasons))
|> Enum.map(fn {reason, index} ->
reason
|> human_reason()
|> leftpad(index)
end)
|> Enum.join("\n")
end
defp message_string(reason) when is_tuple(reason) do
message_string([reason])
end
defp leftpad(string, level) do
padding =
for(_ <- 0..level, do: " ")
|> Enum.drop(1)
|> Enum.join()
padding <> string
end
defp type_to_string({:type, _, :map_field_exact, [type1, type2]}) do
"required(#{type_to_string(type1)}) => #{type_to_string(type2)}"
end
defp type_to_string({:type, _, :map_field_assoc, [type1, type2]}) do
"optional(#{type_to_string(type1)}) => #{type_to_string(type2)}"
end
defp type_to_string(type) do
# We really want to access Code.Typespec.typespec_to_quoted/1 here but it's
# private... this hack needs to suffice.
[_, type_string] =
{:foo, type, []}
|> Code.Typespec.type_to_quoted()
|> Macro.to_string()
|> String.split(" :: ")
type_string
end
end
defmodule TypespecNotFoundError do
@moduledoc false
defexception [:message]
end
@doc """
See [Mox.allow/3](https://hexdocs.pm/mox/Mox.html#allow/3).
"""
def allow(mock, owner_pid, allowed_via) do
Mox.allow(mock, owner_pid, allowed_via)
end
@doc """
See [Mox.defmock/2](https://hexdocs.pm/mox/Mox.html#defmock/2).
"""
def defmock(name, options) do
Mox.defmock(name, options)
end
@doc """
See [Mox.expect/4](https://hexdocs.pm/mox/Mox.html#expect/4).
"""
def expect(mock, name, n \\ 1, code) do
arity = :erlang.fun_info(code)[:arity]
hammox_code =
case fetch_typespecs_for_mock(mock, name, arity) do
# This is really an error case where we're trying to mock a function
# that does not exist in the behaviour. Mox will flag it better though
# so just let it pass through.
[] -> code
typespecs -> protected(code, typespecs, arity)
end
Mox.expect(mock, name, n, hammox_code)
end
@doc """
See [Mox.set_mox_from_context/1](https://hexdocs.pm/mox/Mox.html#set_mox_from_context/1).
"""
def set_mox_from_context(context) do
Mox.set_mox_from_context(context)
end
@doc """
See [Mox.set_mox_global/1](https://hexdocs.pm/mox/Mox.html#set_mox_global/1).
"""
def set_mox_global(context \\ %{}) do
Mox.set_mox_global(context)
end
@doc """
See [Mox.set_mox_private/1](https://hexdocs.pm/mox/Mox.html#set_mox_private/1).
"""
def set_mox_private(context \\ %{}) do
Mox.set_mox_private(context)
end
@doc """
See [Mox.stub/3](https://hexdocs.pm/mox/Mox.html#stub/3).
"""
def stub(mock, name, code) do
Mox.stub(mock, name, code)
end
@doc """
See [Mox.stub_with/2](https://hexdocs.pm/mox/Mox.html#stub_with/2).
"""
def stub_with(mock, module) do
Mox.stub_with(mock, module)
end
@doc """
See [Mox.verify!/0](https://hexdocs.pm/mox/Mox.html#verify!/0).
"""
def verify!() do
Mox.verify!()
end
@doc """
See [Mox.verify!/1](https://hexdocs.pm/mox/Mox.html#verify!/1).
"""
def verify!(mock) do
Mox.verify!(mock)
end
@doc """
See [Mox.verify_on_exit!/1](https://hexdocs.pm/mox/Mox.html#verify_on_exit!/1).
"""
def verify_on_exit!(context \\ %{}) do
Mox.verify_on_exit!(context)
end
@doc since: "0.1.0"
@doc """
Takes the function provided by a module, function, arity tuple and
decorates it with Hammox type checking.
Returns a new anonymous function.
Example:
```elixir
defmodule Calculator do
@callback add(integer(), integer()) :: integer()
end
defmodule TestCalculator do
def add(a, b), do: a + b
end
add_2 = Hammox.protect({TestCalculator, :add, 2}, Calculator)
add_2.(1.5, 2.5) # throws Hammox.TypeMatchError
```
"""
@spec protect(mfa :: mfa(), behaviour_name :: module()) :: fun()
def protect(mfa, behaviour_name)
def protect({module_name, function_name, arity}, behaviour_name)
when is_atom(module_name) and is_atom(function_name) and is_integer(arity) and
is_atom(behaviour_name) do
code = {module_name, function_name}
typespecs = fetch_typespecs!(behaviour_name, function_name, arity)
protected(code, typespecs, arity)
end
@doc since: "0.1.0"
@doc """
Same as `protect/2`, but allows decorating multiple functions at the same
time.
Provide a list of functions to decorate as third argument.
Returns a map where the keys are atoms of the form
`:{function_name}_{arity}` and values are the decorated anonymous
functions.
Example:
```elixir
defmodule Calculator do
@callback add(integer(), integer()) :: integer()
@callback add(integer(), integer(), integer()) :: integer()
@callback multiply(integer(), integer()) :: integer()
end
defmodule TestCalculator do
def add(a, b), do: a + b
def add(a, b, c), do: a + b + c
def multiply(a, b), do: a * b
end
%{
add_2: add_2,
add_3: add_3,
multiply_2: multiply_2
} = Hammox.protect(TestCalculator, Calculator, add: [2, 3], multiply: 2)
```
"""
@spec protect(
module_name :: module(),
behaviour_name :: module(),
funs :: [{atom(), arity() | [arity()]}]
) ::
fun()
def protect(module_name, behaviour_name, funs)
when is_atom(module_name) and is_atom(behaviour_name) and is_list(funs) do
funs
|> Enum.map(fn
{function_name, arity} when is_integer(arity) -> {function_name, [arity]}
{function_name, arities} when is_list(arities) -> {function_name, arities}
end)
|> Enum.map(fn {function_name, arities} ->
Enum.map(arities, fn arity ->
key =
function_name
|> Atom.to_string()
|> Kernel.<>("_#{arity}")
|> String.to_atom()
value = protect({module_name, function_name, arity}, behaviour_name)
{key, value}
end)
end)
|> List.flatten()
|> Enum.into(%{})
end
defp protected(code, typespecs, 0) do
fn ->
protected_code(code, typespecs, [])
end
end
defp protected(code, typespecs, 1) do
fn arg1 ->
protected_code(code, typespecs, [arg1])
end
end
defp protected(code, typespecs, 2) do
fn arg1, arg2 ->
protected_code(code, typespecs, [arg1, arg2])
end
end
defp protected(code, typespecs, 3) do
fn arg1, arg2, arg3 ->
protected_code(code, typespecs, [arg1, arg2, arg3])
end
end
defp protected(code, typespecs, 4) do
fn arg1, arg2, arg3, arg4 ->
protected_code(code, typespecs, [arg1, arg2, arg3, arg4])
end
end
defp protected(code, typespecs, 5) do
fn arg1, arg2, arg3, arg4, arg5 ->
protected_code(code, typespecs, [arg1, arg2, arg3, arg4, arg5])
end
end
defp protected(code, typespecs, 6) do
fn arg1, arg2, arg3, arg4, arg5, arg6 ->
protected_code(code, typespecs, [arg1, arg2, arg3, arg4, arg5, arg6])
end
end
defp protected(code, typespecs, 7) do
fn arg1, arg2, arg3, arg4, arg5, arg6, arg7 ->
protected_code(code, typespecs, [arg1, arg2, arg3, arg4, arg5, arg6, arg7])
end
end
defp protected(code, typespecs, 8) do
fn arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8 ->
protected_code(code, typespecs, [arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8])
end
end
defp protected(code, typespecs, 9) do
fn arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9 ->
protected_code(code, typespecs, [arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9])
end
end
defp protected(_code, _typespec, arity) when arity > 9 do
raise "Hammox only supports protecting functions with arity up to 9. Why do you need over 9 parameters anyway?"
end
defp protected_code(code, typespecs, args) do
return_value =
case code do
{module_name, function_name} -> apply(module_name, function_name, args)
anonymous when is_function(anonymous) -> apply(anonymous, args)
end
check_call(args, return_value, typespecs)
return_value
end
defp check_call(args, return_value, typespecs) when is_list(typespecs) do
typespecs
|> Enum.reduce_while({:error, []}, fn typespec, {:error, reasons} = result ->
case match_call(args, return_value, typespec) do
:ok ->
{:halt, :ok}
{:error, new_reasons} = new_result ->
{:cont, if(length(reasons) >= length(new_reasons), do: result, else: new_result)}
end
end)
|> case do
{:error, _} = error -> raise TypeMatchError, error
:ok -> :ok
end
end
defp match_call(args, return_value, typespec) do
with :ok <- match_args(args, typespec),
:ok <- match_return_value(return_value, typespec) do
:ok
end
end
defp match_args([], _typespec) do
:ok
end
defp match_args(args, typespec) do
args
|> Enum.zip(0..(length(args) - 1))
|> Enum.map(fn {arg, index} ->
{arg_name, arg_type} = arg_typespec(typespec, index)
case match_type(arg, arg_type) do
{:error, reasons} ->
{:error, [{:arg_type_mismatch, arg_name, index, arg, arg_type} | reasons]}
:ok ->
:ok
end
end)
|> Enum.max_by(fn
{:error, reasons} -> length(reasons)
:ok -> 0
end)
end
defp match_return_value(return_value, typespec) do
{:type, _, :fun, [_, return_type]} = typespec
case match_type(return_value, return_type) do
{:error, reasons} ->
{:error, [{:return_type_mismatch, return_value, return_type} | reasons]}
:ok ->
:ok
end
end
defp fetch_typespecs!(behaviour_name, function_name, arity) do
case fetch_typespecs(behaviour_name, function_name, arity) do
[] ->
raise TypespecNotFoundError,
message:
"Could not find typespec for #{Utils.module_to_string(behaviour_name)}.#{
function_name
}/#{arity}."
typespecs ->
typespecs
end
end
defp fetch_typespecs(behaviour_module_name, function_name, arity) do
{:ok, callbacks} = Code.Typespec.fetch_callbacks(behaviour_module_name)
callbacks
|> Enum.find_value([], fn
{{^function_name, ^arity}, typespecs} -> typespecs
_ -> false
end)
|> Enum.map(fn typespec ->
replace_user_types(typespec, behaviour_module_name)
end)
end
defp fetch_typespecs_for_mock(mock_name, function_name, arity)
when is_atom(mock_name) and is_atom(function_name) and is_integer(arity) do
mock_name.__mock_for__()
|> Enum.map(fn behaviour ->
fetch_typespecs(behaviour, function_name, arity)
end)
|> List.flatten()
end
defp arg_typespec(function_typespec, arg_index) do
{:type, _, :fun, [{:type, _, :product, arg_typespecs}, _]} = function_typespec
case Enum.at(arg_typespecs, arg_index) do
{:ann_type, _, [{:var, _, arg_name}, arg_type]} -> {arg_name, arg_type}
{:type, _, _, _} = arg_type -> {nil, arg_type}
{:remote_type, _, _} = arg_type -> {nil, arg_type}
end
end
defp match_type(value, {:type, _, :union, union_types} = union) when is_list(union_types) do
results =
Enum.reduce_while(union_types, [], fn type, reason_stacks ->
case match_type(value, type) do
:ok -> {:halt, :ok}
{:error, reasons} -> {:cont, [reasons | reason_stacks]}
end
end)
case results do
:ok ->
:ok
reason_stacks ->
reason = {:type_mismatch, value, union}
biggest_stack = Enum.max_by(reason_stacks, &length/1)
reasons = if length(biggest_stack) == 1, do: [reason], else: [reason | biggest_stack]
{:error, reasons}
end
end
defp match_type(_value, {:type, _, :any, []}) do
:ok
end
defp match_type(value, {:type, _, :none, []} = type) do
type_mismatch(value, type)
end
defp match_type(value, {:type, _, :atom, []}) when is_atom(value) do
:ok
end
defp match_type(value, {:type, _, :atom, []} = type) do
type_mismatch(value, type)
end
defp match_type(value, {:type, _, :map, :any}) when is_map(value) do
:ok
end
defp match_type(value, {:type, _, :pid, []}) when is_pid(value) do
:ok
end
defp match_type(value, {:type, _, :pid, []} = type) do
type_mismatch(value, type)
end
defp match_type(value, {:type, _, :port, []}) when is_port(value) do
:ok
end
defp match_type(value, {:type, _, :port, []} = type) do
type_mismatch(value, type)
end
defp match_type(value, {:type, _, :reference, []}) when is_reference(value) do
:ok
end
defp match_type(value, {:type, _, :reference, []} = type) do
type_mismatch(value, type)
end
defp match_type(
%{__struct__: _},
{:remote_type, _, [{:atom, _, :elixir}, {:atom, _, :struct}, []]}
) do
:ok
end
defp match_type(value, {:remote_type, _, [{:atom, _, :elixir}, {:atom, _, :struct}, []]} = type) do
type_mismatch(value, type)
end
defp match_type(value, {:remote_type, 0, [{:atom, 0, :elixir}, {:atom, 0, :struct}, []]} = type) do
if Map.has_key?(value, :__struct__), do: :ok, else: type_mismatch(value, type)
end
defp match_type(value, {:type, _, :tuple, :any}) when is_tuple(value) do
:ok
end
defp match_type(value, {:type, _, :tuple, :any} = type) do
type_mismatch(value, type)
end
defp match_type(value, {:type, _, :tuple, tuple_types})
when is_tuple(value) and tuple_size(value) == length(tuple_types) do
error =
[Tuple.to_list(value), tuple_types, 0..(tuple_size(value) - 1)]
|> Enum.zip()
|> Enum.find_value(fn {elem, elem_type, index} ->
case match_type(elem, elem_type) do
:ok ->
nil
{:error, reasons} ->
{:error, [{:tuple_elem_type_mismatch, index, elem, elem_type} | reasons]}
end
end)
error || :ok
end
defp match_type(value, {:type, _, :tuple, _} = type) do
type_mismatch(value, type)
end
defp match_type(value, {:type, _, :float, []}) when is_float(value) do
:ok
end
defp match_type(value, {:type, _, :float, []} = type) do
type_mismatch(value, type)
end
defp match_type(value, {:type, _, :integer, []}) when is_integer(value) do
:ok
end
defp match_type(value, {:type, _, :integer, []} = type) do
type_mismatch(value, type)
end
defp match_type(value, {:type, _, :neg_integer, []}) when is_integer(value) and value < 0 do
:ok
end
defp match_type(value, {:type, _, :neg_integer, []} = type) do
type_mismatch(value, type)
end
defp match_type(value, {:type, _, :non_neg_integer, []})
when is_integer(value) and value >= 0 do
:ok
end
defp match_type(value, {:type, _, :non_neg_integer, []} = type) do
type_mismatch(value, type)
end
defp match_type(value, {:type, _, :pos_integer, []}) when is_integer(value) and value > 0 do
:ok
end
defp match_type(value, {:type, _, :pos_integer, []} = type) do
type_mismatch(value, type)
end
defp match_type([], {:type, _, :list, _}) do
:ok
end
defp match_type(value, {:type, _, :list, []}) when is_list(value) do
:ok
end
defp match_type(value, {:type, _, :list, [elem_typespec]}) when is_list(value) do
match_type(
value,
{:type, 0, :nonempty_list, [elem_typespec]}
)
end
defp match_type(value, {:type, _, :list, _} = type) do
type_mismatch(value, type)
end
defp match_type([_ | _], {:type, _, :nonempty_list, []}) do
:ok
end
defp match_type(value, {:type, _, :nonempty_list, []}) do
match_type(value, {:type, 0, :nonempty_list, [{:type, 0, :any}]})
end
defp match_type([], {:type, _, :nonempty_list, [_]} = type) do
{:error, [{:empty_list_type_mismatch, type}]}
end
defp match_type([_a | b], {:type, _, :nonempty_list, [_]} = type) when not is_list(b) do
{:error, [{:improper_list_type_mismatch, type}]}
end
defp match_type(value, {:type, _, :nonempty_list, [elem_typespec]}) when is_list(value) do
error =
value
|> Enum.zip(0..length(value))
|> Enum.find_value(fn {elem, index} ->
case match_type(elem, elem_typespec) do
{:error, reasons} ->
{:error, [{:elem_type_mismatch, index, elem, elem_typespec} | reasons]}
:ok ->
nil
end
end)
error || :ok
end
defp match_type(value, {:type, _, :nonempty_list, _} = type) do
type_mismatch(value, type)
end
defp match_type(value, {:type, _, :maybe_improper_list, [type1, type2]}) do
match_type(
value,
{:type, 0, :union,
[{:type, 0, :list, [type1]}, {:type, 0, :nonempty_improper_list, [type1, type2]}]}
)
end
defp match_type([], {:type, _, :nonempty_improper_list, [_type1, _type2]} = type) do
{:error, [{:empty_list_type_mismatch, type}]}
end
defp match_type([_ | []], {:type, _, :nonempty_improper_list, [_type1, _type2]} = type) do
{:error, [{:proper_list_type_mismatch, type}]}
end
defp match_type(list, {:type, _, :nonempty_improper_list, [_type1, _type2]} = type)
when is_list(list) do
match_improper_list_type(list, type, 0)
end
defp match_type(value, {:type, _, :nonempty_improper_list, _} = type) do
type_mismatch(value, type)
end
defp match_type(value, {:type, _, :nonempty_maybe_improper_list, [type1, type2]}) do
match_type(
value,
{:type, 0, :union,
[{:type, 0, :nonempty_list, [type1]}, {:type, 0, :nonempty_improper_list, [type1, type2]}]}
)
end
defp match_type(value, {:atom, _, atom}) when value == atom do
:ok
end
defp match_type(value, {:atom, _, _atom} = type) do
type_mismatch(value, type)
end
defp match_type(value, {:type, _, :binary, [{:integer, _, head_size}, {:integer, _, 0}]})
when is_bitstring(value) and bit_size(value) == head_size do
:ok
end
defp match_type(value, {:type, _, :binary, [{:integer, _, head_size}, {:integer, _, unit}]})
when is_bitstring(value) and rem(bit_size(value) - head_size, unit) == 0 do
:ok
end
defp match_type(
value,
{:type, _, :binary, [{:integer, _, _head_size}, {:integer, _, _unit}]} = type
) do
type_mismatch(value, type)
end
defp match_type(value, {:type, _, :fun, [{:type, _, :any}, _return_type]})
when is_function(value) do
:ok
end
defp match_type(value, {:type, _, :fun, [{:type, _, :product, param_types}, _return_type]})
when is_function(value) do
expected = length(param_types)
actual = :erlang.fun_info(value)[:arity]
if expected == actual do
:ok
else
{:error, [{:function_arity_type_mismatch, expected, actual}]}
end
end
defp match_type(value, {:type, _, :fun, []}) do
match_type(value, {:type, 0, :fun, [{:type, 0, :any}, {:type, 0, :any, []}]})
end
defp match_type(value, {:type, _, :fun, _} = type) do
type_mismatch(value, type)
end
defp match_type(value, {:integer, _, integer}) when value === integer do
:ok
end
defp match_type(value, {:integer, _, _integer} = type) do
type_mismatch(value, type)
end
defp match_type(value, {:type, _, :range, [{:integer, _, low}, {:integer, _, high}]})
when value in low..high do
:ok
end
defp match_type(value, {:type, _, :range, _range} = type) do
type_mismatch(value, type)
end
defp match_type(value, {:type, _, nil, []}) when value == [] do
:ok
end
defp match_type(value, {:type, _, nil, []} = type) do
type_mismatch(value, type)
end
defp match_type(value, {:type, _, :map, []} = type) when is_map(value) do
if map_size(value) == 0 do
:ok
else
type_mismatch(value, type)
end
end
defp match_type(%{__struct__: struct_name} = value, {:type, _, :map, map_entry_types} = type) do
{struct_field_types, rest_field_types} =
Enum.split_with(map_entry_types, fn entry_type ->
match?({:type, _, :map_field_exact, [{:atom, _, :__struct__}, _]}, entry_type)
end)
case struct_field_types do
[] ->
match_type(Map.from_struct(value), type)
[{:type, _, :map_field_exact, [{:atom, _, :__struct__}, {:atom, _, ^struct_name}]}] ->
match_type(Map.from_struct(value), {:type, 0, :map, rest_field_types})
[{:type, _, :map_field_exact, [{:atom, _, :__struct__}, {:atom, _, other_struct_name}]}] ->
{:error, [{:struct_name_type_mismatch, struct_name, other_struct_name}]}
end
end
defp match_type(value, {:type, _, :map, map_entry_types}) when is_map(value) do
hit_map =
map_entry_types
|> Enum.map(fn
{:type, _, :map_field_exact, [key_type, value_type]} ->
{:required, {key_type, value_type}}
{:type, _, :map_field_assoc, [key_type, value_type]} ->
{:optional, {key_type, value_type}}
end)
|> Enum.map(fn key -> {key, 0} end)
|> Enum.into(%{})
type_match_result =
Enum.reduce_while(value, hit_map, fn {key, value}, current_hit_map ->
entry_match_results =
Enum.map(current_hit_map, fn {{_, {key_type, value_type}} = hit_map_key, _hits} ->
{hit_map_key, match_type(key, key_type), match_type(value, value_type)}
end)
full_hits =
Enum.filter(entry_match_results, fn
{_, :ok, :ok} -> true
{_, _, _} -> false
end)
entry_result =
case full_hits do
[_ | _] ->
Enum.reduce(full_hits, current_hit_map, fn {hit_map_key, _, _},
current_current_hit_map ->
Map.update!(current_current_hit_map, hit_map_key, fn hits -> hits + 1 end)
end)
[] ->
key_hits =
Enum.filter(entry_match_results, fn
{_, :ok, _} -> true
{_, _, _} -> false
end)
case key_hits do
[] ->
types_and_reasons =
Enum.map(entry_match_results, fn {{_, {key_type, _}}, {:error, key_reasons},
_} ->
{key_type, key_reasons}
end)
case types_and_reasons do
[{key_type, key_reasons}] ->
{:error, [{:map_key_type_mismatch, key, key_type} | key_reasons]}
[_ | _] ->
{:error,
[
{:map_key_type_mismatch, key,
Enum.map(types_and_reasons, fn {key_type, _} -> key_type end)}
]}
end
[_ | _] ->
types_and_reasons =
Enum.map(key_hits, fn {{_, {_, value_type}}, _, {:error, value_reasons}} ->
{value_type, value_reasons}
end)
case types_and_reasons do
[{value_type, value_reasons}] ->
{:error,
[{:map_value_type_mismatch, key, value, value_type} | value_reasons]}
[_ | _] ->
{:error,
[
{:map_value_type_mismatch, key, value,
Enum.map(types_and_reasons, fn {_, value_type} -> value_type end)}
]}
end
end
end
case entry_result do
{:error, _} = error -> {:halt, error}
entry_hit_map when is_map(entry_hit_map) -> {:cont, entry_hit_map}
end
end)
case type_match_result do
{:error, _} = error ->
error
required_hits when is_map(required_hits) ->
unfulfilled_type =
Enum.find(required_hits, fn
{{:required, _}, 0} -> true
{_, _} -> false
end)
case unfulfilled_type do
{{_, {{:atom, _, :__struct__}, {:atom, _, expected_struct_name}}}, _} ->
{:error, [{:struct_name_type_mismatch, expected_struct_name}]}
{{_, {key_type, value_type}}, _} ->
{:error,
[
{:required_field_unfulfilled_map_type_mismatch,
{:type, 0, :map_field_exact, [key_type, value_type]}}
]}
nil ->
:ok
end
end
end
defp match_type(value, {:type, _, :map, _} = type) do
type_mismatch(value, type)
end
defp match_type(value, {:type, _, :term, []}) do
match_type(value, {:type, 0, :any, []})
end
defp match_type(value, {:type, _, :arity, []}) do
match_type(value, {:type, 0, :range, [{:integer, 0, 0}, {:integer, 0, 255}]})
end
defp match_type(
value,
{:remote_type, _, [{:atom, _, :elixir}, {:atom, _, :as_boolean}, [inner_type]]}
) do
match_type(value, inner_type)
end
defp match_type(value, {:type, _, :binary, []}) do
match_type(value, {:type, 0, :binary, [{:integer, 0, 0}, {:integer, 0, 8}]})
end
defp match_type(value, {:type, _, :bitstring, []}) do
match_type(value, {:type, 0, :binary, [{:integer, 0, 0}, {:integer, 0, 1}]})
end
defp match_type(value, {:type, _, :boolean, []}) do
match_type(value, {:type, 0, :union, [{:atom, 0, true}, {:atom, 0, false}]})
end
defp match_type(value, {:type, _, :byte, []}) do
match_type(value, {:type, 0, :range, [{:integer, 0, 0}, {:integer, 0, 255}]})
end
defp match_type(value, {:type, _, :char, []}) do
match_type(value, {:type, 0, :range, [{:integer, 0, 0}, {:integer, 0, 0x10FFFF}]})
end
defp match_type(value, {:remote_type, _, [{:atom, _, :elixir}, {:atom, _, :charlist}, []]}) do
match_type(value, {:type, 0, :list, [{:type, 0, :char, []}]})
end
defp match_type(
value,
{:remote_type, _, [{:atom, _, :elixir}, {:atom, _, :nonempty_charlist}, []]}
) do
match_type(value, {:type, 0, :nonempty_list, [{:type, 0, :char, []}]})
end
defp match_type(value, {:type, _, :function, []}) do
match_type(value, {:type, 0, :fun, []})
end
defp match_type(value, {:type, _, :identifier, []}) do
match_type(
value,
{:type, 0, :union,
[{:type, 0, :pid, []}, {:type, 0, :port, []}, {:type, 0, :reference, []}]}
)
end
defp match_type(value, {:type, _, :iodata, []}) do
match_type(value, {:type, 0, :union, [{:type, 0, :binary, []}, {:type, 0, :iolist, []}]})
end
defp match_type(value, {:type, _, :iolist, []}) do
match_type(
value,
{:type, 0, :maybe_improper_list,
[
{:type, 0, :union,
[{:type, 0, :byte, []}, {:type, 0, :binary, []}, {:type, 0, :iolist, []}]},
{:type, 0, :union, [{:type, 0, :binary, []}, {:type, 0, nil, []}]}
]}
)
end
defp match_type(value, {:remote_type, _, [{:atom, _, :elixir}, {:atom, _, :keyword}, []]}) do
match_type(
value,
{:remote_type, 0, [{:atom, 0, :elixir}, {:atom, 0, :keyword}, [{:type, 0, :any, []}]]}
)
end
defp match_type(value, {:remote_type, _, [{:atom, _, :elixir}, {:atom, _, :keyword}, [type]]}) do
match_type(
value,
{:type, 0, :list, [{:type, 0, :tuple, [{:type, 0, :atom, []}, type]}]}
)
end
defp match_type(value, {:type, _, :maybe_improper_list, []}) do
match_type(
value,
{:type, 0, :maybe_improper_list, [{:type, 0, :any, []}, {:type, 0, :any, []}]}
)
end
defp match_type(value, {:type, _, :nonempty_maybe_improper_list, []}) do
match_type(
value,
{:type, 0, :nonempty_maybe_improper_list, [{:type, 0, :any, []}, {:type, 0, :any, []}]}
)
end
defp match_type(value, {:type, _, :mfa, []}) do
match_type(
value,
{:type, 0, :tuple, [{:type, 0, :module, []}, {:type, 0, :atom, []}, {:type, 0, :arity, []}]}
)
end
defp match_type(value, {:type, _, :module, []}) do
match_type(
value,
{:type, 0, :atom, []}
)
end
defp match_type(value, {:type, _, :no_return, []}) do
match_type(
value,
{:type, 0, :none, []}
)
end
defp match_type(value, {:type, _, :node, []}) do
match_type(
value,
{:type, 0, :atom, []}
)
end
defp match_type(value, {:type, _, :number, []}) do
match_type(value, {:type, 0, :union, [{:type, 0, :integer, []}, {:type, 0, :float, []}]})
end
defp match_type(value, {:type, _, :timeout, []}) do
match_type(
value,
{:type, 0, :union, [{:atom, 0, :infinity}, {:type, 0, :non_neg_integer, []}]}
)
end
defp match_type(
value,
{:remote_type, _, _} = type
) do
with :ok <- maybe_match_protocol(value, type),
{:ok, remote_type} <- resolve_remote_type(type) do
match_type(value, remote_type)
else
{:error, reason} -> {:error, [reason]}
end
end
defp maybe_match_protocol(
value,
{:remote_type, _, [{:atom, _, module_name}, {:atom, _, :t}, []]}
) do
if function_exported?(module_name, :__protocol__, 1) and
function_exported?(module_name, :impl_for, 1) do
case apply(module_name, :impl_for, [value]) do
nil -> {:error, {:protocol_type_mismatch, value, module_name}}
_ -> :ok
end
else
:ok
end
end
defp maybe_match_protocol(_value, _type) do
:ok
end
defp resolve_remote_type(
{:remote_type, _, [{:atom, _, module_name}, {:atom, _, type_name}, args]}
)
when is_atom(module_name) and is_atom(type_name) and is_list(args) do
with {:ok, types} <- fetch_types(module_name),
{:ok, {:type, {_name, type, vars}}} <- get_type(types, type_name, length(args)) do
resolved_type =
args
|> Enum.zip(vars)
|> Enum.reduce(type, fn {arg, var}, resolved_type ->
fill_type_var(resolved_type, var, arg)
end)
{:ok, replace_user_types(resolved_type, module_name)}
else
{:error, {:module_fetch_failure, _}} = error ->
error
{:error, {:type_not_found, {type_name, arity}}} ->
{:error, {:remote_type_fetch_failure, {module_name, type_name, arity}}}
end
end
defp fill_type_var(type, var, arg) do
type_map(type, fn
^var -> arg
other -> other
end)
end
defp replace_user_types(type, module_name) do
type_map(type, fn
{:user_type, _, name, args} ->
{:remote_type, 0, [{:atom, 0, module_name}, {:atom, 0, name}, args]}
other ->
other
end)
end
defp type_map(type, map_fun) do
case map_fun.(type) do
{:type, position, name, params} when is_list(params) ->
{:type, position, name, Enum.map(params, fn param -> type_map(param, map_fun) end)}
{:ann_type, position, [var, ann_type]} ->
{:ann_type, position, [var, type_map(ann_type, map_fun)]}
other ->
other
end
end
defp fetch_types(module_name) do
case Code.Typespec.fetch_types(module_name) do
{:ok, _} = ok -> ok
:error -> {:error, {:module_fetch_failure, module_name}}
end
end
defp get_type(type_list, type_name, arity) do
case Enum.find(type_list, fn {:type, {name, _type, params}} ->
name == type_name and length(params) == arity
end) do
nil -> {:error, {:type_not_found, {type_name, arity}}}
type -> {:ok, type}
end
end
defp match_improper_list_type(
[elem | rest],
{:type, _, :nonempty_improper_list, [type1, _type2]} = type,
index
)
when is_list(rest) do
elem_error =
case match_type(elem, type1) do
:ok -> nil
{:error, reasons} -> {:error, [{:elem_type_mismatch, index, elem, type1} | reasons]}
end
if elem_error do
elem_error
else
match_improper_list_type(rest, type, index + 1)
end
end
defp match_improper_list_type(
[elem | terminator],
{:type, _, :nonempty_improper_list, [type1, type2]},
index
) do
elem_error =
case match_type(elem, type1) do
:ok -> nil
{:error, reasons} -> {:error, [{:elem_type_mismatch, index, elem, type1} | reasons]}
end
terminator_error =
case match_type(terminator, type2) do
:ok ->
nil
{:error, reasons} ->
{:error, [{:improper_list_terminator_type_mismatch, terminator, type2} | reasons]}
end
elem_error || terminator_error || :ok
end
defp type_mismatch(value, type) do
{:error, [{:type_mismatch, value, type}]}
end
end