lib/mix/tasks/metacredo.ex

defmodule Mix.Tasks.Metacredo do
  @shortdoc "Run MetaCredo static analysis checks"
  @moduledoc """
  Runs MetaCredo checks on your project.

  ## Usage

      $ mix metacredo
      $ mix metacredo --strict
      $ mix metacredo --only security,warning
      $ mix metacredo --format json
      $ mix metacredo explain MetaCredo.Check.Security.HardcodedValue
  """

  use Mix.Task

  alias MetaCredo.{CLI.Output, Config, Execution, Sources}

  @impl Mix.Task
  def run(argv) do
    Mix.Task.run("compile", ["--no-warnings"])

    {opts, args, _} =
      OptionParser.parse(argv,
        strict: [
          strict: :boolean,
          only: :string,
          ignore: :string,
          format: :string,
          config_file: :string,
          files_included: :string,
          files_excluded: :string
        ]
      )

    case args do
      ["explain", check_name | _] ->
        run_explain(check_name)

      _ ->
        run_analysis(opts)
    end
  end

  defp run_analysis(opts) do
    execution_opts =
      []
      |> maybe_add(:strict, opts[:strict])
      |> maybe_add(:config_file, opts[:config_file])
      |> maybe_add(:only, parse_list(opts[:only]))
      |> maybe_add(:ignore, parse_list(opts[:ignore]))
      |> maybe_add(:files_included, parse_list(opts[:files_included]))
      |> maybe_add(:files_excluded, parse_list(opts[:files_excluded]))

    report = Execution.run(execution_opts)

    case opts[:format] do
      "json" ->
        IO.puts(Output.to_json(report))

      _ ->
        Output.print_report(report)
    end

    # Set exit code based on issues
    exit_status =
      report.issues
      |> Enum.map(& &1.exit_status)
      |> Enum.reduce(0, &Bitwise.bor/2)

    if exit_status > 0 do
      System.at_exit(fn _ -> exit({:shutdown, exit_status}) end)
    end
  end

  defp run_explain(check_ref) do
    {module, issue, language} = resolve_check_with_context(check_ref)

    if module && Code.ensure_loaded?(module) && function_exported?(module, :category, 0) do
      Output.print_explanation(module, issue, language)
    else
      Mix.shell().error("Check '#{check_ref}' not found.")
    end
  end

  # Resolves a check reference and returns {module, issue | nil, language}.
  #
  # Supported forms:
  #   file:line  e.g. lib/metacredo/cli/output.ex:42
  #              Runs a quick analysis on the file; returns the check module
  #              and issue that produced the first hit at that line.
  #              Falls back to treating the path as a check definition file.
  #   file       e.g. lib/metacredo/check/security/hardcoded_value.ex
  #   FQN        e.g. MetaCredo.Check.Security.HardcodedValue
  #   short name e.g. HardcodedValue
  defp resolve_check_with_context(ref) do
    cond do
      file_ref?(ref) ->
        {path, line_no} = split_file_ref(ref)
        language = Sources.language_for(path) || detect_project_language()

        issue =
          if line_no && File.exists?(path), do: check_at_location(path, line_no)

        module = (issue && issue.check) || path_to_check_module(path)
        {module, issue, language}

      String.contains?(ref, ".") ->
        module = ref |> String.split(".") |> Module.concat()
        {module, nil, detect_project_language()}

      true ->
        {find_check_by_short_name(ref), nil, detect_project_language()}
    end
  end

  defp file_ref?(str) do
    exts = Sources.supported_extensions() |> Enum.map_join("|", &Regex.escape/1)
    Regex.match?(~r/(?:#{exts})(:\d+)?$/, str)
  end

  defp split_file_ref(str) do
    case String.split(str, ":") do
      [path, line] ->
        case Integer.parse(line) do
          {n, ""} -> {path, n}
          _ -> {str, nil}
        end

      [path] ->
        {path, nil}

      _ ->
        {str, nil}
    end
  end

  # Run all enabled checks on a single file and return the first issue at the
  # given line number (or nil if none is found).
  defp check_at_location(file_path, line_no) do
    checks = Config.enabled_checks(Config.default())
    source_files = Sources.find(%{included: [file_path], excluded: []})

    source_files
    |> Execution.run_on_source_files(checks)
    |> Enum.find(&(&1.line_no == line_no))
  end

  # Converts a check source file path to its module by case-insensitive
  # comparison against all compiled check modules in the build directory.
  defp path_to_check_module(path) do
    relative =
      path
      |> String.replace(~r/^(lib|test|src)\//, "")
      |> String.replace(~r/\.exs?$/, "")

    expected =
      relative
      |> String.split("/")
      |> Enum.map_join(".", fn part ->
        part |> String.split("_") |> Enum.map_join("", &String.capitalize/1)
      end)
      |> String.downcase()

    metacredo_check_modules()
    |> Enum.find(fn mod ->
      mod |> to_string() |> String.replace("Elixir.", "") |> String.downcase() == expected
    end)
  end

  defp find_check_by_short_name(short_name) do
    metacredo_check_modules()
    |> Enum.find(fn mod ->
      mod |> to_string() |> String.split(".") |> List.last() == short_name
    end)
  end

  # Enumerate all MetaCredo check modules by scanning the compiled BEAM files.
  # This is reliable in a Mix task context where :application.get_key/2 may
  # not yet be available.
  defp metacredo_check_modules do
    ebin = Path.join([Mix.Project.build_path(), "lib", "metacredo", "ebin"])

    if File.dir?(ebin) do
      ebin
      |> File.ls!()
      |> Enum.filter(&String.match?(&1, ~r/^Elixir\.MetaCredo\.Check\./i))
      |> Enum.flat_map(fn beam_file ->
        mod = beam_file |> String.trim_trailing(".beam") |> String.to_atom()

        if Code.ensure_loaded?(mod) and function_exported?(mod, :category, 0),
          do: [mod],
          else: []
      end)
    else
      []
    end
  end

  # Detects the primary programming language used in the current project.
  # Checks for well-known project descriptor files first, then falls back to
  # scanning source directories and returning the most prevalent language.
  defp detect_project_language do
    cond do
      File.exists?("mix.exs") ->
        :elixir

      File.exists?("rebar.config") or File.exists?("rebar.lock") ->
        :erlang

      true ->
        detect_language_from_sources()
    end
  end

  defp detect_language_from_sources do
    config = Config.default()

    counts =
      config.files.included
      |> Enum.flat_map(fn path ->
        if File.dir?(path) do
          Sources.supported_extensions()
          |> Enum.flat_map(&Path.wildcard("#{path}/**/*#{&1}"))
        else
          [path]
        end
      end)
      |> Enum.map(&Sources.language_for/1)
      |> Enum.reject(&is_nil/1)
      |> Enum.frequencies()

    case Enum.max_by(counts, fn {_lang, count} -> count end, fn -> nil end) do
      {lang, _} -> lang
      nil -> :elixir
    end
  end

  defp parse_list(nil), do: nil
  defp parse_list(""), do: nil

  defp parse_list(str) do
    str
    |> String.split(",")
    |> Enum.map(&String.trim/1)
    |> Enum.map(&String.to_atom/1)
  end

  defp maybe_add(opts, _key, nil), do: opts
  defp maybe_add(opts, key, value), do: Keyword.put(opts, key, value)
end