lib/espec/formatters/doc.ex

defmodule ESpec.Formatters.Doc do
  @moduledoc """
  Generates plain colored text output.
  """
  @green IO.ANSI.green()
  @red IO.ANSI.red()
  @cyan IO.ANSI.cyan()
  @yellow IO.ANSI.yellow()
  # @grey IO.ANSI.light_black()
  @reset IO.ANSI.reset()

  @main_colors [stacktrace: @cyan, diff_headers: @cyan]
  @status_colors [success: @green, failure: @red, pending: @yellow]
  @status_symbols [success: ".", failure: "F", pending: "*"]
  @diff_colors [
    diff_delete: :red,
    diff_delete_whitespace: IO.ANSI.color_background(2, 0, 0),
    diff_insert: :green,
    diff_insert_whitespace: IO.ANSI.color_background(0, 2, 0)
  ]

  alias ESpec.Example

  use ESpec.Formatters.Base

  @doc "Formats an example result."
  def format_example(example, opts) do
    color = color_for_status(example.status)
    symbol = symbol_for_status(example.status)

    if opts[:details] do
      trace_description(example)
    else
      colorize(color, symbol)
    end
  end

  @doc "Formats the final result."
  def format_result(examples, durations, opts) do
    pending = Example.pendings(examples)
    string = ""
    string = if Enum.any?(pending), do: string <> format_pending(pending), else: string
    failed = Example.failure(examples)
    string = if Enum.any?(failed), do: string <> format_failed(failed, opts), else: string
    string = string <> format_footer(examples, failed, pending)
    string = string <> format_times(durations, failed, pending)
    string <> format_seed()
  end

  defp format_failed(failed, opts) do
    failed
    |> Enum.with_index()
    |> Enum.map_join("\n", fn {example, index} ->
      do_format_failed_example(example, index, opts)
    end)
  end

  defp format_pending(pending) do
    pending
    |> Enum.with_index()
    |> Enum.map_join("\n", fn {example, index} ->
      do_format_pending_example(example, example.result, index)
    end)
  end

  defp do_format_pending_example(example, info, index) do
    color = color_for_status(example.status)
    description = one_line_description(example)
    file_line_ref = "#{example.file}:#{example.line}"

    [
      "\n",
      "\t#{index + 1}) #{description}",
      "\t#{colorize(@main_colors[:stacktrace], file_line_ref)}",
      "\t#{colorize(color, info)}"
    ]
    |> Enum.join("\n")
  end

  defp do_format_failed_example(example, index, opts) do
    color = color_for_status(example.status)
    description = one_line_description(example)

    error_message =
      example.error.message
      |> String.replace("\n", "\n\t  ")

    stacktrace = do_format_stacktrace(example)

    Enum.join(
      [
        "\n",
        "\t#{index + 1}) #{description}",
        "\t#{colorize(@main_colors[:stacktrace], stacktrace)}",
        "\t#{colorize(color, error_message)}"
      ],
      "\n"
    ) <> if Map.get(opts, :diff_enabled?, true), do: do_format_diff(example.error), else: ""
  end

  defp do_format_stacktrace(example) do
    list =
      example
      |> do_format_stacktrace_list()
      |> Enum.reverse()

    line = "#{Path.relative_to_cwd(example.file)}:#{example.line}"

    if Enum.empty?(list) do
      line
    else
      ["#{line}: (example)" | list]
      |> Enum.reverse()
      |> Enum.join("\n\t")
    end
  end

  defp do_format_stacktrace_list(example) do
    if is_nil(example.error.stacktrace) do
      []
    else
      example.error.stacktrace
      |> remove_example_if_first(example)
      |> Enum.map(fn {module, function, arity, [file: file, line: line]} = item ->
        if in_this_example?(example, item) do
          "#{file}:#{line}: (inside example)"
        else
          "#{file}:#{line}: #{module}.#{function}/#{arity}"
        end
      end)
    end
  end

  defp in_this_example?(example, {module, function, arity, [file: file, line: _line]}) do
    module == example.module && function == example.function && arity == 1 &&
      file == String.to_charlist(Path.relative_to_cwd(example.file))
  end

  defp remove_example_if_first([], _example) do
    []
  end

  defp remove_example_if_first([{_, _, _, [file: _, line: line]} = first | rest] = trace, example) do
    if in_this_example?(example, first) && line == example.line do
      rest
    else
      trace
    end
  end

  defp do_format_diff(%ESpec.AssertionError{extra: %{diff_fn: f}}) when is_function(f, 0) do
    format_diff(f.())
  end

  defp do_format_diff(_), do: ""

  defp colorize(ansi_color_code, string) do
    [ansi_color_code, string, @reset]
    |> IO.ANSI.format_fragment(true)
    |> IO.iodata_to_binary()
  end

  defp format_diff(nil) do
    ""
  end

  if Version.match?(System.version(), ">= 1.10.0") do
    defp format_diff(%ExUnit.Diff{
           left: left,
           right: right
         }) do
      left =
        left
        |> ExUnit.Diff.to_algebra(fn doc ->
          Inspect.Algebra.color(
            doc,
            get_color_by_content(doc, :diff_delete, :diff_delete_whitespace),
            %Inspect.Opts{syntax_colors: @diff_colors}
          )
        end)
        |> Inspect.Algebra.nest(20)
        |> Inspect.Algebra.format(80)

      right =
        right
        |> ExUnit.Diff.to_algebra(fn doc ->
          Inspect.Algebra.color(
            doc,
            get_color_by_content(doc, :diff_insert, :diff_insert_whitespace),
            %Inspect.Opts{syntax_colors: @diff_colors}
          )
        end)
        |> Inspect.Algebra.nest(20)
        |> Inspect.Algebra.format(80)

      "\n\t  #{colorize(@main_colors[:diff_headers], "expected:")} " <>
        IO.iodata_to_binary(left) <>
        "\n\t  #{colorize(@main_colors[:diff_headers], "actual:")}   " <>
        IO.iodata_to_binary(right)
    end

    defp get_color_by_content(content, color_if_normal, color_if_whitespace)
         when is_binary(content) do
      if String.trim_leading(content) == "", do: color_if_whitespace, else: color_if_normal
    end

    defp get_color_by_content(_content, color_if_normal, _color_if_whitespace) do
      color_if_normal
    end
  else
    defp format_diff({l, r}) do
      [
        "",
        "\t  #{colorize(@main_colors[:diff_headers], "expected:")} #{colorize_diff(r)}",
        "\t  #{colorize(@main_colors[:diff_headers], "actual:")}   #{colorize_diff(l)}"
      ]
      |> Enum.join("\n")
    end

    defp colorize_diff([{:eq, text} | rest]) do
      text <> colorize_diff(rest)
    end

    defp colorize_diff([{:ins, text} | rest]) do
      colorize(@diff_colors[:diff_insert], text) <> colorize_diff(rest)
    end

    defp colorize_diff([{:del, text} | rest]) do
      colorize(@diff_colors[:diff_delete], text) <> colorize_diff(rest)
    end

    defp colorize_diff([{:ins_whitespace, length} | rest]) do
      colorize(@diff_colors[:diff_insert_whitespace], String.duplicate(" ", length)) <>
        colorize_diff(rest)
    end

    defp colorize_diff([]) do
      ""
    end
  end

  defp format_footer(examples, failed, pending) do
    color = get_color_for_content(failed, pending)
    parts = ["#{Enum.count(examples)} examples", "#{Enum.count(failed)} failures"]
    parts = if Enum.any?(pending), do: parts ++ ["#{Enum.count(pending)} pending"], else: parts
    parts_string = Enum.join(parts, ", ")

    "\n\n\t#{colorize(color, parts_string)}"
  end

  defp format_times({start_loading_time, finish_loading_time, finish_specs_time}, failed, pending) do
    color = get_color_for_content(failed, pending)
    load_time = :timer.now_diff(finish_loading_time, start_loading_time)
    spec_time = :timer.now_diff(finish_specs_time, finish_loading_time)

    finished_in =
      "Finished in #{us_to_sec(load_time + spec_time)} seconds" <>
        " (#{us_to_sec(load_time)}s on load, #{us_to_sec(spec_time)}s on specs)"

    "\n\n\t#{colorize(color, finished_in)}\n\n"
  end

  defp format_seed do
    if ESpec.Configuration.get(:order) do
      ""
    else
      seed = ESpec.Configuration.get(:seed)
      "\tRandomized with seed #{seed}\n\n"
    end
  end

  defp us_to_sec(us), do: div(us, 10_000) / 100

  defp get_color_for_content(failed, pending) do
    if Enum.any?(failed) do
      @status_colors[:failure]
    else
      if Enum.any?(pending), do: @status_colors[:pending], else: @status_colors[:success]
    end
  end

  defp one_line_description(example) do
    desc = Example.context_descriptions(example) ++ [example.description]

    desc
    |> Enum.join(" ")
    |> String.trim_trailing()
  end

  defp trace_description(example) do
    color = color_for_status(example.status)

    ex_desc =
      if String.length(example.description) > 0 do
        "#{colorize(color, example.description)}"
      else
        status_message(example, color)
      end

    array = Example.context_descriptions(example) ++ [ex_desc]

    {result, _} =
      Enum.reduce(array, {"", ""}, fn description, acc ->
        {d, w} = acc
        {d <> w <> "#{description}" <> "\n", w <> "  "}
      end)

    result
  end

  defp status_message(example, color) do
    if example.status == :failure do
      "#{colorize(color, example.error.message)}"
    else
      "#{colorize(color, inspect(example.result))}"
    end
  end

  defp color_for_status(status), do: Keyword.get(@status_colors, status)
  defp symbol_for_status(status), do: Keyword.get(@status_symbols, status)
end