space_around_operators.ex (7638B)
1 defmodule Credo.Check.Consistency.SpaceAroundOperators do 2 use Credo.Check, 3 run_on_all: true, 4 base_priority: :high, 5 tags: [:formatter], 6 param_defaults: [ignore: [:|]], 7 explanations: [ 8 check: """ 9 Use spaces around operators like `+`, `-`, `*` and `/`. This is the 10 **preferred** way, although other styles are possible, as long as it is 11 applied consistently. 12 13 # preferred 14 15 1 + 2 * 4 16 17 # also okay 18 19 1+2*4 20 21 While this is not necessarily a concern for the correctness of your code, 22 you should use a consistent style throughout your codebase. 23 """, 24 params: [ 25 ignore: "List of operators to be ignored for this check." 26 ] 27 ] 28 29 @collector Credo.Check.Consistency.SpaceAroundOperators.Collector 30 31 # TODO: add *ignored* operators, so you can add "|" and still write 32 # [head|tail] while enforcing 2 + 3 / 1 ... 33 # FIXME: this seems to be already implemented, but there don't seem to be 34 # any related test cases around. 35 36 @doc false 37 @impl true 38 def run_on_all_source_files(exec, source_files, params) do 39 @collector.find_and_append_issues(source_files, exec, params, &issues_for/3) 40 end 41 42 defp issues_for(expected, source_file, params) do 43 tokens = Credo.Code.to_tokens(source_file) 44 ast = SourceFile.ast(source_file) 45 issue_meta = IssueMeta.for(source_file, params) 46 47 issue_locations = 48 expected 49 |> @collector.find_locations_not_matching(source_file) 50 |> Enum.reject(&ignored?(&1, params)) 51 |> Enum.filter(&create_issue?(&1, tokens, ast, issue_meta)) 52 53 Enum.map(issue_locations, fn location -> 54 format_issue( 55 issue_meta, 56 message: message_for(expected), 57 line_no: location[:line_no], 58 column: location[:column], 59 trigger: location[:trigger] 60 ) 61 end) 62 end 63 64 defp message_for(:with_space = _expected) do 65 "There are spaces around operators most of the time, but not here." 66 end 67 68 defp message_for(:without_space = _expected) do 69 "There are no spaces around operators most of the time, but here there are." 70 end 71 72 defp ignored?(location, params) do 73 ignored_triggers = Params.get(params, :ignore, __MODULE__) 74 75 Enum.member?(ignored_triggers, location[:trigger]) 76 end 77 78 defp create_issue?(location, tokens, ast, issue_meta) do 79 line_no = location[:line_no] 80 trigger = location[:trigger] 81 column = location[:column] 82 83 line = 84 issue_meta 85 |> IssueMeta.source_file() 86 |> SourceFile.line_at(line_no) 87 88 create_issue?(trigger, line_no, column, line, tokens, ast) 89 end 90 91 defp create_issue?(trigger, line_no, column, line, tokens, ast) when trigger in [:+, :-] do 92 create_issue?(line, column, trigger) && 93 !parameter_in_function_call?({line_no, column, trigger}, tokens, ast) 94 end 95 96 defp create_issue?(trigger, _line_no, column, line, _tokens, _ast) do 97 create_issue?(line, column, trigger) 98 end 99 100 # Don't create issues for `c = -1` 101 # TODO: Consider moving these checks inside the Collector. 102 defp create_issue?(line, column, trigger) when trigger in [:+, :-] do 103 !number_with_sign?(line, column) && !number_in_range?(line, column) && 104 !(trigger == :- && minus_in_binary_size?(line, column)) 105 end 106 107 defp create_issue?(line, column, trigger) when trigger == :-> do 108 !arrow_in_typespec?(line, column) 109 end 110 111 defp create_issue?(line, column, trigger) when trigger == :/ do 112 !number_in_function_capture?(line, column) 113 end 114 115 defp create_issue?(line, _column, trigger) when trigger == :* do 116 # The Elixir formatter always removes spaces around the asterisk in 117 # typespecs for binaries by default. Credo shouldn't conflict with the 118 # default Elixir formatter settings. 119 !typespec_binary_unit_operator_without_spaces?(line) 120 end 121 122 defp create_issue?(_, _, _), do: true 123 124 defp typespec_binary_unit_operator_without_spaces?(line) do 125 # In code this construct can only appear inside a binary typespec. It could 126 # also appear verbatim in a string, but it's rather unlikely... 127 line =~ "_::_*" 128 end 129 130 defp arrow_in_typespec?(line, column) do 131 # -2 because we need to subtract the operator 132 line 133 |> String.slice(0..(column - 2)) 134 |> String.match?(~r/\(\s*$/) 135 end 136 137 defp number_with_sign?(line, column) do 138 line 139 # -2 because we need to subtract the operator 140 |> String.slice(0..(column - 2)) 141 |> String.match?(~r/(\A\s+|\@[a-zA-Z0-9\_]+\.?|[\|\\\{\[\(\,\:\>\<\=\+\-\*\/])\s*$/) 142 end 143 144 defp number_in_range?(line, column) do 145 line 146 |> String.slice(column..-1) 147 |> String.match?(~r/^\d+\.\./) 148 end 149 150 defp number_in_function_capture?(line, column) do 151 line 152 |> String.slice(0..(column - 2)) 153 |> String.match?(~r/[\.\&][a-z0-9_]+[\!\?]?$/) 154 end 155 156 # TODO: this implementation is a bit naive. improve it. 157 defp minus_in_binary_size?(line, column) do 158 # -2 because we need to subtract the operator 159 binary_pattern_start_before? = 160 line 161 |> String.slice(0..(column - 2)) 162 |> String.match?(~r/\<\</) 163 164 # -2 because we need to subtract the operator 165 double_colon_before? = 166 line 167 |> String.slice(0..(column - 2)) 168 |> String.match?(~r/\:\:/) 169 170 # -1 because we need to subtract the operator 171 binary_pattern_end_after? = 172 line 173 |> String.slice(column..-1) 174 |> String.match?(~r/\>\>/) 175 176 # -1 because we need to subtract the operator 177 typed_after? = 178 line 179 |> String.slice(column..-1) 180 |> String.match?(~r/^\s*(integer|native|signed|unsigned|binary|size|little|float)/) 181 182 # -2 because we need to subtract the operator 183 typed_before? = 184 line 185 |> String.slice(0..(column - 2)) 186 |> String.match?(~r/(integer|native|signed|unsigned|binary|size|little|float)\s*$/) 187 188 heuristics_met_count = 189 [ 190 binary_pattern_start_before?, 191 binary_pattern_end_after?, 192 double_colon_before?, 193 typed_after?, 194 typed_before? 195 ] 196 |> Enum.filter(& &1) 197 |> Enum.count() 198 199 heuristics_met_count >= 2 200 end 201 202 defp parameter_in_function_call?(location_tuple, tokens, ast) do 203 case find_prev_current_next_token(tokens, location_tuple) do 204 {prev, _current, _next} -> 205 prev 206 |> Credo.Code.TokenAstCorrelation.find_tokens_in_ast(ast) 207 |> List.wrap() 208 |> List.first() 209 |> is_parameter_in_function_call() 210 211 _ -> 212 false 213 end 214 end 215 216 defp is_parameter_in_function_call({atom, _, arguments}) 217 when is_atom(atom) and is_list(arguments) do 218 true 219 end 220 221 defp is_parameter_in_function_call( 222 {{:., _, [{:__aliases__, _, _mods}, fun_name]}, _, arguments} 223 ) 224 when is_atom(fun_name) and is_list(arguments) do 225 true 226 end 227 228 defp is_parameter_in_function_call(_) do 229 false 230 end 231 232 # TOKENS 233 234 defp find_prev_current_next_token(tokens, location_tuple) do 235 tokens 236 |> traverse_prev_current_next(&matching_location(location_tuple, &1, &2, &3, &4), []) 237 |> List.first() 238 end 239 240 defp traverse_prev_current_next(tokens, callback, acc) do 241 tokens 242 |> case do 243 [prev | [current | [next | rest]]] -> 244 acc = callback.(prev, current, next, acc) 245 246 traverse_prev_current_next([current | [next | rest]], callback, acc) 247 248 _ -> 249 acc 250 end 251 end 252 253 defp matching_location( 254 {line_no, column, trigger}, 255 prev, 256 {_, {line_no, column, _}, trigger} = current, 257 next, 258 acc 259 ) do 260 acc ++ [{prev, current, next}] 261 end 262 263 defp matching_location(_, _prev, _current, _next, acc) do 264 acc 265 end 266 end