alias_usage.ex (8360B)
1 defmodule Credo.Check.Design.AliasUsage do 2 use Credo.Check, 3 base_priority: :normal, 4 param_defaults: [ 5 excluded_namespaces: ~w[File IO Inspect Kernel Macro Supervisor Task Version], 6 excluded_lastnames: ~w[Access Agent Application Atom Base Behaviour 7 Bitwise Code Date DateTime Dict Enum Exception 8 File Float GenEvent GenServer HashDict HashSet 9 Integer IO Kernel Keyword List Macro Map MapSet 10 Module NaiveDateTime Node OptionParser Path Port 11 Process Protocol Range Record Regex Registry Set 12 Stream String StringIO Supervisor System Task Time 13 Tuple URI Version], 14 if_nested_deeper_than: 0, 15 if_called_more_often_than: 0, 16 only: nil 17 ], 18 explanations: [ 19 check: """ 20 Functions from other modules should be used via an alias if the module's 21 namespace is not top-level. 22 23 While this is completely fine: 24 25 defmodule MyApp.Web.Search do 26 def twitter_mentions do 27 MyApp.External.TwitterAPI.search(...) 28 end 29 end 30 31 ... you might want to refactor it to look like this: 32 33 defmodule MyApp.Web.Search do 34 alias MyApp.External.TwitterAPI 35 36 def twitter_mentions do 37 TwitterAPI.search(...) 38 end 39 end 40 41 The thinking behind this is that you can see the dependencies of your module 42 at a glance. So if you are attempting to build a medium to large project, 43 this can help you to get your boundaries/layers/contracts right. 44 45 As always: This is just a suggestion. Check the configuration options for 46 tweaking or disabling this check. 47 """, 48 params: [ 49 excluded_namespaces: "List of namespaces to be excluded for this check.", 50 excluded_lastnames: "List of lastnames to be excluded for this check.", 51 if_nested_deeper_than: "Only raise an issue if a module is nested deeper than this.", 52 if_called_more_often_than: 53 "Only raise an issue if a module is called more often than this.", 54 only: """ 55 Regex or a list of regexes that specifies which modules to include for this check. 56 57 `excluded_namespaces` and `excluded_lastnames` take precedence over this parameter. 58 """ 59 ] 60 ] 61 62 alias Credo.Code.Name 63 64 @doc false 65 @impl true 66 def run(%SourceFile{} = source_file, params) do 67 issue_meta = IssueMeta.for(source_file, params) 68 69 excluded_namespaces = Params.get(params, :excluded_namespaces, __MODULE__) 70 71 excluded_lastnames = Params.get(params, :excluded_lastnames, __MODULE__) 72 73 if_nested_deeper_than = Params.get(params, :if_nested_deeper_than, __MODULE__) 74 75 if_called_more_often_than = Params.get(params, :if_called_more_often_than, __MODULE__) 76 77 only = Params.get(params, :only, __MODULE__) 78 79 source_file 80 |> Credo.Code.prewalk( 81 &traverse(&1, &2, issue_meta, excluded_namespaces, excluded_lastnames, only) 82 ) 83 |> filter_issues_if_called_more_often_than(if_called_more_often_than) 84 |> filter_issues_if_nested_deeper_than(if_nested_deeper_than) 85 end 86 87 defp traverse( 88 {:defmodule, _, _} = ast, 89 issues, 90 issue_meta, 91 excluded_namespaces, 92 excluded_lastnames, 93 only 94 ) do 95 aliases = Credo.Code.Module.aliases(ast) 96 mod_deps = Credo.Code.Module.modules(ast) 97 98 new_issues = 99 Credo.Code.prewalk( 100 ast, 101 &find_issues( 102 &1, 103 &2, 104 issue_meta, 105 excluded_namespaces, 106 excluded_lastnames, 107 only, 108 aliases, 109 mod_deps 110 ) 111 ) 112 113 {ast, issues ++ new_issues} 114 end 115 116 defp traverse( 117 ast, 118 issues, 119 _source_file, 120 _excluded_namespaces, 121 _excluded_lastnames, 122 _only 123 ) do 124 {ast, issues} 125 end 126 127 # Ignore module attributes 128 defp find_issues({:@, _, _}, issues, _, _, _, _, _, _) do 129 {nil, issues} 130 end 131 132 # Ignore multi alias call 133 defp find_issues( 134 {:., _, [{:__aliases__, _, _}, :{}]} = ast, 135 issues, 136 _, 137 _, 138 _, 139 _, 140 _, 141 _ 142 ) do 143 {ast, issues} 144 end 145 146 # Ignore alias containing an `unquote` call 147 defp find_issues( 148 {:., _, [{:__aliases__, _, mod_list}, :unquote]} = ast, 149 issues, 150 _, 151 _, 152 _, 153 _, 154 _, 155 _ 156 ) 157 when is_list(mod_list) do 158 {ast, issues} 159 end 160 161 defp find_issues( 162 {:., _, [{:__aliases__, meta, mod_list}, fun_atom]} = ast, 163 issues, 164 issue_meta, 165 excluded_namespaces, 166 excluded_lastnames, 167 only, 168 aliases, 169 mod_deps 170 ) 171 when is_list(mod_list) and is_atom(fun_atom) do 172 cond do 173 Enum.count(mod_list) <= 1 || Enum.any?(mod_list, &tuple?/1) -> 174 {ast, issues} 175 176 Enum.any?(mod_list, &unquote?/1) -> 177 {ast, issues} 178 179 excluded_lastname_or_namespace?( 180 mod_list, 181 excluded_namespaces, 182 excluded_lastnames 183 ) -> 184 {ast, issues} 185 186 excluded_with_only?(mod_list, only) -> 187 {ast, issues} 188 189 conflicting_with_aliases?(mod_list, aliases) -> 190 {ast, issues} 191 192 conflicting_with_other_modules?(mod_list, mod_deps) -> 193 {ast, issues} 194 195 true -> 196 trigger = Credo.Code.Name.full(mod_list) 197 198 {ast, issues ++ [issue_for(issue_meta, meta[:line], trigger)]} 199 end 200 end 201 202 defp find_issues(ast, issues, _, _, _, _, _, _) do 203 {ast, issues} 204 end 205 206 defp unquote?({:unquote, _, arguments}) when is_list(arguments), do: true 207 defp unquote?(_), do: false 208 209 defp excluded_lastname_or_namespace?( 210 mod_list, 211 excluded_namespaces, 212 excluded_lastnames 213 ) do 214 first_name = Credo.Code.Name.first(mod_list) 215 last_name = Credo.Code.Name.last(mod_list) 216 217 Enum.member?(excluded_namespaces, first_name) || Enum.member?(excluded_lastnames, last_name) 218 end 219 220 defp excluded_with_only?(_mod_list, nil), do: false 221 222 defp excluded_with_only?(mod_list, only) when is_list(only) do 223 Enum.any?(only, &excluded_with_only?(mod_list, &1)) 224 end 225 226 defp excluded_with_only?(mod_list, %Regex{} = only) do 227 name = Credo.Code.Name.full(mod_list) 228 !String.match?(name, only) 229 end 230 231 # Returns true if mod_list and alias_name would result in the same alias 232 # since they share the same last name. 233 defp conflicting_with_aliases?(mod_list, aliases) do 234 last_name = Credo.Code.Name.last(mod_list) 235 236 Enum.find(aliases, &conflicting_alias?(&1, mod_list, last_name)) 237 end 238 239 defp conflicting_alias?(alias_name, mod_list, last_name) do 240 full_name = Credo.Code.Name.full(mod_list) 241 alias_last_name = Credo.Code.Name.last(alias_name) 242 243 full_name != alias_name && alias_last_name == last_name 244 end 245 246 # Returns true if mod_list and any dependent module would result in the same alias 247 # since they share the same last name. 248 defp conflicting_with_other_modules?(mod_list, mod_deps) do 249 full_name = Credo.Code.Name.full(mod_list) 250 last_name = Credo.Code.Name.last(mod_list) 251 252 (mod_deps -- [full_name]) 253 |> Enum.filter(&(Credo.Code.Name.parts_count(&1) > 1)) 254 |> Enum.map(&Credo.Code.Name.last/1) 255 |> Enum.any?(&(&1 == last_name)) 256 end 257 258 defp tuple?(t) when is_tuple(t), do: true 259 defp tuple?(_), do: false 260 261 defp filter_issues_if_called_more_often_than(issues, 0) do 262 issues 263 end 264 265 defp filter_issues_if_called_more_often_than(issues, count) do 266 issues 267 |> Enum.reduce(%{}, fn issue, memo -> 268 list = memo[issue.trigger] || [] 269 270 Map.put(memo, issue.trigger, [issue | list]) 271 end) 272 |> Enum.filter(fn {_trigger, issues} -> 273 length(issues) > count 274 end) 275 |> Enum.flat_map(fn {_trigger, issues} -> 276 issues 277 end) 278 end 279 280 defp filter_issues_if_nested_deeper_than(issues, count) do 281 Enum.filter(issues, fn issue -> 282 Name.parts_count(issue.trigger) > count 283 end) 284 end 285 286 defp issue_for(issue_meta, line_no, trigger) do 287 format_issue( 288 issue_meta, 289 message: "Nested modules could be aliased at the top of the invoking module.", 290 trigger: trigger, 291 line_no: line_no 292 ) 293 end 294 end