lib/machete/matchers/map_matcher.ex

defmodule Machete.MapMatcher do
  @moduledoc """
  Defines a matcher that matches maps
  """

  import Machete.Mismatch
  import Machete.Operators

  defstruct keys: nil, values: nil, size: nil, min: nil, max: nil

  @typedoc """
  Describes an instance of this matcher
  """
  @opaque t :: %__MODULE__{}

  @typedoc """
  Describes the arguments that can be passed to this matcher
  """
  @type opts :: [
          {:keys, Machete.Matchable.t()},
          {:values, Machete.Matchable.t()},
          {:size, non_neg_integer()},
          {:min, non_neg_integer()},
          {:max, non_neg_integer()}
        ]

  @doc """
  Matches maps. Useful for cases where you wish to match against the general shape / size of
  a map, but cannot match against a literal map (or use the `Machete.Superset` / `Machete.Subset`
  matchers)

  Takes the following arguments:

  * `keys`: A matcher to use against all keys in the map
  * `values`: A matcher to use against all values in the map
  * `size`: Requires the matched map to be exactly the specified size
  * `min`: Requires the matched map to be greater than or equal to the specified size
  * `max`: Requires the matched map to be less than or equal to the specified size

  Examples:

      iex> assert %{a: 1} ~> map()
      true

      iex> assert %{a: 1} ~> map(keys: atom())
      true

      iex> assert %{a: 1} ~> map(values: integer())
      true

      iex> assert %{a: 1} ~> map(size: 1)
      true

      iex> assert %{a: 1} ~> map(min: 1)
      true

      iex> assert %{a: 1} ~> map(max: 2)
      true
  """
  @spec map(opts()) :: t()
  def map(opts \\ []), do: struct!(__MODULE__, opts)

  defimpl Machete.Matchable do
    def mismatches(%@for{}, b) when not is_map(b), do: mismatch("#{inspect(b)} is not a map")

    def mismatches(%@for{} = a, b) do
      with nil <- matches_size(b, a.size),
           nil <- matches_min(b, a.min),
           nil <- matches_max(b, a.max),
           nil <- matches_keys(b, a.keys),
           nil <- matches_values(b, a.values) do
      end
    end

    defp matches_size(b, size) when is_number(size) and map_size(b) != size,
      do: mismatch("#{inspect(b)} is not exactly #{size} pairs in size")

    defp matches_size(_, _), do: nil

    defp matches_min(b, min) when is_number(min) and map_size(b) < min,
      do: mismatch("#{inspect(b)} is less than #{min} pairs in size")

    defp matches_min(_, _), do: nil

    defp matches_max(b, max) when is_number(max) and map_size(b) > max,
      do: mismatch("#{inspect(b)} is greater than #{max} pairs in size")

    defp matches_max(_, _), do: nil

    defp matches_keys(_, nil), do: nil

    defp matches_keys(b, matcher) do
      b
      |> Map.keys()
      |> Enum.flat_map(fn k -> Enum.map(k ~>> matcher, &%{&1 | path: [k | &1.path]}) end)
    end

    defp matches_values(_, nil), do: nil

    defp matches_values(b, matcher) do
      b
      |> Map.keys()
      |> Enum.flat_map(fn k -> Enum.map(b[k] ~>> matcher, &%{&1 | path: [k | &1.path]}) end)
    end
  end
end