lib/type_check/type_error/default_formatter.ex

defmodule TypeCheck.TypeError.DefaultFormatter do
  @behaviour TypeCheck.TypeError.Formatter

  @spec format(TypeCheck.TypeError.problem_tuple(), TypeCheck.TypeError.location()) :: String.t()
  def format(problem_tuple, location \\ []) do
    res =
      do_format(problem_tuple)
      # Ensure we start with four spaces, which multi-line exception pretty-printing expects
      |> indent()
      |> indent()

    (location_string(location) <> res)
    |> String.trim()
  end

  defp location_string([]), do: ""

  defp location_string(location) do
    raw_file = location[:file]
    line = location[:line]

    file = String.replace_prefix(raw_file, File.cwd!() <> "/", "")
    "At #{file}:#{line}:\n"
  end

  @doc """
  Transforms a `problem_tuple` into a humanly-readable explanation string.

  C.f. `TypeCheck.TypeError.Formatter` for more information about problem tuples.
  """
  @spec do_format(TypeCheck.TypeError.Formatter.problem_tuple()) :: String.t()
  def do_format(problem_tuple)

  def do_format({%TypeCheck.Builtin.Atom{}, :no_match, _, val}) do
    "`#{inspect(val, inspect_value_opts())}` is not an atom."
  end

  def do_format({%TypeCheck.Builtin.Binary{}, :no_match, _, val}) do
    "`#{inspect(val, inspect_value_opts())}` is not a binary."
  end

  def do_format({%TypeCheck.Builtin.Bitstring{}, :no_match, _, val}) do
    "`#{inspect(val, inspect_value_opts())}` is not a bitstring."
  end

  def do_format({%TypeCheck.Builtin.SizedBitstring{}, :no_match, _, val}) do
    "`#{inspect(val, inspect_value_opts())}` is not a bitstring."
  end

  def do_format({s = %TypeCheck.Builtin.SizedBitstring{}, :wrong_size, _, val}) do
    cond do
      s.unit_size == nil ->
        "`#{inspect(val, inspect_value_opts())}` has a different bit_size (#{bit_size(val)}) than expected (#{s.prefix_size})."

      s.prefix_size == 0 ->
        "`#{inspect(val, inspect_value_opts())}` has a different bit_size (#{bit_size(val)}) than expected (_ * #{s.unit_size})."

      true ->
        "`#{inspect(val, inspect_value_opts())}` has a different bit_size (#{bit_size(val)}) than expected (#{s.prefix_size} + _ * #{s.unit_size})."
    end
  end

  def do_format({%TypeCheck.Builtin.Boolean{}, :no_match, _, val}) do
    "`#{inspect(val, inspect_value_opts())}` is not a boolean."
  end

  def do_format({s = %TypeCheck.Builtin.FixedList{}, :not_a_list, _, val}) do
    problem = "`#{inspect(val, inspect_value_opts())}` is not a list."
    compound_check(val, s, problem)
  end

  def do_format(
        {s = %TypeCheck.Builtin.FixedList{}, :different_length,
         %{expected_length: expected_length}, val}
      ) do
    problem =
      "`#{inspect(val, inspect_value_opts())}` has #{length(val)} elements rather than #{expected_length}."

    compound_check(val, s, problem)
  end

  def do_format(
        {s = %TypeCheck.Builtin.FixedList{}, :element_error, %{problem: problem, index: index},
         val}
      ) do
    compound_check(val, s, "at index #{index}:\n", do_format(problem))
  end

  def do_format({s = %maplike{}, :not_a_map, _, val})
      when maplike in [
             TypeCheck.Builtin.FixedMap,
             TypeCheck.Builtin.CompoundFixedMap,
             TypeCheck.Builtin.OptionalFixedMap
           ] do
    problem = "`#{inspect(val, inspect_value_opts())}` is not a map."
    compound_check(val, s, problem)
  end

  def do_format({s = %maplike{}, :missing_keys, %{keys: keys}, val})
      when maplike in [
             TypeCheck.Builtin.FixedMap,
             TypeCheck.Builtin.CompoundFixedMap,
             TypeCheck.Builtin.OptionalFixedMap
           ] do
    keys_str =
      keys
      |> Enum.map(&inspect/1)
      |> Enum.join(", ")

    problem =
      "`#{inspect(val, inspect_value_opts())}` is missing the following required key(s): `#{keys_str}`."

    compound_check(val, s, problem)
  end

  def do_format({s = %maplike{}, :superfluous_keys, %{keys: keys}, val})
      when maplike in [
             TypeCheck.Builtin.FixedMap,
             TypeCheck.Builtin.CompoundFixedMap,
             TypeCheck.Builtin.OptionalFixedMap
           ] do
    keys_str =
      keys
      |> Enum.map(&inspect/1)
      |> Enum.join(", ")

    problem =
      "`#{inspect(val, inspect_value_opts())}` contains the following superfluous key(s): `#{keys_str}`."

    compound_check(val, s, problem)
  end

  def do_format({s = %maplike{}, :value_error, %{problem: problem, key: key}, val})
      when maplike in [
             TypeCheck.Builtin.FixedMap,
             TypeCheck.Builtin.CompoundFixedMap,
             TypeCheck.Builtin.OptionalFixedMap
           ] do
    compound_check(
      val,
      s,
      "under key `#{inspect(key, inspect_type_opts())}`:\n",
      do_format(problem)
    )
  end

  def do_format({%TypeCheck.Builtin.Float{}, :no_match, _, val}) do
    "`#{inspect(val, inspect_value_opts())}` is not a float."
  end

  def do_format({%TypeCheck.Builtin.Function{param_types: list}, :no_match, _, val})
      when is_list(list) and is_function(val) do
    {:arity, arity} = Function.info(val, :arity)

    "`#{inspect(val, inspect_value_opts())}` (arity #{arity}) is not a function of arity `#{length(list)}`."
  end

  def do_format({%TypeCheck.Builtin.Function{}, :no_match, _, val}) do
    "`#{inspect(val, inspect_value_opts())}` is not a function."
  end

  def do_format({s = %TypeCheck.Builtin.Guarded{}, :type_failed, %{problem: problem}, val}) do
    compound_check(val, s, do_format(problem))
  end

  def do_format({s = %TypeCheck.Builtin.Guarded{}, :guard_failed, %{bindings: bindings}, val}) do
    guard_str =
      Inspect.Algebra.format(
        Inspect.Algebra.color(
          Macro.to_string(s.guard),
          :builtin_type,
          struct(Inspect.Opts, inspect_type_opts())
        ),
        80
      )

    problem = """
    `#{guard_str}` evaluated to false or nil.
    bound values: #{inspect(bindings, inspect_type_opts())}
    """

    compound_check(val, s, "type guard:\n", problem)
  end

  def do_format({%TypeCheck.Builtin.Integer{}, :no_match, _, val}) do
    "`#{inspect(val, inspect_value_opts())}` is not an integer."
  end

  def do_format({%TypeCheck.Builtin.PosInteger{}, :no_match, _, val}) do
    "`#{inspect(val, inspect_value_opts())}` is not a positive integer."
  end

  def do_format({%TypeCheck.Builtin.NegInteger{}, :no_match, _, val}) do
    "`#{inspect(val, inspect_value_opts())}` is not a negative integer."
  end

  def do_format({%TypeCheck.Builtin.NonNegInteger{}, :no_match, _, val}) do
    "`#{inspect(val, inspect_value_opts())}` is not a non-negative integer."
  end

  def do_format({s = %listlike{}, :not_a_list, _, val})
      when listlike in [TypeCheck.Builtin.List, TypeCheck.Builtin.MaybeImproperList] do
    compound_check(val, s, "`#{inspect(val, inspect_value_opts())}` is not a list.")
  end

  def do_format({s = %listlike{}, :element_error, %{problem: problem, index: index}, val})
      when listlike in [TypeCheck.Builtin.List, TypeCheck.Builtin.MaybeImproperList] do
    compound_check(val, s, "at index #{index}:\n", do_format(problem))
  end

  def do_format(
        {s = %TypeCheck.Builtin.MaybeImproperList{}, :terminator_error, %{problem: problem}, val}
      ) do
    compound_check(val, s, "at the improper terminator of the list:\n", do_format(problem))
  end

  def do_format({%TypeCheck.Builtin.Literal{value: expected_value}, :not_same_value, %{}, val}) do
    "`#{inspect(val, inspect_value_opts())}` is not the same value as `#{inspect(expected_value, inspect_type_opts())}`."
  end

  def do_format({s = %TypeCheck.Builtin.Map{}, :not_a_map, _, val}) do
    compound_check(val, s, "`#{inspect(val, inspect_value_opts())}` is not a map.")
  end

  def do_format({s = %maplike{}, :key_error, %{problem: problem}, val})
      when maplike in [TypeCheck.Builtin.Map, TypeCheck.Builtin.CompoundFixedMap] do
    compound_check(val, s, "key error:\n", do_format(problem))
  end

  def do_format({s = %TypeCheck.Builtin.Map{}, :value_error, %{problem: problem, key: key}, val}) do
    compound_check(
      val,
      s,
      "under key `#{inspect(key, inspect_type_opts())}`:\n",
      do_format(problem)
    )
  end

  def do_format({s = %TypeCheck.Builtin.NamedType{}, :named_type, %{problem: problem}, val}) do
    child_str = indent(do_format(problem))

    """
    `#{inspect(val, inspect_value_opts())}` does not match the definition of the named type `#{Inspect.Algebra.format(Inspect.Algebra.color(to_string(s.name), :named_type, struct(Inspect.Opts, inspect_type_opts())), 80)}`
    which is: `#{TypeCheck.Inspect.inspect_binary(s, [show_long_named_type: true] ++ inspect_type_opts())}`. Reason:
    #{child_str}
    """

    # compound_check(val, s, do_format(problem))
  end

  def do_format({%TypeCheck.Builtin.None{}, :no_match, _, val}) do
    "`#{inspect(val, inspect_value_opts())}` does not match `none()` (no value matches `none()`)."
  end

  def do_format({%TypeCheck.Builtin.Number{}, :no_match, _, val}) do
    "`#{inspect(val, inspect_value_opts())}` is not a number."
  end

  def do_format({s = %TypeCheck.Builtin.OneOf{}, :all_failed, %{problems: problems}, val}) do
    message =
      problems
      |> Enum.with_index()
      |> Enum.map(fn {problem, index} ->
        """
        #{index})
        #{indent(do_format(problem))}
        """
      end)
      |> Enum.join("\n")

    compound_check(val, s, "all possibilities failed:\n", message)
  end

  def do_format({%TypeCheck.Builtin.PID{}, :no_match, _, val}) do
    "`#{inspect(val, inspect_value_opts())}` is not a pid."
  end

  def do_format({%TypeCheck.Builtin.Port{}, :no_match, _, val}) do
    "`#{inspect(val, inspect_value_opts())}` is not a port."
  end

  def do_format({%TypeCheck.Builtin.Reference{}, :no_match, _, val}) do
    "`#{inspect(val, inspect_value_opts())}` is not a reference."
  end

  def do_format({s = %TypeCheck.Builtin.Range{}, :not_an_integer, _, val}) do
    compound_check(val, s, "`#{inspect(val, inspect_value_opts())}` is not an integer.")
  end

  def do_format({s = %TypeCheck.Builtin.Range{range: range}, :not_in_range, _, val}) do
    compound_check(
      val,
      s,
      "`#{inspect(val, inspect_value_opts())}` falls outside the range #{inspect(range, inspect_type_opts())}."
    )
  end

  def do_format({s = %TypeCheck.Builtin.FixedTuple{}, :not_a_tuple, _, val}) do
    problem = "`#{inspect(val, inspect_value_opts())}` is not a tuple."
    compound_check(val, s, problem)
  end

  def do_format(
        {s = %TypeCheck.Builtin.FixedTuple{}, :different_size, %{expected_size: expected_size},
         val}
      ) do
    problem =
      "`#{inspect(val, inspect_value_opts())}` has #{tuple_size(val)} elements rather than #{expected_size}."

    compound_check(val, s, problem)
  end

  def do_format(
        {s = %TypeCheck.Builtin.FixedTuple{}, :element_error, %{problem: problem, index: index},
         val}
      ) do
    compound_check(val, s, "at index #{index}:\n", do_format(problem))
  end

  def do_format({s = %TypeCheck.Builtin.Tuple{}, :no_match, _, val}) do
    problem = "`#{inspect(val, inspect_value_opts())}` is not a tuple."
    compound_check(val, s, problem)
  end

  def do_format(
        {%TypeCheck.Builtin.ImplementsProtocol{protocol: protocol_name}, :no_match, _, val}
      ) do
    "`#{inspect(val, inspect_value_opts())}` does not implement the protocol `#{protocol_name}`"
  end

  def do_format({s = %mod{}, :param_error, %{index: index, problem: problem}, val})
      when mod in [TypeCheck.Spec, TypeCheck.Builtin.Function] do
    # compound_check(val, s, "at parameter no. #{index + 1}:\n", do_format(problem))
    name = Map.get(s, :name, "#Function<...>")

    function_with_arity =
      IO.ANSI.format_fragment([:default_color, "#{name}/#{Enum.count(val)}", :red])

    param_spec =
      s.param_types |> Enum.at(index) |> TypeCheck.Inspect.inspect_binary(inspect_type_opts())

    arguments = val |> Enum.map(&inspect(&1, inspect_value_opts())) |> Enum.join(", ")

    raw_call =
      if mod == TypeCheck.Builtin.Function do
        "#{name}.(#{arguments})"
      else
        "#{name}(#{arguments})"
      end

    call = IO.ANSI.format_fragment([:default_color, raw_call, :red])

    value = Enum.at(val, index)
    value_str = inspect(value, inspect_value_opts())

    """
    The call to `#{function_with_arity}` failed,
    because parameter no. #{index + 1} does not adhere to the spec `#{param_spec}`.
    Rather, its value is: `#{value_str}`.
    Details:
      The call `#{call}`
      does not adhere to spec `#{TypeCheck.Inspect.inspect_binary(s, inspect_type_opts())}`. Reason:
        parameter no. #{index + 1}:
    #{indent(indent(indent(do_format(problem))))}
    """
  end

  def do_format({s = %mod{}, :return_error, %{problem: problem, arguments: arguments}, val})
      when mod in [TypeCheck.Spec, TypeCheck.Builtin.Function] do
    name = Map.get(s, :name, "#Function<...>")

    function_with_arity =
      IO.ANSI.format_fragment([:default_color, "#{name}/#{Enum.count(arguments)}", :red])

    result_spec = s.return_type |> TypeCheck.Inspect.inspect_binary(inspect_type_opts())

    arguments_str =
      arguments |> Enum.map(fn val -> inspect(val, inspect_value_opts()) end) |> Enum.join(", ")

    arguments_str = IO.ANSI.format_fragment([:default_color, arguments_str, :default_color])

    raw_call =
      if mod == TypeCheck.Builtin.Function do
        "#{name}.(#{arguments_str})"
      else
        "#{name}(#{arguments_str})"
      end

    call = IO.ANSI.format_fragment([:default_color, raw_call, :red])

    val_str = inspect(val, inspect_value_opts())

    """
    The call to `#{function_with_arity}` failed,
    because the returned result does not adhere to the spec `#{result_spec}`.
    Rather, its value is: `#{val_str}`.
    Details:
      The result of calling `#{call}`
      does not adhere to spec `#{TypeCheck.Inspect.inspect_binary(s, inspect_type_opts())}`. Reason:
        Returned result:
    #{indent(indent(indent(do_format(problem))))}
    """
  end

  defp compound_check(val, s, child_prefix \\ nil, child_problem) do
    child_str =
      if child_prefix do
        indent(child_prefix <> indent(child_problem))
      else
        indent(child_problem)
      end

    """
    `#{inspect(val, inspect_value_opts())}` does not check against `#{TypeCheck.Inspect.inspect_binary(s, inspect_type_opts())}`. Reason:
    #{child_str}
    """
  end

  defp indent(str) do
    String.replace("  " <> str, "\n", "\n  ")
  end

  defp inspect_value_opts() do
    # [reset_color: :red, syntax_colors: ([reset: :default_color] ++ TypeCheck.Inspect.default_colors())]
    color_opts =
      if IO.ANSI.enabled?() do
        [reset_color: :red, syntax_colors: [reset: :red] ++ TypeCheck.Inspect.default_colors()]
      else
        []
      end

    [limit: 5] ++ color_opts
  end

  defp inspect_type_opts() do
    if IO.ANSI.enabled?() do
      [reset_color: :red, syntax_colors: [reset: :red] ++ TypeCheck.Inspect.default_colors()]
    else
      []
    end
  end
end