lib/propex.ex

defmodule PropEx.TypespecClause do
  defmacro typespec_clause(name, args \\ []) do
    fun_args = Enum.map(args, fn name -> {:var!, [], [{name, [], __MODULE__}]} end)
    call_args = Enum.map(args, fn name -> {:convert_typespec, [], [{:var!, [], [{name, [], __MODULE__}]}]} end)
    call = {{:., [], [:proper_types, name]}, [], call_args}
    quote do
      def convert_typespec({unquote(name), _, unquote(fun_args)}), do: unquote(call)
    end
  end
end

defmodule PropEx do
  @moduledoc """
  A wrapper around PropEr
  """

  alias Code.Typespec
  alias :proper_types, as: PT
  import __MODULE__.TypespecClause

  def convert_typespec(typespec)

  # straightforward types
  typespec_clause :any
  typespec_clause :term
  typespec_clause :arity
  typespec_clause :binary
  typespec_clause :integer
  typespec_clause :neg_integer
  typespec_clause :non_neg_integer
  typespec_clause :float
  typespec_clause :list
  typespec_clause :list, [:elem]

  # type defined in another module
  def convert_typespec({{:., _, [mod, type_name]}, _, _args} = ast) do
    with {:ok, types} <- Typespec.fetch_types(mod),
                types <- Enum.map(types, fn {:type, type} -> type end),
                type <- List.keyfind(types, type_name, 0),
                {:"::", _, [{^type_name, _, type_args}, type_definition]} <- Typespec.type_to_quoted(type) do
      if length(type_args) != 0, do:
        raise ArgumentError, "PropEx: Sorry, not implemented: type #{Macro.to_string(ast)} is generic"
      convert_typespec(type_definition)
    else
      error -> raise ArgumentError, "PropEx: unable to access @type #{Macro.to_string(ast)}: #{error}"
    end
  end

  # unimplemented types
  def convert_typespec({type, _, []}) when type == :pid or type == :port or type == :reference, do:
    raise ArgumentError, "PropEx: type #{type}() cannot be generated by PropEr"
  def convert_typespec(type), do:
    raise ArgumentError, "PropEx: Sorry, not implemented: type #{Macro.to_string(type)} is not supported\nRepresentation: #{inspect type}"

  def convert_argument_spec({:"::", _, [{name, _, nil}, type]}), do:
    {name, convert_typespec(type)}
  def convert_argument_spec({:"::", _, [name, type]}) when is_atom(name), do:
    {name, convert_typespec(type)}
  def convert_argument_spec(_spec), do:
    raise ArgumentError, """
    PropEx: Your type specification seems to be missing variable names.
    When using arguments_of, instead of:

        @spec add(integer(), integer()) :: integer()
        def add(a, b), do: a + b

    You should write:

        @spec add(a :: integer(), b :: integer()) :: integer()
        def add(a, b), do: a + b
    """

  def convert_argument_list(argument_list) do
    argument_list
      |> Enum.map(&convert_argument_spec/1)
      |> Enum.into(%{})
      |> Map.values
      |> :erlang.list_to_tuple
  end

  def get_arguments_of({:/, _, [call, arity]} = ast, context) do
    # parse the function reference
    {orig_mod, mod, fun, arity} = case Macro.decompose_call(call) do
      {mod, fun, []} -> {mod, Macro.expand(mod, context), fun, arity}
      _ -> get_arguments_of(:yolo, context) # to raise an error
    end

    # fetch function specification and extract the arguments from it
    args = with {:ok, specs} <- Typespec.fetch_specs(mod),
         {{^fun, ^arity}, [spec]} <- List.keyfind(specs, {fun, arity}, 0),
         {:"::", _, [{^fun, _, args}, _return_type]} <- Typespec.spec_to_quoted(fun, spec) do
      args
    else
      error -> raise ArgumentError, "PropEx: unable to access the @spec for function #{Macro.to_string(ast)}: #{error}"
    end

    # generate the AST for `arg1, arg2, ...`
    call_args = args
      |> Enum.map(fn
        {:"::", _, [{name, _, nil}, _type]} -> name
        {:"::", _, [name, _type]} -> name
      end)
      |> Enum.map(fn var_name -> {:var!, [], [{var_name, [], __MODULE__}]} end)

    # generate the AST for `result = mod.fun(args)`
    result_fetcher = {:=, [], [{:var!, [], [{:result, [], __MODULE__}]}, {{:., [], [orig_mod, fun]}, [], call_args}]}

    {args, result_fetcher}
  end

  def get_arguments_of(_, _), do: raise ArgumentError, """
    PropEx: It looks like you passed something other than Mod.fun/arity to arguments_of. You should use it like this:

        forall arguments_of MyModule.my_fun/2 do
          # code
        end
    """

  defmacro __using__(_env) do
    quote do
      require PropEx
      import PropEx, only: [forall: 2]
    end
  end

  defmacro forall(argument_list, opts) do
    # if argument_list is actually a request to fetch the arguments from the
    # specs, do that
    {argument_list, result_fetcher} = case argument_list do
      {:arguments_of, _, [ast_fun_ref]} -> get_arguments_of(ast_fun_ref, __CALLER__)
      _ -> {argument_list, nil}
    end

    # process argument list
    destructurer = argument_list
      |> Enum.map(&convert_argument_spec/1)
      |> Enum.into(%{})
      |> Map.keys
      |> Enum.map(fn var_name -> {:var!, [], [{var_name, [], __MODULE__}]} end)
      |> :erlang.list_to_tuple

    quote do
      assert :proper.forall(PropEx.convert_argument_list(unquote(Macro.escape(argument_list))), fn arguments ->
        unquote(destructurer) = arguments
        unquote(result_fetcher)
        unquote(opts[:do])
      end) |> :proper.quickcheck
    end
  end
end