defmodule ExCheck.Reporter do
@moduledoc false
# Behaviour for rendering the final check results. A reporter receives the full
# list of result tuples once all tools have finished and is responsible for
# producing the user-facing output for its format.
#
# Result tuples are `{status, {name, cmd, opts}, {exit_code, output, duration}}`
# for run tools (status `:ok`/`:error`) and `{:skipped, name, reason}` for skipped
# ones. `name` is an atom or `{atom, app}` for umbrella instances.
@type result :: tuple
@type opts :: keyword
@callback report(results :: [result], total_duration :: integer, opts) :: :ok
@formats %{
pretty: ExCheck.Reporter.Pretty,
json: ExCheck.Reporter.Json,
agent: ExCheck.Reporter.Agent
}
@doc "Resolves a format atom to its reporter module."
def resolve(format) do
Map.get(@formats, format, ExCheck.Reporter.Pretty)
end
@doc "List of supported format atoms."
def formats, do: Map.keys(@formats)
@doc """
Sink shared by the batch reporters (agent/json): writes the rendered report to the
file given by `:output`, or to stdout when no output path is set.
"""
# sobelow_skip ["Traversal.FileModule"]
def emit(iodata, opts) do
case Keyword.get(opts, :output) do
nil -> IO.puts(iodata)
path -> File.write!(path, iodata)
end
:ok
end
# Shared formatting helpers.
@doc "Plain (uncolored) tool name, such as formatter or 'formatter in child'."
def tool_name_string(name) when is_atom(name), do: Atom.to_string(name)
def tool_name_string({name, app}), do: "#{name} in #{app}"
@doc "Colored tool name iodata for terminal output."
def format_tool_name(name) when is_atom(name), do: bright(name)
def format_tool_name({name, app}) when is_atom(name), do: [bright(name), " in ", bright(app)]
@doc "Formats a duration in seconds as `M:SS`."
def format_duration(secs) do
min = div(secs, 60)
sec = rem(secs, 60)
sec_str = if sec < 10, do: "0#{sec}", else: "#{sec}"
"#{min}:#{sec_str}"
end
@doc "Wraps inner text in bright/normal ANSI markers."
def bright(inner), do: [:bright, to_string(inner), :normal]
@doc "Sort comparable for results: ok first, then errors, then skipped, by name."
def summary_order({:ok, {name, _, _}, _}), do: {0, normalize_name(name)}
def summary_order({:error, {name, _, _}, _}), do: {1, normalize_name(name)}
def summary_order({:skipped, name, _}), do: {2, normalize_name(name)}
defp normalize_name(name = {_, _}), do: name
defp normalize_name(name), do: {name, 0}
@ansi_code_regex ~r/\x1b\[[0-9;]*[a-zA-Z]/
@doc "Strips ANSI escape codes from captured tool output."
def strip_ansi(output), do: String.replace(output, @ansi_code_regex, "")
@doc "Whether a captured output block needs a trailing blank line for padding."
def output_needs_padding?(output) do
not (String.match?(output, ~r/\n{2,}$/) or output == "")
end
@doc "Splits a result name into `{name_string, app_string_or_nil}`."
def split_name(name) when is_atom(name), do: {Atom.to_string(name), nil}
def split_name({name, app}), do: {Atom.to_string(name), to_string(app)}
@doc "Renders a skip reason as a plain string."
def skip_reason_string({:elixir, version}),
do: "Elixir version = #{System.version()}, not #{version}"
def skip_reason_string({:deps, [name | _]}),
do: "unsatisfied dependency #{tool_name_string(name)}"
def skip_reason_string({:package, name}), do: "missing package #{name}"
def skip_reason_string({:package, name, app}), do: "missing package #{name} in #{app}"
def skip_reason_string({:file, name}), do: "missing file #{name}"
def skip_reason_string({:cd, cd}), do: "missing directory #{cd}"
end