lib/draft/validator/length.ex

defmodule Draft.Validator.Length do
    @moduledoc """
    Ensure a value's length meets a constraint.

    ## Options

    At least one of the following must be provided:

    * `:min`: The value is at least this long
    * `:max`: The value is at most this long
    * `:in`: The value's length is within this Range
    * `:is`: The value's length is exactly this amount.

    The length for `:is` can be provided instead of the options keyword list.
    The `:is` is available for readability purposes.

    Optional:

    * `:tokenizer`: A function with arity 1 used to split up a
        value for length checking. By default binarys are broken up using
        `String.graphemes` and all other values (eg, lists) are
        passed through intact. See `Draft.Validator.tokens/1`.
    * `:message`: Optional. A custom error message. May be in EEx format
        and use the fields described in "Custom Error Messages," below.

    ## Examples

        iex> Draft.Validator.Length.validate("foo", 3)
        {:ok, "foo"}

        iex> Draft.Validator.Length.validate("foo", 2)
        {:error, "must have a length of 2"}

        iex> Draft.Validator.Length.validate(nil, [is: 2, allow_nil: true])
        {:ok, nil}

        iex> Draft.Validator.Length.validate("", [is: 2, allow_blank: true])
        {:ok, ""}

        iex> Draft.Validator.Length.validate("foo", min: 2, max: 8)
        {:ok, "foo"}

        iex> Draft.Validator.Length.validate("foo", min: 4)
        {:error, "must have a length of at least 4"}

        iex> Draft.Validator.Length.validate("foo", max: 2)
        {:error, "must have a length of no more than 2"}

        iex> Draft.Validator.Length.validate("foo", max: 2, message: "must be the right length")
        {:error, "must be the right length"}

        iex> Draft.Validator.Length.validate("foo", is: 3)
        {:ok, "foo"}

        iex> Draft.Validator.Length.validate("foo", is: 2)
        {:error, "must have a length of 2"}

        iex> Draft.Validator.Length.validate("foo", in: 1..6)
        {:ok, "foo"}

        iex> Draft.Validator.Length.validate("foo", in: 8..10)
        {:error, "must have a length between 8 and 10"}

        iex> Draft.Validator.Length.validate("four words are here", max: 4, tokenizer: &String.split/1)
        {:ok, "foor"}

    ## Custom Error Messages

    Custom error messages (in EEx format), provided as :message, can use the following values:

        iex> Draft.Validator.Length.__validator__(:message_fields)
        [value: "Bad value", tokens: "Tokens from value", size: "Number of tokens", min: "Minimum acceptable value", max: "Maximum acceptable value"]

    An example:

        iex> Draft.Validator.Length.validate("hello my darling", min: 4, tokenizer: &String.split/1,
        ...> message: "<%= length tokens %> words isn't enough")
        {:error, "3 words isn't enough"}

    """
    use Draft.Validator

    @message_fields [
        value: "Bad value",
        tokens: "Tokens from value",
        size: "Number of tokens",
        min: "Minimum acceptable value",
        max: "Maximum acceptable value"
    ]
    def validate(value, options) when is_integer(options), do: validate(value, is: options)
    def validate(value, min..max//-1), do: validate(value, in: min..max)

    def validate(value, options) when is_list(options) do
        unless_skipping(value, options) do
            tokenizer = Keyword.get(options, :tokenizer, &tokens/1)
            tokens = tokenizer.(value)
            size = Kernel.length(tokens)
            {lower, upper} = limits = bounds(options)

            {has_errors, default_message} =
                case limits do
                    {nil, nil} ->
                        raise "Missing length validation range"

                    {same, same} ->
                        {size == same, "must have a length of #{same}"}

                    {nil, max} ->
                        {size <= max, "must have a length of no more than #{max}"}

                    {min, nil} ->
                        {min <= size, "must have a length of at least #{min}"}

                    {min, max} ->
                        {min <= size and size <= max, "must have a length between #{min} and #{max}"}
                end


            msg_params = [
                tokens: tokens,
                value: value,
                size: size,
                min: lower,
                max: upper
            ]

            result(has_errors, value, message(options, default_message, msg_params))
        end
    end

    defp bounds(options) do
        is = Keyword.get(options, :is)
        min = Keyword.get(options, :min)
        max = Keyword.get(options, :max)
        range = Keyword.get(options, :in)

        cond do
            is -> {is, is}
            min -> {min, max}
            max -> {min, max}
            range -> {range.first, range.last}
            true -> {nil, nil}
        end
    end

    defp tokens(value) when is_binary(value), do: String.graphemes(value)
    defp tokens(value), do: value

    defp result(true, value, _), do: {:ok, value}
    defp result(false, _value, message), do: {:error, message}

end