Skip to main content

lib/ex_check/check/compiler.ex

defmodule ExCheck.Check.Compiler do
  @moduledoc false

  alias ExCheck.{Config, Project}

  def compile(tools, opts) do
    {
      process_compiler(tools, opts),
      process_others(tools, opts)
    }
  end

  defp process_compiler(tools, opts) do
    compiler = List.keyfind(tools, :compiler, 0) || raise("compiler tool definition missing")
    compiler = prepare(compiler, opts)

    case compiler do
      {:pending, _} -> compiler
      _ -> {:pending, {:compiler, ["mix", "compile"], []}}
    end
  end

  defp process_others(tools, opts) do
    tools
    |> List.keydelete(:compiler, 0)
    |> Enum.sort_by(&get_order/1)
    |> filter_apps_in_umbrella()
    |> unwrap_recursive()
    |> map_recursive_dependents()
    |> Enum.map(&prepare(&1, opts))
    |> Enum.reject(&match?({:disabled, _}, &1))
  end

  defp filter_apps_in_umbrella(tools) do
    app = Project.config()[:app]

    if Project.in_umbrella?() do
      Enum.filter(tools, fn {_, tool_opts} ->
        enabled_apps = get_in(tool_opts, [:umbrella, :apps])
        !enabled_apps || Enum.member?(enabled_apps, app)
      end)
    else
      tools
    end
  end

  defp unwrap_recursive(tools) do
    Enum.reduce(tools, [], fn tool = {tool_name, tool_opts}, final_tools ->
      recursive = recursive?(tool_opts)

      if recursive and Project.umbrella?() do
        actual_apps_paths = Project.apps_paths()
        enabled_apps = get_in(tool_opts, [:umbrella, :apps])

        apps_paths =
          if enabled_apps,
            do: Map.take(actual_apps_paths, enabled_apps),
            else: actual_apps_paths

        tool_instances =
          apps_paths
          |> Enum.sort_by(&elem(&1, 0))
          |> Enum.map(fn {app_name, app_dir} ->
            final_tool_opts = Keyword.update(tool_opts, :cd, app_dir, &Path.join(app_dir, &1))
            {{tool_name, app_name}, final_tool_opts}
          end)

        final_tools ++ tool_instances
      else
        final_tools ++ [tool]
      end
    end)
  end

  defp map_recursive_dependents(tools) do
    recursive_tools =
      tools
      |> Enum.filter(&match?({{_, _}, _}, &1))
      |> Enum.group_by(fn {{name, _}, _} -> name end)

    Enum.reduce(recursive_tools, tools, fn recursive_tool, tools ->
      Enum.map(tools, fn {name, opts} ->
        opts = map_recursive_dependent(name, opts, recursive_tool)
        {name, opts}
      end)
    end)
  end

  defp map_recursive_dependent(name, opts, recursive_tool) do
    Keyword.update(opts, :deps, [], fn deps ->
      do_map_recursive_dependent(name, deps, recursive_tool)
    end)
  end

  defp do_map_recursive_dependent(name, deps, {recursive_name, recursive_instances}) do
    deps
    |> Enum.map(fn {dep, opts} ->
      if dep == recursive_name do
        case name do
          {_, app} -> {{recursive_name, app}, opts}
          _ -> Enum.map(recursive_instances, &{elem(&1, 0), opts})
        end
      else
        {dep, opts}
      end
    end)
    |> List.flatten()
  end

  defp recursive?(tool_opts) do
    case get_in(tool_opts, [:umbrella, :recursive]) do
      nil ->
        tool_opts
        |> Keyword.fetch!(:command)
        |> command_to_array()
        |> mix_task_recursive?()

      recursive ->
        recursive
    end
  end

  defp command_to_array(cmd) when is_list(cmd), do: cmd
  defp command_to_array(cmd), do: String.split(cmd, " ")

  defp mix_task_recursive?(["mix", task | _]) do
    case Mix.Task.get(task) do
      nil -> false
      task_module -> Mix.Task.recursive(task_module)
    end
  end

  defp mix_task_recursive?(_) do
    true
  end

  defp prepare({name, tool_opts}, opts) do
    cond do
      disabled?(name, tool_opts, opts) ->
        {:disabled, name}

      failed_detection = find_failed_detection(name, tool_opts) ->
        prepare_failed_detection(name, failed_detection)

      tool_opts[:cd] && not File.dir?(tool_opts[:cd]) ->
        {:skipped, name, {:cd, tool_opts[:cd]}}

      true ->
        prepare_pending(name, tool_opts, opts)
    end
  end

  defp disabled?({name, _}, tool_opts, opts) do
    disabled?(name, tool_opts, opts)
  end

  defp disabled?(name, tool_opts, opts) do
    Keyword.get(tool_opts, :enabled, true) == false ||
      (Keyword.has_key?(opts, :only) && !Enum.any?(opts, &(&1 == {:only, name}))) ||
      Enum.any?(opts, fn i -> i == {:except, name} end)
  end

  defp find_failed_detection(name, tool_opts) do
    tool_opts
    |> Keyword.get(:detect, [])
    |> Enum.map(&split_detection_opts/1)
    |> Enum.map(fn {base, opts} -> {prepare_detection_base(base, name, tool_opts), opts} end)
    |> Enum.find(fn {base, _} -> failed_detection?(base) end)
  end

  defp split_detection_opts({:elixir, version}), do: {{:elixir, version}, []}
  defp split_detection_opts({:package, name, opts}), do: {{:package, name}, opts}
  defp split_detection_opts({:package, name}), do: {{:package, name}, []}
  defp split_detection_opts({:file, name, opts}), do: {{:file, name}, opts}
  defp split_detection_opts({:file, name}), do: {{:file, name}, []}

  defp prepare_detection_base({:elixir, version}, _, _), do: {:elixir, version}

  defp prepare_detection_base({:package, name}, {_, app}, _), do: {:package, name, app}
  defp prepare_detection_base({:package, name}, _, _), do: {:package, name}

  defp prepare_detection_base({:file, name}, _, tool_opts) do
    filename =
      tool_opts
      |> Keyword.get(:cd, "")
      |> Path.join(name)
      |> Path.relative_to_cwd()

    {:file, filename}
  end

  defp failed_detection?({:elixir, version}), do: not Version.match?(System.version(), version)
  defp failed_detection?({:package, name, app}), do: not Project.has_dep_in_app?(name, app)
  defp failed_detection?({:package, name}), do: not Project.has_dep?(name)
  defp failed_detection?({:file, name}), do: not File.exists?(name)

  defp prepare_failed_detection(name, failed_detection) do
    {base, opts} = failed_detection

    case Keyword.get(opts, :else, :skip) do
      :disable -> {:disabled, name}
      :skip -> {:skipped, name, base}
    end
  end

  defp prepare_pending(name, tool_opts, opts) do
    {mode, command} = pick_mode_and_command(tool_opts, opts)

    command =
      command
      |> command_to_array()
      |> postprocess_cmd(tool_opts)

    command_opts =
      tool_opts
      |> Keyword.take([:cd, :env, :deps])
      |> Keyword.put(:mode, mode)
      |> Keyword.put(:umbrella_parallel, get_in(tool_opts, [:umbrella, :parallel]))

    {:pending, {name, command, command_opts}}
  end

  defp pick_mode_and_command(tool_opts, opts) do
    cond do
      opts[:fix] && tool_opts[:fix] ->
        {:fix, tool_opts[:fix]}

      opts[:retry] && tool_opts[:retry] ->
        {:retry, tool_opts[:retry]}

      true ->
        {nil, Keyword.fetch!(tool_opts, :command)}
    end
  end

  defp postprocess_cmd(cmd, opts) do
    if Keyword.get(opts, :enable_ansi, true) do
      supports_erl_config = Version.match?(System.version(), ">= 1.9.0")

      enable_ansi(cmd, supports_erl_config)
    else
      cmd
    end
  end

  # Elixir commands executed by `mix check` are not run in a TTY and will by default not print ANSI
  # characters in their output - which means no colors, no bold etc. This makes the tool output
  # (e.g. assertion diffs from ex_unit) less useful. We explicitly enable ANSI to fix that.
  defp enable_ansi(["mix" | arg], true),
    do: ["elixir", "--erl-config", enable_ansi_erl_cfg_path(), "-S", "mix" | arg]

  defp enable_ansi(["elixir" | arg], true),
    do: ["elixir", "--erl-config", enable_ansi_erl_cfg_path() | arg]

  defp enable_ansi(["mix" | arg], false),
    do: ["elixir", "-e", "Application.put_env(:elixir, :ansi_enabled, true)", "-S", "mix" | arg]

  defp enable_ansi(cmd, _),
    do: cmd

  defp enable_ansi_erl_cfg_path,
    do: Application.app_dir(:ex_check_ng, ~w[priv enable_ansi enable_ansi.config])

  defp get_order({name, opts}),
    do: [Keyword.get(opts, :order, 0), Config.Default.tool_order(name)]
end