Skip to main content

lib/ex_check/check.ex

defmodule ExCheck.Check do
  @moduledoc false

  alias ExCheck.Check.Compiler
  alias ExCheck.Check.Pipeline
  alias ExCheck.Command
  alias ExCheck.Config
  alias ExCheck.Manifest
  alias ExCheck.Printer
  alias ExCheck.Reporter

  def run(opts) do
    {tools, config_opts} = Config.load(file: opts[:config])

    opts =
      config_opts
      |> Keyword.merge(opts)
      |> maybe_toggle_retry_mode()
      |> Manifest.convert_retry_to_only()

    compile_and_run_tools(tools, opts)
  end

  defp maybe_toggle_retry_mode(opts) do
    with false <- Keyword.has_key?(opts, :retry),
         tools when tools != [] and tools != [:compiler] <- Manifest.get_failed_tools(opts) do
      tool_names =
        tools
        |> Enum.with_index()
        |> Enum.map(fn
          {tool, 0} -> Reporter.format_tool_name(tool)
          {tool, _} -> [" ", Reporter.format_tool_name(tool)]
        end)

      if live?(opts) do
        Printer.info([:cyan, "=> retrying automatically: "] ++ tool_names)
        Printer.info()
      end

      opts ++ [{:retry, true}]
    else
      _ -> opts
    end
  end

  defp compile_and_run_tools(tools, opts) do
    {compiler, others} = Compiler.compile(tools, opts)

    start_time = DateTime.utc_now()
    compiler_result = run_compiler(compiler, opts)
    others_results = if run_others?(compiler_result), do: run_others(others, opts), else: []
    total_duration = DateTime.diff(DateTime.utc_now(), start_time)

    all_results = [compiler_result | others_results]
    failed_results = Enum.filter(all_results, &match?({:error, _, _}, &1))

    reporter = Reporter.resolve(Keyword.get(opts, :format, :pretty))
    reporter.report(all_results, total_duration, opts)
    Manifest.save(all_results, opts)
    maybe_set_exit_status(failed_results)
  end

  defp run_compiler(compiler, opts) do
    run_tool(compiler, opts)
  end

  defp live?(opts), do: Keyword.get(opts, :format, :pretty) == :pretty

  @compile_warn_out "Compilation failed due to warnings while using the --warnings-as-errors option"

  defp run_others?(_compiler_result = {status, _, {_, output, _}}) do
    status == :ok or String.contains?(output, @compile_warn_out)
  end

  defp run_others(tools, opts) do
    {pending, skipped} = Enum.split_with(tools, &match?({:pending, _}, &1))
    {finished, skipped_runtime} = run_tools(pending, opts)

    finished ++ skipped ++ skipped_runtime
  end

  defp run_tools(tools, opts) do
    {finished, broken} =
      Pipeline.run(
        tools,
        throttle_fn: &throttle_tools(&1, &2, &3, opts),
        start_fn: &start_tool(&1, opts),
        collect_fn: &await_tool(&1, opts)
      )

    skipped = filter_broken_skipped(broken, finished)

    {finished, skipped}
  end

  defp filter_broken_skipped(broken, finished) do
    broken
    |> Enum.map(fn tool = {:pending, {name, _, _}} ->
      deps = get_unsatisfied_deps(tool, finished)

      dep_names =
        deps
        |> Enum.filter(fn {_, opts} -> opts[:else] != :disable end)
        |> Enum.map(&elem(&1, 0))

      Enum.any?(dep_names) && {:skipped, name, {:deps, dep_names}}
    end)
    |> Enum.filter(& &1)
  end

  defp run_tool(tool, opts) do
    tool
    |> start_tool(opts)
    |> await_tool(opts)
  end

  defp throttle_tools(pending, running, finished, opts) do
    parallel = Keyword.get(opts, :parallel, true)

    pending
    |> filter_no_deps(finished)
    |> throttle_parallel(running, parallel)
    |> throttle_umbrella_parallel(running)
  end

  defp filter_no_deps(pending, finished) do
    Enum.filter(pending, fn tool ->
      get_unsatisfied_deps(tool, finished) == []
    end)
  end

  defp get_unsatisfied_deps({:pending, {_, _, opts}}, finished) do
    opts
    |> Keyword.get(:deps, [])
    |> Enum.map(fn
      dep = {_, opts} when is_list(opts) -> dep
      name -> {name, []}
    end)
    |> Enum.reject(&satisfied_dep?(&1, finished))
  end

  defp satisfied_dep?({name, opts}, finished) do
    status = Keyword.get(opts, :status, :any)
    finished_match = Enum.find(finished, fn {_, {fin_name, _, _}, _} -> fin_name == name end)

    finished_match && satisfied_dep_status?(status, finished_match)
  end

  defp satisfied_dep_status?(list, finished) when is_list(list) do
    Enum.any?(list, &satisfied_dep_status?(&1, finished))
  end

  defp satisfied_dep_status?(:any, _), do: true
  defp satisfied_dep_status?(:ok, {:ok, _, _}), do: true
  defp satisfied_dep_status?(:error, {:error, _, _}), do: true
  defp satisfied_dep_status?(code, {_, _, {actual, _, _}}) when is_integer(code), do: code == actual
  defp satisfied_dep_status?(_, _), do: false

  defp throttle_parallel(selected, _, true), do: selected
  defp throttle_parallel([first_selected | _], [], false), do: [first_selected]
  defp throttle_parallel(_, _, false), do: []

  defp throttle_umbrella_parallel(selected, running) do
    running_names = Enum.map(running, &extract_tool_name/1)

    Enum.reduce(selected, [], fn next = {:pending, {name, _, opts}}, approved ->
      approved_names = Enum.map(approved, &extract_tool_name/1)

      if opts[:umbrella_parallel] == false &&
           (includes_umbrella_instance_from_same_app?(running_names, name) ||
              includes_umbrella_instance_from_same_app?(approved_names, name)) do
        approved
      else
        approved ++ [next]
      end
    end)
  end

  defp extract_tool_name({:pending, {name, _, _}}), do: name

  defp includes_umbrella_instance_from_same_app?(names, match_name) do
    Enum.any?(names, &umbrella_instance_from_same_app?(&1, match_name))
  end

  defp umbrella_instance_from_same_app?({name, _}, {name, _}), do: true
  defp umbrella_instance_from_same_app?(_, _), do: false

  defp start_tool({:pending, {name, cmd, tool_opts}}, opts) do
    cmd_opts =
      if live?(opts) do
        Keyword.merge(tool_opts, stream: true, silenced: true, tint: IO.ANSI.faint())
      else
        Keyword.merge(tool_opts, stream: false, silenced: true)
      end

    task = Command.async(cmd, cmd_opts)

    {:running, {name, cmd, cmd_opts}, task}
  end

  defp await_tool({:running, {name, cmd, tool_opts}, task}, opts) do
    if live?(opts) do
      await_tool_live(name, cmd, tool_opts, task)
    else
      {output, code, duration} = Command.await(task)
      {tool_status(code), {name, cmd, tool_opts}, {code, output, duration}}
    end
  end

  defp await_tool_live(name, cmd, tool_opts, task) do
    mode_suffix = if mode = tool_opts[:mode], do: [" in ", Reporter.bright(mode), " mode"], else: []

    Printer.info([:magenta, "=> running "] ++ Reporter.format_tool_name(name) ++ mode_suffix)
    Printer.info()
    IO.write(IO.ANSI.faint())

    {output, code, duration} =
      task
      |> Command.unsilence()
      |> Command.await()

    if Reporter.output_needs_padding?(output), do: Printer.info()
    IO.write(IO.ANSI.reset())

    {tool_status(code), {name, cmd, tool_opts}, {code, output, duration}}
  end

  defp tool_status(0), do: :ok
  defp tool_status(_), do: :error

  defp maybe_set_exit_status(failed_tools) do
    if Enum.any?(failed_tools) do
      System.at_exit(fn _ -> exit({:shutdown, 1}) end)
    end
  end
end