lib/type_check/builtin/compound_fixed_map.ex

defmodule TypeCheck.Builtin.CompoundFixedMap do
  @moduledoc """
  Special type for map-typespecs that contain a combination of fixed keys as well as an `optional(...)` or `required(...)` part.

  Its checks compile down to a combination of `TypeCheck.Builtin.FixedMap` and `TypeCheck.Builtin.Map`
  for the fixed resp. non-fixed parts.
  """

  defstruct [:fixed, :flexible]

  use TypeCheck

  @type! t :: %__MODULE__{
           fixed: TypeCheck.Builtin.FixedMap.t(),
           flexible: TypeCheck.Builtin.Map.t() | TypeCheck.Builtin.OptionalFixedMap.t()
         }

  @type! problem_tuple ::
           {t(), :not_a_map, %{}, any()}
           | {t(), :missing_keys, %{keys: list(atom())}, map()}
           | {t(), :superfluous_keys, %{keys: list(atom())}, map()}
           | {t(), :value_error,
              %{problem: lazy(TypeCheck.TypeError.Formatter.problem_tuple()), key: any()}, map()}
           | {t(), :key_error,
              %{problem: lazy(TypeCheck.TypeError.Formatter.problem_tuple()), key: any()}, map()}

  defimpl TypeCheck.Protocols.ToCheck do
    def to_check(s = %TypeCheck.Builtin.CompoundFixedMap{}, param) do
      # NOTE: We cannot use Keyword.keys here since the keys are not atoms but arbitrary values
      fixed_keys = s.fixed.keypairs |> Enum.map(fn {key, _val} -> key end)
      fixed_part_var = Macro.var(:fixed_part, __MODULE__)
      flexible_part_var = Macro.var(:flexible_part, __MODULE__)

      res =
        quote generated: true, location: :keep do
          with {:ok, _, _} <- unquote(map_check(param, s)),
               {unquote(fixed_part_var), unquote(flexible_part_var)} =
                 Map.split(unquote(param), unquote(fixed_keys)),
               {:ok, bindings1, fixed_part} <-
                 unquote(TypeCheck.Protocols.ToCheck.to_check(s.fixed, fixed_part_var)),
               {:ok, bindings2, flexible_part} <-
                 unquote(TypeCheck.Protocols.ToCheck.to_check(s.flexible, flexible_part_var)) do
            {:ok, bindings1 ++ bindings2, Map.merge(fixed_part, flexible_part)}
          else
            {:error, {_, reason, info, _val}} ->
              {:error,
               {unquote(TypeCheck.Internals.Escaper.escape(s)), reason, info, unquote(param)}}
          end
        end

      res
    end

    defp map_check(param, s) do
      quote generated: true, location: :keep do
        case unquote(param) do
          val when is_map(val) ->
            {:ok, [], val}

          other ->
            {:error, {unquote(TypeCheck.Internals.Escaper.escape(s)), :not_a_map, %{}, other}}
        end
      end
    end
  end

  defimpl TypeCheck.Protocols.Inspect do
    def inspect(s, opts) do
      import Inspect.Algebra, only: [color: 3, concat: 1, container_doc: 6, group: 1, break: 1]

      fixed_keypairs_str =
        container_doc("", s.fixed.keypairs, "", opts, &to_map_kv(&1, &2),
          separator: color(",", :map, opts),
          break: :strict
        )

      flexible_str =
        case s.flexible do
          %TypeCheck.Builtin.Map{} = flexible ->
            flexible_key_str = TypeCheck.Protocols.Inspect.inspect(flexible.key_type, opts)
            flexible_val_str = TypeCheck.Protocols.Inspect.inspect(flexible.value_type, opts)

            concat([
              color("optional(", :map, opts),
              flexible_key_str,
              color(") => ", :map, opts),
              flexible_val_str
            ])

          %TypeCheck.Builtin.OptionalFixedMap{} = flexible ->
            for {key, value_type} <- flexible.keypairs do
              flexible_key_str = TypeCheck.Protocols.Inspect.inspect(key, opts)
              flexible_val_str = TypeCheck.Protocols.Inspect.inspect(value_type, opts)

              concat([
                color("optional(", :map, opts),
                flexible_key_str,
                color(") => ", :map, opts),
                flexible_val_str
              ])
            end
            |> Enum.intersperse(color(", ", :map, opts))
            |> concat()
        end

      concat([
        color("%{", :map, opts),
        group(concat([fixed_keypairs_str, color(", ", :map, opts), flexible_str])),
        color("}", :map, opts)
      ])
      |> color(:map, opts)
    end

    if function_exported?(Macro, :inspect_atom, 2) do
      # Elixir 1.14+
      defp inspect_as_key(key) do
        Macro.inspect_atom(:key, key)
      end
    else
      # Legacy Elixir
      defdelegate inspect_as_key(key), to: Code.Identifier
    end

    defp to_map_kv({key, value_type}, opts) do
      import Inspect.Algebra, only: [color: 3, concat: 2, to_doc: 2]
      value_doc = TypeCheck.Protocols.Inspect.inspect(value_type, opts)

      if non_module_atom?(key) do
        key = color(inspect_as_key(key), :atom, opts)
        concat(key, concat(" ", value_doc))
      else
        sep = color(" =>", :map, opts)
        concat(concat(to_doc(key, opts), sep), value_doc)
      end
    end

    defp non_module_atom?(val) do
      is_atom(val) and !match?(~c"Elixir." ++ _, Atom.to_charlist(val))
    end
  end

  defimpl TypeCheck.Protocols.Escape do
    def escape(s) do
      %{
        s
        | fixed: TypeCheck.Protocols.Escape.escape(s.fixed),
          flexible: TypeCheck.Protocols.Escape.escape(s.flexible)
      }
    end
  end

  if Code.ensure_loaded?(StreamData) do
    defimpl TypeCheck.Protocols.ToStreamData do
      def to_gen(s) do
        fixed_gen = TypeCheck.Protocols.ToStreamData.to_gen(s.fixed)
        flexible_gen = TypeCheck.Protocols.ToStreamData.to_gen(s.flexible)

        StreamData.map({fixed_gen, flexible_gen}, fn {fixed_map, flexible_map} ->
          Map.merge(fixed_map, flexible_map)
        end)
      end
    end
  end
end