lib/drops/types/map/dsl.ex

defmodule Drops.Types.Map.DSL do
  @moduledoc """
  DSL functions for defining map key and value type specifications.

  Functions from this module are typically used via Drops.Contract.schema/1
  """

  @type type() :: {:type, {atom(), keyword()}}

  @doc ~S"""
  Returns a required key specification.

  ## Examples
      %{
        required(:email) => type(:string)
      }
  """
  @doc since: "0.1.0"
  @spec required(atom()) :: {:required, atom()}
  def required(name) do
    {:required, name}
  end

  @doc ~S"""
  Returns an optional key specification.

  ## Examples
      %{
        optional(:age) => type(:integer)
      }
  """
  @doc since: "0.1.0"
  @spec optional(atom()) :: {:optional, atom()}
  def optional(name) do
    {:optional, name}
  end

  @doc ~S"""
  Returns a type cast specification.


  ## Examples

      # cast a string to an integer
      cast(:string) |> integer()

      # cast a string to an integer with additional constraints
      cast(string(match?: ~r/\d+/])) |> integer()

  """
  @doc since: "0.1.0"
  @spec cast(type(), Keyword.t()) :: {:cast, {type(), Keyword.t()}}
  def cast(type, cast_opts \\ []) do
    {:cast, {type, cast_opts}}
  end

  @doc ~S"""
  Returns a type specification.

  ## Examples

      # string
      type(:string)

      # either a nil or a string
      type([:nil, :string])
  """
  @doc since: "0.1.0"

  @spec type({atom(), []}) :: type()
  @spec type(list: atom()) :: type()
  @spec type(list: []) :: type()
  @spec type([atom()]) :: [type()]
  @spec type(atom()) :: type()

  def type(list: members) when is_map(members) or is_tuple(members) do
    {:type, {:list, members}}
  end

  def type(list: [type | predicates]) do
    {:type, {:list, type(type, predicates)}}
  end

  def type({type, predicates}) when is_atom(type) do
    type(type, predicates)
  end

  def type([type | rest]) do
    case rest do
      [] -> type(type)
      _ -> {:sum, {type(type), type(rest)}}
    end
  end

  def type(type) do
    {:type, {type, []}}
  end

  @doc ~S"""
  Returns a type specification with additional constraints.

  ## Examples

      # string with that must be filled
      type(:string, [:filled?]),

      # an integer that must be greater than 18
      type(:integer, [gt?: 18])

  """
  @doc since: "0.1.0"

  @spec type(atom(), []) :: type()
  @spec type({:cast, {atom(), []}}, type()) :: type()

  def type([type | rest], predicates) when is_list(predicates) do
    case rest do
      [] -> type(type, predicates)
      _ -> {:sum, {type(type, predicates), type(rest, predicates)}}
    end
  end

  def type(type, predicates) when is_list(predicates) do
    {:type, {type, predicates}}
  end

  def type({:cast, {input_type, cast_opts}}, output_type)
      when is_tuple(input_type) and is_tuple(output_type) do
    {:cast, {input_type, output_type, cast_opts}}
  end

  def type({:cast, {input_type, cast_opts}}, output_type) when is_atom(output_type) do
    {:cast, {type(input_type), type(output_type), cast_opts}}
  end

  def type({:cast, {input_type, cast_opts}}, output_type) do
    {:cast, {type(input_type), output_type, cast_opts}}
  end

  @doc ~S"""
  Returns a list type specification.

  ## Examples

      # a list with a specified member type
      list(:string)

      # a list with a specified sum member type
      list([:string, :integer])

  """
  @doc since: "0.1.0"

  @spec list([atom()]) :: type()

  def list(members) when is_map(members) or is_tuple(members) do
    type(list: members)
  end

  @doc ~S"""
  Returns a list type specification with a constrained member type

  ## Examples

      # a list with a specified member type
      list(:string, [:filled?])

      list(:integer, [gt?: 18])

  """
  @doc since: "0.1.0"

  @spec list([atom()]) :: type()

  def list(type, predicates \\ []) when is_list(predicates) do
    type(list: [type | predicates])
  end

  @doc ~S"""
  Returns :any type specification.

  ## Examples

      any()

  """
  @doc since: "0.1.0"

  @spec any() :: type()

  def any() do
    type(:any)
  end

  @doc ~S"""
  Returns a maybe type specification.

  ## Examples

      # either a nil or a string
      maybe(:string)

  """
  @doc since: "0.1.0"

  @spec maybe(atom()) :: type()
  @spec maybe(map()) :: [type()]

  def maybe(schema) when is_map(schema) do
    {:sum, {type(nil), schema}}
  end

  @doc ~S"""
  Returns a maybe type specification with additional constraints.

  ## Examples

      # either a nil or a non-empty string
      maybe(:string, [:filled?])

  """
  @doc since: "0.1.0"

  @spec maybe(atom(), []) :: type()

  def maybe(type, predicates \\ []) do
    type([nil, {type, predicates}])
  end

  @doc ~S"""
  Returns a string type specification.

  ## Examples

      # a string with no constraints
      string()

  """
  @doc since: "0.1.0"

  @spec string() :: type()

  def string() do
    type(:string)
  end

  @doc ~S"""
  Returns a string type specification with additional constraints.

  ## Examples

      # a string with constraints
      string(:filled?)

      # a string with multiple constraints
      string([:filled?, max_length?: 255])

  """
  @doc since: "0.1.0"

  @spec string(atom()) :: type()
  @spec string([]) :: type()

  def string(predicate) when is_atom(predicate) do
    string([predicate])
  end

  def string(predicates) when is_list(predicates) do
    type(:string, predicates)
  end

  def string({:cast, _} = cast_spec, predicates \\ []) do
    type(cast_spec, string(predicates))
  end

  @doc ~S"""
  Returns an integer type specification.

  ## Examples

      # an integer with no constraints
      integer()

  """
  @doc since: "0.1.0"

  @spec integer() :: type()

  def integer() do
    type(:integer)
  end

  @doc ~S"""
  Returns an integer type specification with additional constraints.

  ## Examples

      # an integer with constraints
      integer(:even?)

      # an integer with multiple constraints
      integer([:even?, gt?: 100])

  """
  @doc since: "0.1.0"

  @spec integer(atom()) :: type()
  @spec integer([]) :: type()

  def integer(predicate) when is_atom(predicate) do
    integer([predicate])
  end

  def integer(predicates) when is_list(predicates) do
    type(:integer, predicates)
  end

  def integer({:cast, _} = cast_spec, predicates \\ []) do
    type(cast_spec, integer(predicates))
  end

  @doc ~S"""
  Returns a float type specification.

  ## Examples

      # a float with no constraints
      float()

  """
  @doc since: "0.1.0"

  @spec float() :: type()

  def float() do
    type(:float)
  end

  @doc ~S"""
  Returns a float type specification with additional constraints.

  ## Examples

      # a float with constraints
      float(gt?: 1.0)

  """
  @doc since: "0.1.0"

  @spec float([]) :: type()

  def float(predicates) when is_list(predicates) do
    type(:float, predicates)
  end

  def float({:cast, _} = cast_spec, predicates \\ []) do
    type(cast_spec, float(predicates))
  end

  @doc ~S"""
  Returns a boolean type specification.

  ## Examples

      # a boolean with no constraints
      boolean()

  """
  @doc since: "0.1.0"

  @spec boolean() :: type()

  def boolean() do
    type(:boolean)
  end

  @doc ~S"""
  Returns a map type specification.

  ## Examples

      # a map with no constraints
      map()

  """
  @doc since: "0.1.0"

  @spec map() :: type()

  def map() do
    type(:map)
  end

  @doc ~S"""
  Returns a map type specification with additional constraints.

  ## Examples

      # a map with constraints
      map(min_size?: 2)

  """
  @doc since: "0.1.0"

  @spec map(atom()) :: type()
  @spec map([]) :: type()

  def map(predicate) when is_atom(predicate) do
    map([predicate])
  end

  def map(predicates) when is_list(predicates) do
    type(:map, predicates)
  end
end