lib/swiss/map.ex

defmodule Swiss.Map do
  @moduledoc """
  A few extra functions to deal with Maps.
  """

  @doc """
  Applies defaults to a map.

  ## Examples

      iex> Swiss.Map.defaults(%{a: 42}, %{b: 12})
      %{a: 42, b: 12}

      iex> Swiss.Map.defaults(%{a: 42}, %{a: 44, b: 12})
      %{a: 42, b: 12}

      iex> Swiss.Map.defaults(%{a: 42, c: nil}, [a: nil, b: 12, c: 13])
      %{a: 42, b: 12, c: nil}

  """
  @spec defaults(map(), map() | keyword()) :: map()
  def defaults(map, defaults) when is_list(defaults),
    do: defaults(map, Enum.into(defaults, %{}))

  def defaults(map, defaults) when is_map(defaults),
    do: Map.merge(defaults, map)

  @doc """
  Wrapper around `Map.from_struct/1` that tolerates `nil`.

  ## Examples

      iex> Swiss.Map.from_struct(nil)
      nil

      iex> Swiss.Map.from_struct(%{__struct__: SomeStruct, life: 42})
      %{life: 42}

  """
  @spec from_struct(struct | nil) :: map() | nil
  def from_struct(nil), do: nil
  def from_struct(struct), do: Map.from_struct(struct)

  @doc """
  Converts an atom-keyed map into a string-keyed map.

  ## Examples

      iex> Swiss.Map.to_string_keys(%{life: 42})
      %{"life" => 42}

      iex> Swiss.Map.to_string_keys(%{"life" => 42, death: 27})
      %{"life" => 42, "death" => 27}

  """
  @spec to_string_keys(map()) :: map()
  def to_string_keys(map) do
    map
    |> Map.to_list()
    |> Stream.map(fn
      {key, value} when is_atom(key) -> {Atom.to_string(key), value}
      entry -> entry
    end)
    |> Enum.into(%{})
  end

  @doc """
  Fetches a value from a map with indifferent access, i.e. given an atom,
  returns the value that is keyed by that atom, or by its string equivalent.

  If both atom and String keys exist in the map, the atom's value is returned.

  ## Examples

      iex> Swiss.Map.indif_fetch!(%{life: 42}, :life)
      42

      iex> Swiss.Map.indif_fetch!(%{"life" => 42}, :life)
      42

      iex> Swiss.Map.indif_fetch!(%{:life => 42, "life" => 64}, :life)
      42

      iex> Swiss.Map.indif_fetch!(%{}, :life)
      ** (KeyError) key :life not found in: %{}

  """
  @spec indif_fetch!(map(), atom()) :: any()
  def indif_fetch!(map, key) when is_atom(key) do
    Map.get_lazy(map, key, fn ->
      string_key = Atom.to_string(key)

      if Map.has_key?(map, string_key) do
        map[string_key]
      else
        raise KeyError, "key #{inspect(key)} not found in: #{inspect(map)}"
      end
    end)
  end

  @doc """
  Runs `Map.put/3` only if `pred` returns truthy when called on the value.

  The default behavior is to put unless the value is `nil`.

  `pred` can also be a boolean.

  ## Examples

      iex> Swiss.Map.put_if(%{life: 42}, :life, 22)
      %{life: 22}

      iex> Swiss.Map.put_if(%{life: 42}, :life, nil)
      %{life: 42}

      iex> Swiss.Map.put_if(%{life: 42}, :life, nil, &is_nil/1)
      %{life: nil}

      iex> Swiss.Map.put_if(%{life: 42}, :life, 22, &(&1 < 55))
      %{life: 22}

      iex> Swiss.Map.put_if(%{life: 42}, :life, 30, true)
      %{life: 30}

      iex> Swiss.Map.put_if(%{life: 42}, :life, 30, false)
      %{life: 42}
  """
  @spec put_if(map(), any(), any(), (any() -> boolean()) | boolean()) :: map()
  def put_if(map, key, value, pred \\ fn v -> !is_nil(v) end)

  def put_if(map, key, value, pred) when is_function(pred, 1),
    do: put_if(map, key, value, pred.(value))

  def put_if(map, key, value, condition) when is_boolean(condition) do
    if condition,
      do: Map.put(map, key, value),
      else: map
  end

  @doc """
  Runs `Map.put/3` only if `cond` is truthy. Unlike `Swiss.Map.put_if/4`, takes
  a function that is called when the condition passes, that should return the
  value to insert in the map.

  ## Examples

      iex> Swiss.Map.put_if_lazy(%{life: 42}, :life, fn -> 12 end, true)
      %{life: 12}

      iex> Swiss.Map.put_if_lazy(%{life: 42}, :life, fn -> 12 end, false)
      %{life: 42}

  """
  @spec put_if_lazy(map(), any(), (() -> any()), any()) :: map()
  def put_if_lazy(map, key, value_fn, condition) do
    if condition do
      Map.put(map, key, value_fn.())
    else
      map
    end
  end

  @doc """
  Deep merges two maps. Only maps are merged, all other types are overridden.

  ## Examples

      iex> Swiss.Map.deep_merge(%{user: %{id: 42}}, %{user: %{name: "João"}})
      %{user: %{id: 42, name: "João"}}

      iex> Swiss.Map.deep_merge(
      ...> %{user: %{id: 42, message: %{id: 22}}},
      ...> %{user: %{message: %{text: "hi"}}},
      ...> 1
      ...> )
      %{user: %{id: 42, message: %{text: "hi"}}}

      iex> Swiss.Map.deep_merge(
      ...> %{user: %{id: 42}, messages: [%{id: 1}]},
      ...> %{user: %{id: 30, age: 40}, messages: [%{id: 2}]}
      ...> )
      %{user: %{id: 30, age: 40}, messages: [%{id: 2}]}

  """
  @spec deep_merge(map(), map(), non_neg_integer() | :infinity) :: map()
  def deep_merge(map_dest, map_src, max_depth \\ :infinity) do
    deep_merge(map_dest, map_src, max_depth, 0)
  end

  defp deep_merge(map_dest, map_src, max_depth, depth)
       when is_number(max_depth) and max_depth <= depth do
    Map.merge(map_dest, map_src)
  end

  defp deep_merge(map_dest, map_src, max_depth, depth) do
    Map.merge(map_dest, map_src, fn
      _key, value_dest, value_src when is_map(value_dest) and is_map(value_src) ->
        deep_merge(value_dest, value_src, max_depth, depth + 1)

      _key, _value_dest, value_src ->
        value_src
    end)
  end

  @doc """
  Cuts off a map at the given depth.

  This works for nested maps, but also maps nested in lists and tuples.

  ## Options

  * `max_depth´: Depth at which to cut off at. Defaults to 1.
  * `placeholder`: Map values that would exceed the maximum depth are replaced by a placeholder. Defaults to an empty map.

  ## Examples

      iex> Swiss.Map.cut_depth(%{a: 1, b: 2})
      %{a: 1, b: 2}

      iex> Swiss.Map.cut_depth(%{a: %{c: 3, d: 4}, b: 2})
      %{a: %{}, b: 2}

      iex> Swiss.Map.cut_depth(%{a: %{c: 3, d: 4}, b: 2}, max_depth: 2)
      %{a: %{c: 3, d: 4}, b: 2}

      iex> Swiss.Map.cut_depth(%{a: %{c: 3, d: 4}, b: 2}, placeholder: "%{...}")
      %{a: "%{...}", b: 2}

      iex> Swiss.Map.cut_depth(%{a: [1, 2], b: [%{a: 2}], c: {1, 2}, d: {%{a: 5}}})
      %{a: [1, 2], b: [%{}], c: {1, 2}, d: {%{}}}
  """
  @spec cut_depth(map :: map(), opts :: cut_depth_opts) :: map
        when cut_depth_opts: [max_depth: non_neg_integer(), placeholder: any()]
  def cut_depth(map, opts \\ []) when is_map(map) do
    max_depth = opts[:max_depth] || 1
    placeholder = opts[:placeholder] || %{}

    cut_depth(map, max_depth, placeholder)
  end

  defp cut_depth(value, max_depth, placeholder, depth \\ 1)

  defp cut_depth(lst, max_depth, placeholder, depth) when is_list(lst),
    do: Enum.map(lst, &cut_depth(&1, max_depth, placeholder, depth))

  defp cut_depth(map, max_depth, placeholder, depth) when is_map(map) do
    if depth > max_depth do
      placeholder
    else
      map
      |> Enum.map(fn {key, map_value} ->
        {key, cut_depth(map_value, max_depth, placeholder, depth + 1)}
      end)
      |> Enum.into(%{})
    end
  end

  defp cut_depth(tuple, max_depth, placeholder, depth) when is_tuple(tuple) do
    tuple
    |> Tuple.to_list()
    |> cut_depth(max_depth, placeholder, depth)
    |> List.to_tuple()
  end

  defp cut_depth(value, _max_depth, _placeholder, _depth),
    do: value

  @doc """
  Appplies an updater function to all keys in the given map.

  The updater function receives a `{key, value}` tuple and may return a new
  value, or a new `{key, value}` tuple.

  ## Examples

      iex> Swiss.Map.update_all(%{a: 1, b: 2}, &(elem(&1, 1) * 2))
      %{a: 2, b: 4}

      iex> Swiss.Map.update_all(%{a: 1, b: 2}, &{Atom.to_string(elem(&1, 0)), elem(&1, 1) * 3})
      %{"a" => 3, "b" => 6}

  """
  @spec update_all(map(), ({any(), any()} -> {any(), any()} | any())) :: map()
  def update_all(map, updater) do
    Enum.reduce(map, %{}, fn {key, value}, acc ->
      case updater.({key, value}) do
        {new_key, new_value} -> Map.put(acc, new_key, new_value)
        new_value -> Map.put(acc, key, new_value)
      end
    end)
  end
end