lib/credo/check/refactor/module_dependencies.ex

defmodule Credo.Check.Refactor.ModuleDependencies do
  use Credo.Check,
    id: "EX4017",
    base_priority: :normal,
    tags: [:controversial],
    param_defaults: [
      max_deps: 10,
      dependency_namespaces: [],
      excluded_namespaces: [],
      excluded_paths: [~r"/test/", ~r"^test/"]
    ],
    explanations: [
      check: """
      This module might be doing too much. Consider limiting the number of
      module dependencies.

      As always: This is just a suggestion. Check the configuration options for
      tweaking or disabling this check.
      """,
      params: [
        max_deps: "Maximum number of module dependencies.",
        dependency_namespaces: "List of dependency namespaces to include in this check",
        excluded_namespaces: "List of namespaces to exclude from this check",
        excluded_paths: "List of paths or regex to exclude from this check"
      ]
    ]

  alias Credo.Code.Module
  alias Credo.Code.Name

  @doc false
  @impl true
  def run(%SourceFile{} = source_file, params) do
    issue_meta = IssueMeta.for(source_file, params)

    max_deps = Params.get(params, :max_deps, __MODULE__)
    dependency_namespaces = Params.get(params, :dependency_namespaces, __MODULE__)

    excluded_namespaces =
      params |> Params.get(:excluded_namespaces, __MODULE__) |> Enum.map(&to_string/1)

    excluded_paths = Params.get(params, :excluded_paths, __MODULE__)

    case ignore_path?(source_file.filename, excluded_paths) do
      true ->
        []

      false ->
        Credo.Code.prewalk(
          source_file,
          &traverse(
            &1,
            &2,
            issue_meta,
            dependency_namespaces,
            excluded_namespaces,
            max_deps
          )
        )
    end
  end

  # Check if analyzed module path is within ignored paths
  defp ignore_path?(filename, excluded_paths) do
    directory = Path.dirname(filename)

    Enum.any?(excluded_paths, &matches?(directory, &1))
  end

  defp matches?(directory, %Regex{} = regex), do: Regex.match?(regex, directory)
  defp matches?(directory, path) when is_binary(path), do: String.starts_with?(directory, path)

  defp traverse(
         {:defmodule, meta, [mod | _]} = ast,
         issues,
         issue_meta,
         dependency_namespaces,
         excluded_namespaces,
         max
       ) do
    module_name = Name.full(mod)

    new_issues =
      if has_namespace?(module_name, excluded_namespaces) do
        []
      else
        module_dependencies = get_dependencies(ast, dependency_namespaces)

        issues_for_module(module_dependencies, max, issue_meta, meta)
      end

    {ast, issues ++ new_issues}
  end

  defp traverse(ast, issues, _issues_meta, _dependency_namespaces, _excluded_namespaces, _max) do
    {ast, issues}
  end

  defp get_dependencies(ast, dependency_namespaces) do
    aliases = Module.aliases(ast)

    ast
    |> Module.modules()
    |> with_fullnames(aliases)
    |> filter_namespaces(dependency_namespaces)
  end

  defp issues_for_module(deps, max_deps, issue_meta, meta) when length(deps) > max_deps do
    [
      format_issue(
        issue_meta,
        message: "Module has too many dependencies: #{length(deps)} (max is #{max_deps})",
        trigger: deps,
        line_no: meta[:line],
        column_no: meta[:column]
      )
    ]
  end

  defp issues_for_module(_, _, _, _), do: []

  # Resolve dependencies to full module names
  defp with_fullnames(dependencies, aliases) do
    dependencies
    |> Enum.map(&full_name(&1, aliases))
    |> Enum.uniq()
  end

  # Keep only dependencies which are within specified namespaces
  defp filter_namespaces(dependencies, namespaces) do
    Enum.filter(dependencies, &keep?(&1, namespaces))
  end

  defp keep?(_module_name, []), do: true

  defp keep?(module_name, namespaces), do: has_namespace?(module_name, namespaces)

  defp has_namespace?(module_name, namespaces) do
    Enum.any?(namespaces, &String.starts_with?(module_name, &1))
  end

  # Get full module name from list of aliases (if present)
  defp full_name(dep, aliases) do
    aliases
    |> Enum.find(&String.ends_with?(&1, dep))
    |> case do
      nil -> dep
      full_name -> full_name
    end
  end
end