lib/patch/importer.ex

defmodule Patch.Importer do
  defmacro __using__(options \\ []) do
    validate_keyword_options!(options)
    validate_unknown_options!(options)
    validate_mutually_exclusive_options!(options)
    validate_alias_option!(options[:alias])
    validate_except_option!(options[:except])
    validate_only_option!(options[:only])

    delegates =
      all()
      |> exclude(options[:except])
      |> include(options[:only])
      |> aliasing(options[:alias])
      |> Enum.map(&delegate/1)

    quote do
      require Patch
      require Patch.Assertions
      require Patch.Macro
      require Patch.Mock
      require Patch.Mock.History.Tagged

      unquote_splicing(delegates)
    end
  end

  def all() do
    [
      :assert_any_call,
      :assert_called,
      :assert_called_once,
      :callable,
      :cycle,
      :debug,
      :expose,
      :fake,
      :history,
      :inject,
      :listen,
      :patch,
      :private,
      :raises,
      :real,
      :refute_any_call,
      :refute_called,
      :refute_called_once,
      :replace,
      :restore,
      :scalar,
      :sequence,
      :spy,
      :throws
    ]
  end

  ## Delegation

  defp delegate({:assert_any_call, symbol}) do
    quote do
      defmacro unquote(symbol)(call) do
        quote do
          Patch.assert_any_call(unquote(call))
        end
      end

      defdelegate unquote(symbol)(module, function), to: Patch, as: :assert_any_call
    end
  end

  defp delegate({:assert_called, symbol}) do
    quote do
      defmacro unquote(symbol)(call) do
        quote do
          Patch.assert_called(unquote(call))
        end
      end

      defmacro unquote(symbol)(call, count) do
        quote do
          Patch.assert_called(unquote(call), unquote(count))
        end
      end
    end
  end

  defp delegate({:assert_called_once, symbol}) do
    quote do
      defmacro unquote(symbol)(call) do
        quote do
          Patch.assert_called_once(unquote(call))
        end
      end
    end
  end

  defp delegate({:callable, symbol}) do
    quote do
      defdelegate unquote(symbol)(target), to: Patch.Mock.Value, as: :callable
      defdelegate unquote(symbol)(target, dispatch_or_options), to: Patch.Mock.Value, as: :callable
    end
  end

  defp delegate({:cycle, symbol}) do
    quote do
      defdelegate unquote(symbol)(values), to: Patch.Mock.Value, as: :cycle
    end
  end

  defp delegate({:debug, symbol}) do
    quote do
      defdelegate unquote(symbol)(), to: Patch, as: :debug
      defdelegate unquote(symbol)(value), to: Patch, as: :debug
    end
  end

  defp delegate({:expose, symbol}) do
    quote do
      defdelegate unquote(symbol)(module, exposes), to: Patch, as: :expose
    end
  end

  defp delegate({:fake, symbol}) do
    quote do
      defdelegate unquote(symbol)(real_module, fake_module), to: Patch, as: :fake
    end
  end

  defp delegate({:inject, symbol}) do
    quote do
      defdelegate unquote(symbol)(tag, target, keys), to: Patch, as: :inject
      defdelegate unquote(symbol)(tag, target, keys, options), to: Patch, as: :inject
    end
  end

  defp delegate({:is_value, symbol}) do
    quote do
      defguard unquote(symbol)(module) when Patch.Mock.Value.is_value(module)
    end
  end

  defp delegate({:history, symbol}) do
    quote do
      defdelegate unquote(symbol)(module), to: Patch, as: :history
      defdelegate unquote(symbol)(module, sorting), to: Patch, as: :history
    end
  end

  defp delegate({:listen, symbol}) do
    quote do
      defdelegate unquote(symbol)(tag), to: Patch, as: :listen
      defdelegate unquote(symbol)(tag, target), to: Patch, as: :listen
      defdelegate unquote(symbol)(tag, target, options), to: Patch, as: :listen
    end
  end

  defp delegate({:patch, symbol}) do
    quote do
      defdelegate unquote(symbol)(module, function, value), to: Patch, as: :patch
    end
  end

  defp delegate({:private, symbol}) do
    quote do
      defmacro unquote(symbol)(call) do
        quote do
          Patch.private(unquote(call))
        end
      end

      defmacro unquote(symbol)(argument, call) do
        quote do
          Patch.private(unquote(argument), unquote(call))
        end
      end
    end
  end

  defp delegate({:raises, symbol}) do
    quote do
      defdelegate unquote(symbol)(message), to: Patch.Mock.Value, as: :raises
      defdelegate unquote(symbol)(exception, attributes), to: Patch.Mock.Value, as: :raises
    end
  end

  defp delegate({:real, symbol}) do
    quote do
      defdelegate unquote(symbol)(module), to: Patch, as: :real
    end
  end

  defp delegate({:refute_any_call, symbol}) do
    quote do
      defmacro unquote(symbol)(call) do
        quote do
          Patch.refute_any_call(unquote(call))
        end
      end

      defdelegate unquote(symbol)(module, function), to: Patch, as: :refute_any_call
    end
  end

  defp delegate({:refute_called, symbol}) do
    quote do
      defmacro unquote(symbol)(call) do
        quote do
          Patch.refute_called(unquote(call))
        end
      end

      defmacro unquote(symbol)(call, count) do
        quote do
          Patch.refute_called(unquote(call), unquote(count))
        end
      end
    end
  end

  defp delegate({:refute_called_once, symbol}) do
    quote do
      defmacro unquote(symbol)(call) do
        quote do
          Patch.refute_called_once(unquote(call))
        end
      end
    end
  end

  defp delegate({:replace, symbol}) do
    quote do
      defdelegate unquote(symbol)(target, keys, value), to: Patch, as: :replace
    end
  end

  defp delegate({:restore, symbol}) do
    quote do
      defdelegate unquote(symbol)(module), to: Patch, as: :restore
      defdelegate unquote(symbol)(module, name), to: Patch, as: :restore
    end
  end

  defp delegate({:scalar, symbol}) do
    quote do
      defdelegate unquote(symbol)(value), to: Patch.Mock.Value, as: :scalar
    end
  end

  defp delegate({:sequence, symbol}) do
    quote do
      defdelegate unquote(symbol)(values), to: Patch.Mock.Value, as: :sequence
    end
  end

  defp delegate({:spy, symbol}) do
    quote do
      defdelegate unquote(symbol)(module), to: Patch, as: :spy
    end
  end

  defp delegate({:throws, symbol}) do
    quote do
      defdelegate unquote(symbol)(value), to: Patch.Mock.Value, as: :throws
    end
  end


  ## Private

  defp aliasing(symbols, nil) do
    Keyword.new(symbols, &{&1, &1})
  end

  defp aliasing(symbols, aliases) do
    Keyword.new(symbols, fn symbol ->
      {symbol, aliases[symbol] || symbol}
    end)
  end

  defp exclude(symbols, nil) do
    symbols
  end

  defp exclude(_symbols, :all) do
    []
  end

  defp exclude(symbols, except) do
    Enum.reject(symbols, & &1 in except)
  end

  defp include(symbols, nil) do
    symbols
  end

  defp include(symbols, only) do
    Enum.filter(symbols, & &1 in only)
  end

  defp validate_keyword_options!(options) do
    unless Keyword.keyword?(options) do
      raise Patch.ConfigurationError, message: "Patch only accepts a Keyword for configuration, invalid configuration provided"
    end
  end

  defp validate_unknown_options!(options) do
    keys = Keyword.keys(options)

    invalid_keys = keys -- [:alias, :except, :only]

    unless Enum.empty?(invalid_keys) do
      raise Patch.ConfigurationError, message: "Patch only accepts the :alias, :except, and :only configuration keys, invalid configuration keys provided: #{inspect(invalid_keys)}"
    end
  end

  defp validate_mutually_exclusive_options!(options) do
    if Keyword.has_key?(options, :except) and Keyword.has_key?(options, :only) do
      raise Patch.ConfigurationError, message: "Patch options :except and :only are mutually exclusive but both were provided"
    end
  end

  defp validate_alias_option!(nil) do
    :ok
  end

  defp validate_alias_option!(aliases) do
    unless Keyword.keyword?(aliases) do
      raise Patch.ConfigurationError, message: "Patch option :alias accepts a Keyword mapping patch symbols to aliases, the provided value is invalid because it is not a Keyword"
    end

    known_symbols = all()
    unknown_symbols =
      aliases
      |> Keyword.keys()
      |> Enum.reject(& &1 in known_symbols)

    unless Enum.empty?(unknown_symbols) do
      raise Patch.ConfigurationError, message: "Patch option :alias accepts a Keyword mapping patch symbols to aliases, the provided valus is invalid because it contains unknown symbols: #{inspect(unknown_symbols)}"
    end

    all_atoms? =
      aliases
      |> Keyword.values()
      |> Enum.all?(&is_atom/1)

    unless all_atoms? do
      raise Patch.ConfigurationError, message: "Patch option :alias accepts a Keyword mapping patch symbols to aliases, the provided value is invalid becasue it contains aliases that are not atoms"
    end

    :ok
  end

  defp validate_except_option!(nil) do
    :ok
  end

  defp validate_except_option!(:all) do
    :ok
  end

  defp validate_except_option!(except) when not is_list(except) do
    raise Patch.ConfigurationError, message: "Patch option :except accepts either the atom `:all` or a list of atoms to exclude from importing, the provided value is invalid because it is neither of these"
  end

  defp validate_except_option!(except) do
    unless Enum.all?(except, &is_atom/1) do
      raise Patch.ConfigurationError, message: "Patch option :except accepts a list of atoms to exclude from importing, the provided value is invalid because it contains values that are not atoms"
    end

    known_symbols = all()
    unknown_symbols = Enum.reject(except, & &1 in known_symbols)

    unless Enum.empty?(unknown_symbols) do
      raise Patch.ConfigurationError, message: "Patch option :excpet accepts a list of atoms to exclude from importing, the provided value is invalid because it contains unknown symbols: #{inspect(unknown_symbols)}"
    end

    :ok
  end

  defp validate_only_option!(nil) do
    :ok
  end

  defp validate_only_option!(only) when not is_list(only) do
    raise Patch.ConfigurationError, message: "Patch option :only accepts a list of atoms to import, the provided value is invalid because it is not a list"
  end

  defp validate_only_option!(only) do
    unless Enum.all?(only, &is_atom/1) do
      raise Patch.ConfigurationError, message: "Patch option :only accepts a list of atoms to import, the provided value is invalid because it contains values that are not atoms"
    end

    known_symbols = all()
    unknown_symbols = Enum.reject(only, & &1 in known_symbols)

    unless Enum.empty?(unknown_symbols) do
      raise Patch.ConfigurationError, message: "Patch option :only accepts a list of atoms to import, the provided value is invalid because it contains unknown symbols: #{inspect(unknown_symbols)}"
    end

    :ok
  end
end