lib/type_check/builtin/fixed_list.ex

defmodule TypeCheck.Builtin.FixedList do
  @moduledoc """
  Checks whether the value is a list with the expected elements

  On failure returns a problem tuple with:
    - `:not_a_list` if the value is not a list
    - `:different_length` if the value is a list but not of equal size.
    - `:element_error` if one of the elements does not match. The extra information contains in this case `:problem` and `:index` to indicate what and where the problem occured.
  """

  defstruct [:element_types]

  use TypeCheck
  @type! t :: %__MODULE__{element_types: list(TypeCheck.Type.t())}

  @type! problem_tuple ::
           {t(), :not_a_list, %{}, any()}
           | {t(), :different_length, %{expected_length: non_neg_integer()}, list()}
           | {t(), :element_error,
              %{
                problem: lazy(TypeCheck.TypeError.Formatter.problem_tuple()),
                index: non_neg_integer()
              }, list()}

  defimpl TypeCheck.Protocols.ToCheck do
    def to_check(s, param) do
      expected_length = length(s.element_types)
      element_checks_ast = build_element_checks_ast(s.element_types, param, s)

      quote generated: true, location: :keep do
        case unquote(param) do
          x when not is_list(x) ->
            {:error, {unquote(Macro.escape(s)), :not_a_list, %{}, x}}

          x when length(x) != unquote(expected_length) ->
            {:error,
             {unquote(Macro.escape(s)), :different_length,
              %{expected_length: unquote(expected_length)}, x}}

          _ ->
            unquote(element_checks_ast)
        end
      end
    end

    def build_element_checks_ast(element_types, param, s) do
      element_checks =
        element_types
        |> Enum.with_index()
        |> Enum.flat_map(fn {element_type, index} ->
          impl =
            TypeCheck.Protocols.ToCheck.to_check(
              element_type,
              quote generated: true, location: :keep do
                hd(var!(rest, unquote(__MODULE__)))
              end
            )

          quote generated: true, location: :keep do
            [
              {{:ok, element_bindings, altered_element}, index, var!(rest, unquote(__MODULE__))} <-
                {unquote(impl), unquote(index), tl(var!(rest, unquote(__MODULE__)))},
              bindings = element_bindings ++ bindings,
              altered_param = [altered_element | altered_param]
            ]
          end
        end)

      quote generated: true, location: :keep do
        bindings = []
        altered_param = []

        with var!(rest, unquote(__MODULE__)) = unquote(param),
             unquote_splicing(element_checks),
             altered_param = :lists.reverse(altered_param) do
          {:ok, bindings, altered_param}
        else
          {{:error, error}, index, _rest} ->
            {:error,
             {unquote(Macro.escape(s)), :element_error, %{problem: error, index: index},
              unquote(param)}}
        end
      end
    end
  end

  defimpl TypeCheck.Protocols.Escape do
    def escape(s) do
      update_in(
        s.element_types,
        &Enum.map(&1, fn val -> TypeCheck.Protocols.Escape.escape(val) end)
      )
    end
  end

  defimpl TypeCheck.Protocols.Inspect do
    def inspect(s, opts) do
      s.element_types
      |> Elixir.Inspect.inspect(%Inspect.Opts{
        opts
        | inspect_fun: &TypeCheck.Protocols.Inspect.inspect/2
      })
      |> Inspect.Algebra.color(:builtin_type, opts)
    end
  end

  if Code.ensure_loaded?(StreamData) do
    defimpl TypeCheck.Protocols.ToStreamData do
      def to_gen(s) do
        s.element_types
        |> Enum.map(&TypeCheck.Protocols.ToStreamData.to_gen/1)
        |> StreamData.fixed_list()
      end
    end
  end
end