lib/nimble_options_ex.ex

defmodule NimbleOptionsEx do
  @moduledoc """
  Set of custom functions to enhance the UX with `NimbleOptions`.
  """

  @doc """
  Validates a behaviour specified as a module or as a set of functions.

  _Example:_

      iex> schema = [
      ...>   container: [
      ...>     required: true,
      ...>     type: {:custom, NimbleOptionsEx, :behaviour, [Supervisor]},
      ...>     doc: "The implementation of `Supervisor` to be used as a supervisor"
      ...>   ]
      ...> ]
      iex> NimbleOptions.validate([container: DynamicSupervisor], schema)
      {:ok, [container: DynamicSupervisor]}
      iex> NimbleOptions.validate([container: DateTime], schema)
      {:error, %NimbleOptions.ValidationError{
        message: "invalid value for :container option: module ‹DateTime› does not implement requested callbacks ‹[init: 1]›",
        key: :container, value: DateTime, keys_path: []}}

  If no arguments are given, the checker would make sure the module
    passed is indeed a module, available at the moment of invocation.

  ---

  Please note, that `Access` behaviour is somewhat special, because
    it’s supported for _terms_, such as maps `%{foo: :bar}` (not `Map`,)
    keywords `[foo: :bar]` (not `Keyword`,) and structs `%Strct{foo: :bar}`
    (not `Strct` module itself.) Use `access?/1` validator to check
    whether `Access` is supported _by a term itself_.
  """
  @spec behaviour(module() | any(), module() | []) ::
          {:ok, validated_option :: module()} | {:error, String.t()}
  def behaviour(value, funs_or_behaviour \\ [])

  def behaviour(value, behaviour) when not is_atom(value) do
    {:error,
     "expected to be a module, implementing a behaviour, " <>
       "specified by ‹" <>
       inspect(behaviour) <>
       "›, got: ‹" <>
       inspect(value) <> "›"}
  end

  def behaviour(value, behaviour) when is_atom(behaviour) do
    with {:module, ^behaviour} <- Code.ensure_compiled(behaviour),
         true <- function_exported?(behaviour, :behaviour_info, 1),
         funs when is_list(funs) <- behaviour.behaviour_info(:callbacks) do
      behaviour(value, funs)
    else
      _ ->
        {:error, "schema is invalid, ‹" <> inspect(behaviour) <> "› is not a behaviour"}
    end
  end

  def behaviour(value, funs) when is_list(funs) do
    case Code.ensure_compiled(value) do
      {:module, ^value} ->
        funs
        |> Enum.reject(fn {fun, arity} -> function_exported?(value, fun, arity) end)
        |> case do
          [] ->
            {:ok, value}

          not_exported when is_list(not_exported) ->
            {:error,
             "module ‹" <>
               inspect(value) <>
               "› does not implement requested callbacks ‹" <> inspect(not_exported) <> "›"}
        end

      {:error, error} ->
        {:error, "cannot find the requested module ‹" <> inspect(value) <> "› (#{error})"}
    end
  end

  @doc """
  Validates that the term passed implements `Access` behaviour.

  _Example:_

      iex> schema = [
      ...>   container: [
      ...>     required: true,
      ...>     type: {:custom, NimbleOptionsEx, :access?, []},
      ...>     doc: "The implementation of `Access` to be used as a storage"
      ...>   ]
      ...> ]
      iex> NimbleOptions.validate([container: %{}], schema)
      {:ok, [container: %{}]}
      iex> NimbleOptions.validate([container: [foo: :bar]], schema)
      {:ok, [container: [foo: :bar]]}
      iex> NimbleOptions.validate([container: [1, 2, 3]], schema)
      {:error, %NimbleOptions.ValidationError{
        message: "invalid value for :container option: expected a keyword list, got a list ‹[1, 2, 3]›",
        key: :container, value: [1, 2, 3], keys_path: []}}
  """
  @spec access?(term()) ::
          {:ok, validated_option :: module()} | {:error, String.t()}
  def access?(nil), do: {:ok, nil}

  def access?(value) when is_list(value) do
    if Keyword.keyword?(value),
      do: {:ok, value},
      else: {:error, "expected a keyword list, got a list ‹" <> inspect(value) <> "›"}
  end

  def access?(%type{} = value) do
    with {:ok, ^type} <- behaviour(type, Access), do: {:ok, value}
  end

  def access?(%{} = value), do: {:ok, value}
end