lib/mix/tasks/archeometer.explore.static.ex

defmodule Mix.Tasks.Arch.Explore.Static do
  use Mix.Task
  alias Archeometer.Explore.Project

  @moduledoc """
  Mix Task to run code AST exploration of modules, functions and macros
  and store its findings into a Archeometer database.

  Usage:

      mix arch.explore.static [options]

  The following options are accepted:

    * `--include-deps` - Wildcard glob style to filter dependencies

  For more information see:
  [Basic usage guide](guides/introduction/basic_usage.md#include-dependencies-in-the-analysis)
  """

  @shortdoc "Runs static code exploration"

  @impl Mix.Task
  def run(argv) do
    case get_args(argv) do
      [
        include_deps: deps_filter
      ] ->
        Credo.Application.start(nil, nil)

        %Credo.Execution{argv: []}
        |> Credo.Execution.ExecutionTiming.start_server()
        |> Credo.Execution.ExecutionConfigFiles.start_server()
        |> Credo.Execution.ExecutionSourceFiles.start_server()
        |> Credo.Execution.Task.AppendDefaultConfig.call([])
        |> Credo.Execution.Task.ParseOptions.call([])
        |> Credo.ConfigBuilder.parse()
        |> append_deps(deps_filter)
        |> Credo.CLI.Task.LoadAndValidateSourceFiles.call()
        |> Archeometer.Explore.Credo.SaveStatsTask.call(Project.test_paths(deps_filter))
        |> Credo.Execution.get_exit_status()
        |> ok_status()

      {:validate_error, error_type} ->
        Mix.shell().error("Error: #{error_type}")
        {:error, error_type}

      {:error, error_type} ->
        Mix.shell().error("Error: #{error_type}")
        print_help()
        {:error, error_type}
    end
  end

  defp get_args(argv) do
    {opts, _args, invalid} =
      OptionParser.parse(
        argv,
        strict: [include_deps: :keep]
      )

    case invalid do
      [] ->
        deps_filter = Keyword.get_values(opts, :include_deps)

        validate_options(deps_filter)

      _ ->
        {:error, :wrong_arguments}
    end
  end

  defp validate_options(deps_filter) do
    case Project.filter_deps(deps_filter) do
      [] ->
        {:validate_error, "Filter doesn't match any dependency"}

      _ ->
        [include_deps: deps_filter]
    end
  end

  defp print_help() do
    Mix.shell().info("""
    Usage: mix arch.explore.static [opts]

    opts: --include-deps 'deps_filter'

    Where `deps_filter` is a glob Unix-like pattern, matches dependencies name.
    --include-deps can be used more than once.

    - `*` matches none or many tokens
    - `?` matches exactly one token
    - `[abc]` matches a set of tokens
    - `[a-z]` matches a range of tokens
    - `[!...]` matches anything but a set of tokens
    """)
  end

  defp append_deps(exec, deps_filter) do
    included_deps = Project.filter_deps(deps_filter)

    if included_deps do
      deps_dirs = Keyword.values(included_deps)

      include_suffixes = [
        "lib/**/*.{ex,exs}",
        "src/",
        "test/**/*.{ex,exs}",
        "web/",
        "apps/*/lib/",
        "apps/*/src/",
        "apps/*/test/",
        "apps/*/web/"
      ]

      include_dirs =
        for dep_dir <- deps_dirs,
            include_suffix <- include_suffixes,
            do: dep_dir <> "/" <> include_suffix

      included = Map.get(exec.files, :included, []) ++ include_dirs
      excluded = [~r/\/_build\//, ~r/\/node_modules\//] ++ Enum.map(deps_dirs, &(&1 <> "/deps"))

      Map.update!(exec, :files, &%{&1 | included: included})
      |> Map.update!(:files, &%{&1 | excluded: excluded})
    else
      exec
    end
  end

  defp ok_status(0), do: :ok
  defp ok_status(other), do: {:error, other}
end