lib/opts.ex

defmodule Moar.Opts do
  # @related [test](/test/opts_test.exs)

  @moduledoc """
  Extracts keys and values from enumerables, especially from function options.

  There are three main functions, each of which takes an opts enumerable as input. `get/3` extracts
  one value from the opts with an optional default value. `pop/3` is like `get/3` but removes the opt from opts
  and returns the opt and the remaining opts as a tuple. `take/2` extracts multiple values from the opts
  with optional default values for some or all keys.

  `get/3`, `pop/3`, and `take/2` differ from their `Map` and `Keyword` counterparts in the following ways:
  * `get/3`, `pop/3`, and `take/2` accept any enumerable, including maps, keyword lists, and mixed lists like
    `[:a, :b, c: 3, d: 4]`.
  * `get/3`, `pop/3`, and `take/2` will fall back to the default value if the given key's value is blank
    as defined by `Moar.Term.blank?/1` (`nil`, empty strings, strings made up only of whitespace,
    empty lists, and empty maps), and will default valueless keys (e.g., `:a` in `[:a, b: 2]`) to `true` unless a
    different default is specified. The corresponding `Map` and `Keyword` functions only fall back to
    the default value if the value is exactly `nil`, and `Keyword` functions don't support valueless keys.
  * `get/3`, `pop/3`, and `take/2` allow default values to be specified.
  * `take/2` will return the value for a requested key even if the key is not in the input enumerable.

  Example using `get/2` and `get/3`:

  ```elixir
  def build_url(path, opts \\\\ []) do
    %URI{
      path: path,
      host: Moar.Opts.get(opts, :host, "localhost"),
      port: Moar.Opts.get(opts, :port),
      scheme: "https"
    } |> URI.to_string()
  end
  ```

  Examples using `take/2`:

  ```elixir
  # example using pattern matching
  def build_url(path, opts \\ []) do
    %{host: h, port: p} = Moar.Opts.take(opts, [:port, host: "localhost"])
    %URI{path: path, host: h, port: p, scheme: "https"} |> URI.to_string()
  end

  # example rebinding `opts` to the parsed opts
  def build_url(path, opts \\ []) do
    opts = Moar.Opts.take(opts, [:port, host: "localhost"])
    %URI{path: path, host: opts.host, port: opts.port, scheme: "https"} |> URI.to_string()
  end
  ```
  """

  @doc """
  Deletes an opt given a key, a {key, value}, or a function that accepts opts and a map, list, or term.

  ```elixir
  iex> Moar.Opts.delete(%{a: 1, b: 2}, :a)
  %{b: 2}

  iex> Moar.Opts.delete([a: 1, b: 2], :a)
  [b: 2]

  iex> Moar.Opts.delete([:a, b: 2], :a)
  [b: 2]

  iex> Moar.Opts.delete([a: 1, b: 2], :a, 1)
  [b: 2]

  iex> Moar.Opts.delete([a: 1, b: 2], :a, 99)
  [a: 1, b: 2]

  iex> Moar.Opts.delete([:a, b: 2], :a, 1)
  [:a, b: 2]

  iex> Moar.Opts.delete([:trim, :downcase, :reverse], fn k -> k == :downcase end)
  [:trim, :reverse]

  iex> Moar.Opts.delete([a: 1, b: 2, c: 3, d: 4], fn {_k, v} -> Integer.mod(v, 2) == 0 end)
  [a: 1, c: 3]
  ```
  """
  @spec delete(map() | list(), any()) :: map() | list()
  @spec delete(map() | list(), any(), any()) :: map() | list()

  def delete(input, fun) when is_function(fun) and is_map(input) do
    Enum.reduce(input, %{}, fn
      {k, v}, acc -> if fun.({k, v}), do: acc, else: Map.put(acc, k, v)
    end)
  end

  def delete(input, fun) when is_function(fun) and is_list(input) do
    List.foldr(input, [], fn
      {k, v}, acc -> if fun.({k, v}), do: acc, else: [{k, v} | acc]
      k, acc -> if fun.(k), do: acc, else: [k | acc]
    end)
  end

  def delete(input, fun) when is_function(fun) do
    if fun.(input),
      do: input,
      else: nil
  end

  def delete(input, key) do
    delete(input, fn
      {k, _v} -> k == key
      k -> k == key
    end)
  end

  def delete(input, key, value) do
    delete(input, fn
      {k, v} -> k == key && v == value
      _ -> false
    end)
  end

  @doc """
  Get the value of `key` from `input`, falling back to optional `default` if the key does not exist,
  or if its value is blank (via `Moar.Term.blank?/1`).

  ```elixir
  iex> [a: 1, b: 2] |> Moar.Opts.get(:a)
  1

  iex> [:a, b: 2] |> Moar.Opts.get(:a)
  true

  iex> [a: 1, b: 2] |> Moar.Opts.get(:c)
  nil

  iex> [a: 1, b: 2, c: ""] |> Moar.Opts.get(:c)
  nil

  iex> [a: 1, b: 2, c: %{}] |> Moar.Opts.get(:c)
  nil

  iex> [a: 1, b: 2, c: "   "] |> Moar.Opts.get(:c, 300)
  300
  ```
  """
  @spec get(Enum.t(), binary() | atom(), any()) :: any()
  def get(input, key, default \\ nil) do
    Enum.find_value(input, fn
      {k, v} -> k == key && Moar.Term.presence(v)
      k -> k == key && Moar.Term.presence(default, true)
    end) || default
  end

  @doc """
  Removes an opt from the opts (via `get/3`), returning `{opt, remaining_opts}`.

  ```elixir
  iex> [a: 1, b: 2] |> Moar.Opts.pop(:a)
  {1, [b: 2]}

  iex> [:a, b: 2] |> Moar.Opts.pop(:a)
  {true, [b: 2]}
  ```
  """
  @spec pop(Enum.t(), binary() | atom(), any()) :: {any(), Enum.t()}
  def pop(input, key, default \\ nil)

  def pop(input, key, default) when is_list(input) do
    value = get(input, key, default)

    list =
      cond do
        key in input -> List.delete(input, key)
        Keyword.has_key?(input, key) -> Keyword.delete(input, key)
        :else -> input
      end

    {value, list}
  end

  def pop(input, key, default) when is_map(input),
    do: {get(input, key, default), Map.delete(input, key)}

  @doc """
  Replace an opt.

  ```elixir
  iex> Moar.Opts.replace(%{a: 1, b: 2}, {:a, 1}, {:a, 100})
  %{a: 100, b: 2}

  iex> Moar.Opts.replace([a: 1, b: 2], {:a, 1}, {:a, 100})
  [a: 100, b: 2]

  iex> Moar.Opts.replace([:a, b: 2], :a, :aa)
  [:aa, b: 2]
  ```
  """
  @spec replace(map(), {any(), any()}, {any(), any()}) :: map()
  @spec replace(list(), {any(), any()} | any(), any()) :: list()

  def replace(opts, {key, value}, {replacement_key, replacement_value}) when is_map(opts) do
    Enum.reduce(opts, %{}, fn {k, v}, acc ->
      if key == k && value == v,
        do: Map.put(acc, replacement_key, replacement_value),
        else: Map.put(acc, k, v)
    end)
  end

  def replace(opts, {key, value}, replacement) when is_list(opts) do
    List.foldr(opts, [], fn {k, v}, acc ->
      if key == k && value == v,
        do: [replacement | acc],
        else: [{k, v} | acc]
    end)
  end

  def replace(opts, key, replacement) when is_list(opts) do
    List.foldr(opts, [], fn k, acc ->
      if key == k,
        do: [replacement | acc],
        else: [k | acc]
    end)
  end

  @doc """
  Get the value each key in `keys` from `input`, falling back to optional default values for keys that
  do not exist, or for values that are blank (via `Moar.Term.blank?/1`).

  If `key` does not exist in `keys`, returns `nil`, or returns the default value if provided.

  `keys` is a list of keys (e.g., `[:a, :b]`),
  a keyword list of keys and default values (e.g., `[a: 1, b: 2]`),
  or a hybrid list/keyword list (e.g., `[:a, b: 2]`)

  ```elixir
  iex> [a: 1, b: 2] |> Moar.Opts.take([:a, :c])
  %{a: 1, c: nil}

  iex> [:a, b: 2] |> Moar.Opts.take([:a, :c])
  %{a: true, c: nil}

  iex> [a: 1, b: 2] |> Moar.Opts.take([:a, b: 0, c: 3])
  %{a: 1, b: 2, c: 3}
  ```
  """
  @spec take(Enum.t(), list()) :: map()
  def take(input, keys) do
    Enum.reduce(keys, %{}, fn
      {key, default}, acc -> Map.put(acc, key, get(input, key, default))
      key, acc -> Map.put(acc, key, get(input, key))
    end)
  end
end