module_dependencies.ex (4174B)
1 defmodule Credo.Check.Refactor.ModuleDependencies do 2 use Credo.Check, 3 base_priority: :normal, 4 tags: [:controversial], 5 param_defaults: [ 6 max_deps: 10, 7 dependency_namespaces: [], 8 excluded_namespaces: [], 9 excluded_paths: [~r"/test/", ~r"^test/"] 10 ], 11 explanations: [ 12 check: """ 13 This module might be doing too much. Consider limiting the number of 14 module dependencies. 15 16 As always: This is just a suggestion. Check the configuration options for 17 tweaking or disabling this check. 18 """, 19 params: [ 20 max_deps: "Maximum number of module dependencies.", 21 dependency_namespaces: "List of dependency namespaces to include in this check", 22 excluded_namespaces: "List of namespaces to exclude from this check", 23 excluded_paths: "List of paths or regex to exclude from this check" 24 ] 25 ] 26 27 alias Credo.Code.Module 28 alias Credo.Code.Name 29 30 @doc false 31 @impl true 32 def run(%SourceFile{} = source_file, params) do 33 issue_meta = IssueMeta.for(source_file, params) 34 35 max_deps = Params.get(params, :max_deps, __MODULE__) 36 dependency_namespaces = Params.get(params, :dependency_namespaces, __MODULE__) 37 excluded_namespaces = Params.get(params, :excluded_namespaces, __MODULE__) 38 excluded_paths = Params.get(params, :excluded_paths, __MODULE__) 39 40 case ignore_path?(source_file.filename, excluded_paths) do 41 true -> 42 [] 43 44 false -> 45 Credo.Code.prewalk( 46 source_file, 47 &traverse( 48 &1, 49 &2, 50 issue_meta, 51 dependency_namespaces, 52 excluded_namespaces, 53 max_deps 54 ) 55 ) 56 end 57 end 58 59 # Check if analyzed module path is within ignored paths 60 defp ignore_path?(filename, excluded_paths) do 61 directory = Path.dirname(filename) 62 63 Enum.any?(excluded_paths, &matches?(directory, &1)) 64 end 65 66 defp matches?(directory, %Regex{} = regex), do: Regex.match?(regex, directory) 67 defp matches?(directory, path) when is_binary(path), do: String.starts_with?(directory, path) 68 69 defp traverse( 70 {:defmodule, meta, [mod | _]} = ast, 71 issues, 72 issue_meta, 73 dependency_namespaces, 74 excluded_namespaces, 75 max 76 ) do 77 module_name = Name.full(mod) 78 79 new_issues = 80 if has_namespace?(module_name, excluded_namespaces) do 81 [] 82 else 83 module_dependencies = get_dependencies(ast, dependency_namespaces) 84 85 issues_for_module(module_dependencies, max, issue_meta, meta) 86 end 87 88 {ast, issues ++ new_issues} 89 end 90 91 defp traverse(ast, issues, _issues_meta, _dependency_namespaces, _excluded_namespaces, _max) do 92 {ast, issues} 93 end 94 95 defp get_dependencies(ast, dependency_namespaces) do 96 aliases = Module.aliases(ast) 97 98 ast 99 |> Module.modules() 100 |> with_fullnames(aliases) 101 |> filter_namespaces(dependency_namespaces) 102 end 103 104 defp issues_for_module(deps, max_deps, issue_meta, meta) when length(deps) > max_deps do 105 [ 106 format_issue( 107 issue_meta, 108 message: "Module has too many dependencies: #{length(deps)} (max is #{max_deps})", 109 trigger: deps, 110 line_no: meta[:line], 111 column_no: meta[:column] 112 ) 113 ] 114 end 115 116 defp issues_for_module(_, _, _, _), do: [] 117 118 # Resolve dependencies to full module names 119 defp with_fullnames(dependencies, aliases) do 120 dependencies 121 |> Enum.map(&full_name(&1, aliases)) 122 |> Enum.uniq() 123 end 124 125 # Keep only dependencies which are within specified namespaces 126 defp filter_namespaces(dependencies, namespaces) do 127 Enum.filter(dependencies, &keep?(&1, namespaces)) 128 end 129 130 defp keep?(_module_name, []), do: true 131 132 defp keep?(module_name, namespaces), do: has_namespace?(module_name, namespaces) 133 134 defp has_namespace?(module_name, namespaces) do 135 Enum.any?(namespaces, &String.starts_with?(module_name, &1)) 136 end 137 138 # Get full module name from list of aliases (if present) 139 defp full_name(dep, aliases) do 140 aliases 141 |> Enum.find(&String.ends_with?(&1, dep)) 142 |> case do 143 nil -> dep 144 full_name -> full_name 145 end 146 end 147 end