abc_size.ex (7741B)
1 defmodule Credo.Check.Refactor.ABCSize do 2 use Credo.Check, 3 tags: [:controversial], 4 param_defaults: [ 5 max_size: 30, 6 excluded_functions: [] 7 ], 8 explanations: [ 9 check: """ 10 The ABC size describes a metric based on assignments, branches and conditions. 11 12 A high ABC size is a hint that a function might be doing "more" than it 13 should. 14 15 As always: Take any metric with a grain of salt. Since this one was originally 16 introduced for C, C++ and Java, we still have to see whether or not this can 17 be a useful metric in a declarative language like Elixir. 18 """, 19 params: [ 20 max_size: "The maximum ABC size a function should have.", 21 excluded_functions: "All functions listed will be ignored." 22 ] 23 ] 24 25 @ecto_functions ["where", "from", "select", "join"] 26 @def_ops [:def, :defp, :defmacro] 27 @branch_ops [:.] 28 @condition_ops [:if, :unless, :for, :try, :case, :cond, :and, :or, :&&, :||] 29 @non_calls [:==, :fn, :__aliases__, :__block__, :if, :or, :|>, :%{}] 30 31 @doc false 32 @impl true 33 def run(%SourceFile{} = source_file, params) do 34 ignore_ecto? = imports_ecto_query?(source_file) 35 issue_meta = IssueMeta.for(source_file, params) 36 max_abc_size = Params.get(params, :max_size, __MODULE__) 37 excluded_functions = Params.get(params, :excluded_functions, __MODULE__) 38 39 excluded_functions = 40 if ignore_ecto? do 41 @ecto_functions ++ excluded_functions 42 else 43 excluded_functions 44 end 45 46 Credo.Code.prewalk( 47 source_file, 48 &traverse(&1, &2, issue_meta, max_abc_size, excluded_functions) 49 ) 50 end 51 52 defp imports_ecto_query?(source_file), 53 do: Credo.Code.prewalk(source_file, &traverse_for_ecto/2, false) 54 55 defp traverse_for_ecto(_, true), do: {nil, true} 56 57 defp traverse_for_ecto({:import, _, [{:__aliases__, _, [:Ecto, :Query]} | _]}, false), 58 do: {nil, true} 59 60 defp traverse_for_ecto(ast, false), do: {ast, false} 61 62 defp traverse( 63 {:defmacro, _, [{:__using__, _, _}, _]} = ast, 64 issues, 65 _issue_meta, 66 _max_abc_size, 67 _excluded_functions 68 ) do 69 {ast, issues} 70 end 71 72 # TODO: consider for experimental check front-loader (ast) 73 # NOTE: see above how we want to exclude certain front-loads 74 for op <- @def_ops do 75 defp traverse( 76 {unquote(op), meta, arguments} = ast, 77 issues, 78 issue_meta, 79 max_abc_size, 80 excluded_functions 81 ) 82 when is_list(arguments) do 83 abc_size = 84 ast 85 |> abc_size_for(excluded_functions) 86 |> round 87 88 if abc_size > max_abc_size do 89 fun_name = Credo.Code.Module.def_name(ast) 90 91 {ast, 92 [ 93 issue_for(issue_meta, meta[:line], fun_name, max_abc_size, abc_size) 94 | issues 95 ]} 96 else 97 {ast, issues} 98 end 99 end 100 end 101 102 defp traverse(ast, issues, _issue_meta, _max_abc_size, _excluded_functions) do 103 {ast, issues} 104 end 105 106 @doc """ 107 Returns the ABC size for the block inside the given AST, which is expected 108 to represent a function or macro definition. 109 110 iex> {:def, [line: 1], 111 ...> [ 112 ...> {:first_fun, [line: 1], nil}, 113 ...> [do: {:=, [line: 2], [{:x, [line: 2], nil}, 1]}] 114 ...> ] 115 ...> } |> Credo.Check.Refactor.ABCSize.abc_size 116 1.0 117 """ 118 def abc_size_for({_def_op, _meta, arguments}, excluded_functions) when is_list(arguments) do 119 arguments 120 |> Credo.Code.Block.do_block_for!() 121 |> abc_size_for(arguments, excluded_functions) 122 end 123 124 @doc false 125 def abc_size_for(nil, _arguments, _excluded_functions), do: 0 126 127 def abc_size_for(ast, arguments, excluded_functions) do 128 initial_acc = [a: 0, b: 0, c: 0, var_names: get_parameters(arguments)] 129 130 [a: a, b: b, c: c, var_names: _] = 131 Credo.Code.prewalk(ast, &traverse_abc(&1, &2, excluded_functions), initial_acc) 132 133 :math.sqrt(a * a + b * b + c * c) 134 end 135 136 defp get_parameters(arguments) do 137 case Enum.at(arguments, 0) do 138 {_name, _meta, nil} -> 139 [] 140 141 {_name, _meta, parameters} -> 142 Enum.map(parameters, &var_name/1) 143 end 144 end 145 146 for op <- @def_ops do 147 defp traverse_abc({unquote(op), _, arguments} = ast, abc, _excluded_functions) 148 when is_list(arguments) do 149 {ast, abc} 150 end 151 end 152 153 # Ignore string interpolation 154 defp traverse_abc({:<<>>, _, _}, acc, _excluded_functions) do 155 {nil, acc} 156 end 157 158 # A - assignments 159 defp traverse_abc( 160 {:=, _meta, [lhs | rhs]}, 161 [a: a, b: b, c: c, var_names: var_names], 162 _excluded_functions 163 ) do 164 var_names = 165 case var_name(lhs) do 166 nil -> 167 var_names 168 169 false -> 170 var_names 171 172 name -> 173 var_names ++ [name] 174 end 175 176 {rhs, [a: a + 1, b: b, c: c, var_names: var_names]} 177 end 178 179 # B - branch 180 defp traverse_abc( 181 {:->, _meta, arguments} = ast, 182 [a: a, b: b, c: c, var_names: var_names], 183 _excluded_functions 184 ) do 185 var_names = var_names ++ fn_parameters(arguments) 186 {ast, [a: a, b: b + 1, c: c, var_names: var_names]} 187 end 188 189 for op <- @branch_ops do 190 defp traverse_abc( 191 {unquote(op), _meta, [{_, _, nil}, _] = arguments} = ast, 192 [a: a, b: b, c: c, var_names: var_names], 193 _excluded_functions 194 ) 195 when is_list(arguments) do 196 {ast, [a: a, b: b, c: c, var_names: var_names]} 197 end 198 199 defp traverse_abc( 200 {unquote(op), _meta, arguments} = ast, 201 [a: a, b: b, c: c, var_names: var_names], 202 _excluded_functions 203 ) 204 when is_list(arguments) do 205 {ast, [a: a, b: b + 1, c: c, var_names: var_names]} 206 end 207 end 208 209 defp traverse_abc( 210 {fun_name, _meta, arguments} = ast, 211 [a: a, b: b, c: c, var_names: var_names], 212 excluded_functions 213 ) 214 when is_atom(fun_name) and fun_name not in @non_calls and is_list(arguments) do 215 if Enum.member?(excluded_functions, to_string(fun_name)) do 216 {nil, [a: a, b: b, c: c, var_names: var_names]} 217 else 218 {ast, [a: a, b: b + 1, c: c, var_names: var_names]} 219 end 220 end 221 222 defp traverse_abc( 223 {fun_or_var_name, _meta, nil} = ast, 224 [a: a, b: b, c: c, var_names: var_names], 225 _excluded_functions 226 ) do 227 is_variable = Enum.member?(var_names, fun_or_var_name) 228 229 if is_variable do 230 {ast, [a: a, b: b, c: c, var_names: var_names]} 231 else 232 {ast, [a: a, b: b + 1, c: c, var_names: var_names]} 233 end 234 end 235 236 # C - conditions 237 for op <- @condition_ops do 238 defp traverse_abc( 239 {unquote(op), _meta, arguments} = ast, 240 [a: a, b: b, c: c, var_names: var_names], 241 _excluded_functions 242 ) 243 when is_list(arguments) do 244 {ast, [a: a, b: b, c: c + 1, var_names: var_names]} 245 end 246 end 247 248 defp traverse_abc(ast, abc, _excluded_functions) do 249 {ast, abc} 250 end 251 252 defp var_name({name, _, nil}) when is_atom(name), do: name 253 defp var_name(_), do: nil 254 255 defp fn_parameters([params, tuple]) when is_list(params) and is_tuple(tuple) do 256 fn_parameters(params) 257 end 258 259 defp fn_parameters([[{:when, _, params}], _]) when is_list(params) do 260 fn_parameters(params) 261 end 262 263 defp fn_parameters(params) when is_list(params) do 264 params 265 |> Enum.map(&var_name/1) 266 |> Enum.reject(&is_nil/1) 267 end 268 269 defp issue_for(issue_meta, line_no, trigger, max_value, actual_value) do 270 format_issue( 271 issue_meta, 272 message: "Function is too complex (ABC size is #{actual_value}, max is #{max_value}).", 273 trigger: trigger, 274 line_no: line_no, 275 severity: Severity.compute(actual_value, max_value) 276 ) 277 end 278 end