lib/map.ex

defmodule Moar.Map do
  # @related [test](/test/map_test.exs)

  @moduledoc "Map-related functions."

  @doc """
  Converts `key` in `map` to an atom, optionally transforming the value with `value_transformer`.

  Raises if `key` is a string and `map` already has an atomized version of that key.

  ```elixir
  iex> Moar.Map.atomize_key(%{"number-one" => "one", "number-two" => "two"}, "number-one")
  %{:number_one => "one", "number-two" => "two"}

  iex> Moar.Map.atomize_key(%{"number-one" => "one", "number-two" => "two"}, "number-one", &String.upcase/1)
  %{:number_one => "ONE", "number-two" => "two"}
  ```
  """
  @spec atomize_key(map(), binary() | atom(), (any() -> any()) | nil) :: map()
  def atomize_key(map, key, value_transformer \\ &Function.identity/1) do
    atomized_key =
      if is_atom(key) do
        key
      else
        key
        |> Moar.String.underscore()
        |> Moar.Atom.from_string()
        |> tap(fn new_key ->
          if Map.has_key?(map, new_key),
            do: raise(KeyError, ["key ", inspect(new_key), " already exists in ", inspect(map)] |> to_string())
        end)
      end

    map |> rename_key(key, atomized_key) |> transform(atomized_key, value_transformer)
  end

  @doc """
  Like `atomize_key/3` but raises if `key` is not in `map`.
  """
  @spec atomize_key!(map(), binary() | atom(), (any() -> any()) | nil) :: map()
  def atomize_key!(map, key, value_fn \\ &Function.identity/1)

  def atomize_key!(map, key, value_fn) when is_map_key(map, key),
    do: atomize_key(map, key, value_fn)

  def atomize_key!(map, key, _value_fn),
    do: raise(KeyError, ["key ", inspect(key), " not found in: ", inspect(map)] |> to_string())

  @doc """
  Converts keys in `map` to atoms.

  Raises if converting a key from a string to an atom would result in a key conflict.

  ```elixir
  iex> Moar.Map.atomize_keys(%{"a" => 1, "b" => 2})
  %{a: 1, b: 2}
  ```
  """
  @spec atomize_keys(map()) :: map()
  def atomize_keys(map),
    do: Enum.reduce(map, map, fn {k, _v}, acc -> atomize_key(acc, k) end)

  @doc """
  Converts keys to atoms, traversing through descendant lists and maps.

  Raises if converting a key from a string to an atom would result in a key conflict.

  ```elixir
  iex> Moar.Map.deep_atomize_keys(%{"a" => %{"aa" => 1}, "b" => [%{"bb" => 2}, %{"bbb" => 3}]})
  %{a: %{aa: 1}, b: [%{bb: 2}, %{bbb: 3}]}
  ```
  """
  @spec deep_atomize_keys(list() | map()) :: map()
  def deep_atomize_keys(list) when is_list(list),
    do: list |> Enum.map(&deep_atomize_keys(&1))

  def deep_atomize_keys(map) when is_map(map) do
    Enum.reduce(map, map, fn
      {k, v}, acc when is_map(v) -> atomize_key(acc, k, &deep_atomize_keys/1)
      {k, list}, acc when is_list(list) -> atomize_key(acc, k, &Enum.map(&1, fn v -> deep_atomize_keys(v) end))
      {k, _v}, acc -> atomize_key(acc, k)
    end)
  end

  def deep_atomize_keys(not_a_map),
    do: not_a_map

  @doc """
  Deeply merges two enumerables into a single map.

  Optionally accepts `conflict_fn` which gets called when both enumerables have values at the same keypath.
  It receives the conflicting values from each enumerable and is expected to return the winning value.

  ```elixir
  iex> Moar.Map.deep_merge(%{fruit: %{apples: 3, bananas: 5}, veggies: %{carrots: 10}}, [fruit: [cherries: 20]])
  %{fruit: %{apples: 3, bananas: 5, cherries: 20}, veggies: %{carrots: 10}}

  iex> Moar.Map.deep_merge(%{a: %{b: 1}}, %{a: %{b: 2}})
  %{a: %{b: 2}}

  iex> Moar.Map.deep_merge(%{a: %{b: 1}}, %{a: %{b: 2}}, fn x, _y -> x end)
  %{a: %{b: 1}}

  iex> Moar.Map.deep_merge(%{a: %{b: 1}}, %{a: %{b: 2}}, fn x, y -> [x, y] end)
  %{a: %{b: [1, 2]}}
  ```
  """
  @spec deep_merge(Enum.t(), Enum.t(), (any(), any() -> any())) :: map()
  def deep_merge(a, b, conflict_fn \\ fn _val1, val2 -> val2 end),
    do: deep_merge(nil, a, b, conflict_fn)

  defp deep_merge(_key, a, b, conflict_fn) do
    if Moar.Protocol.implements?(a, Enumerable) && Moar.Protocol.implements?(b, Enumerable),
      do: Map.merge(Map.new(a), Map.new(b), fn k, v1, v2 -> deep_merge(k, v1, v2, conflict_fn) end),
      else: conflict_fn.(a, b)
  end

  @doc """
  Merges two enumerables into a single map.

  ```elixir
  iex> Moar.Map.merge(%{a: 1}, [b: 2])
  %{a: 1, b: 2}
  ```
  """
  @spec merge(Enum.t(), Enum.t()) :: map()
  def merge(a, b) when is_map(a) and is_map(b),
    do: Map.merge(a, b)

  def merge(a, b),
    do: Map.merge(Map.new(a), Map.new(b))

  @doc """
  Puts a key/value pair into the given map if the key is not alredy in the map, or if the value in the map is
  blank as defined by `Moar.Term.blank?/1`.

  Also, the `map` parameter can be any enumerable that can be turned into a map via `Enum.into/2`.

  ```elixir
  iex> %{a: 1} |> Moar.Map.put_if_blank(:b, 2)
  %{a: 1, b: 2}

  iex> %{a: 1, b: nil} |> Moar.Map.put_if_blank(:b, 2)
  %{a: 1, b: 2}

  iex> %{a: 1, b: 3} |> Moar.Map.put_if_blank(:b, 2)
  %{a: 1, b: 3}
  ```
  """
  @spec put_if_blank(map() | keyword(), any(), any()) :: map()
  def put_if_blank(map, key, value) do
    map = Enum.into(map, %{})

    if Map.get(map, key) |> Moar.Term.present?(),
      do: map,
      else: Map.put(map, key, value)
  end

  @doc """
  Returns a copy of `map` with `old_key_name` changed to `new_key_name`.

  ```elixir
  iex> %{"color" => "red", "size" => "medium"} |> Moar.Map.rename_key("color", "colour")
  %{"colour" => "red", "size" => "medium"}
  ```
  """
  @spec rename_key(map(), binary() | atom(), binary() | atom()) :: map()
  def rename_key(map, old_key_name, new_key_name) when is_map_key(map, old_key_name) do
    {value, new_map} = Map.pop(map, old_key_name)
    Map.put(new_map, new_key_name, value)
  end

  def rename_key(map, _, _),
    do: map

  @doc """
  Like `rename_key/2` but raises if `key` is not in `map`
  """
  @spec rename_key!(map(), {binary(), binary()}) :: map()
  def rename_key!(map, {old_key_name, new_key_name} = _old_and_new_key),
    do: rename_key!(map, old_key_name, new_key_name)

  @doc """
  Like `rename_key/3` but raises if `key` is not in `map`
  """
  @spec rename_key!(map(), binary() | atom(), binary() | atom()) :: map()
  def rename_key!(map, old_key_name, new_key_name) when is_map_key(map, old_key_name),
    do: rename_key(map, old_key_name, new_key_name)

  def rename_key!(map, old_key_name, _new_key_name),
    do: raise(KeyError, ["key ", inspect(old_key_name), " not found in: ", inspect(map)] |> to_string())

  @doc """
  Returns a copy of `map` with `old_key_name` changed to `new_key_name`.

  `old_key_name` and `new_key_name` are passed in as a `{old_key_name, new_key_name}` tuple.

  ```elixir
  iex> %{"color" => "red", "size" => "medium"} |> Moar.Map.rename_key({"color", "colour"})
  %{"colour" => "red", "size" => "medium"}
  ```
  """
  @spec rename_key(map(), {binary(), binary()}) :: map()
  def rename_key(map, {old_key_name, new_key_name} = _old_and_new_key),
    do: rename_key(map, old_key_name, new_key_name)

  @doc """
  Returns a copy of `map` after changing key names supplied by `keys_map`.

  ```elixir
  %{"behavior" => "chill", "color" => "red"} |> Moar.Map.rename_keys(%{"behavior" => "behaviour", "color" => "colour"})
  %{"behaviour" => "chill", "colour" => "red"}
  ```
  """
  @spec rename_keys(map(), map()) :: map()
  def rename_keys(map, keys_map),
    do: Enum.reduce(keys_map, map, &rename_key(&2, &1))

  @doc """
  Like `rename_keys/2` but raises if any key in `keys_map` is not in `map`.
  """
  @spec rename_keys!(map(), map()) :: map()
  def rename_keys!(map, keys_map),
    do: Enum.reduce(keys_map, map, &rename_key!(&2, &1))

  @doc """
  Converts keys in `map` to strings.

  ```elixir
  iex> Moar.Map.stringify_keys(%{a: 1, b: 2} )
  %{"a" => 1, "b" => 2}
  ```
  """
  @spec stringify_keys(map()) :: map()
  def stringify_keys(map),
    do: map |> Map.new(fn {k, v} -> {Moar.Atom.to_string(k), v} end)

  @doc """
  Transforms values of `map` using `transformer` function.

  ```elixir
  iex> %{"foo" => "chicken", "bar" => "cow", "baz" => "pig"} |> Moar.Map.transform("foo", &String.upcase/1)
  %{"foo" => "CHICKEN", "bar" => "cow", "baz" => "pig"}

  iex> %{"foo" => "chicken", "bar" => "cow", "baz" => "pig"} |> Moar.Map.transform(["foo", "bar"], &String.upcase/1)
  %{"foo" => "CHICKEN", "bar" => "COW", "baz" => "pig"}
  ```
  """
  @spec transform(map(), atom() | binary() | list(), (any() -> any())) :: map()
  def transform(map, keys, transformer) when is_list(keys),
    do: Enum.reduce(keys, map, fn key, new_map -> transform(new_map, key, transformer) end)

  def transform(map, key, transformer) when is_map_key(map, key),
    do: Map.update!(map, key, transformer)

  def transform(map, _, _),
    do: map
end