large_numbers.ex (7185B)
1 defmodule Credo.Check.Readability.LargeNumbers do 2 use Credo.Check, 3 base_priority: :high, 4 tags: [:formatter], 5 param_defaults: [ 6 only_greater_than: 9_999, 7 trailing_digits: [] 8 ], 9 explanations: [ 10 check: """ 11 Numbers can contain underscores for readability purposes. 12 These do not affect the value of the number, but can help read large numbers 13 more easily. 14 15 141592654 # how large is this number? 16 17 141_592_654 # ah, it's in the hundreds of millions! 18 19 Like all `Readability` issues, this one is not a technical concern. 20 But you can improve the odds of others reading and liking your code by making 21 it easier to follow. 22 """, 23 params: [ 24 only_greater_than: "The check only reports numbers greater than this.", 25 trailing_digits: 26 "The check allows for the given number of trailing digits (can be a number, range or list)" 27 ] 28 ] 29 30 @doc false 31 # TODO: consider for experimental check front-loader (tokens) 32 def run(%SourceFile{} = source_file, params) do 33 min_number = Params.get(params, :only_greater_than, __MODULE__) 34 issue_meta = IssueMeta.for(source_file, Keyword.merge(params, only_greater_than: min_number)) 35 36 allowed_trailing_digits = 37 case Params.get(params, :trailing_digits, __MODULE__) do 38 %Range{} = value -> Enum.to_list(value) 39 value -> List.wrap(value) 40 end 41 42 source_file 43 |> Credo.Code.to_tokens() 44 |> collect_number_tokens([], min_number) 45 |> find_issues([], allowed_trailing_digits, issue_meta) 46 end 47 48 defp collect_number_tokens([], acc, _), do: acc 49 50 defp collect_number_tokens([head | t], acc, min_number) do 51 acc = 52 case number_token(head, min_number) do 53 nil -> acc 54 token -> acc ++ [token] 55 end 56 57 collect_number_tokens(t, acc, min_number) 58 end 59 60 # tuple for Elixir >= 1.10.0 61 defp number_token({:flt, {_, _, number}, _} = tuple, min_number) when min_number < number do 62 tuple 63 end 64 65 # tuple for Elixir >= 1.6.0 66 defp number_token({:int, {_, _, number}, _} = tuple, min_number) when min_number < number do 67 tuple 68 end 69 70 defp number_token({:float, {_, _, number}, _} = tuple, min_number) when min_number < number do 71 tuple 72 end 73 74 # tuple for Elixir <= 1.5.x 75 defp number_token({:number, _, number} = tuple, min_number) when min_number < number do 76 tuple 77 end 78 79 defp number_token(_, _), do: nil 80 81 defp find_issues([], acc, _allowed_trailing_digits, _issue_meta) do 82 acc 83 end 84 85 # tuple for Elixir >= 1.10.0 86 defp find_issues( 87 [{:flt, {line_no, column1, number} = location, _} | t], 88 acc, 89 allowed_trailing_digits, 90 issue_meta 91 ) do 92 acc = 93 acc ++ find_issue(line_no, column1, location, number, allowed_trailing_digits, issue_meta) 94 95 find_issues(t, acc, allowed_trailing_digits, issue_meta) 96 end 97 98 # tuple for Elixir >= 1.6.0 99 defp find_issues( 100 [{:int, {line_no, column1, number} = location, _} | t], 101 acc, 102 allowed_trailing_digits, 103 issue_meta 104 ) do 105 acc = 106 acc ++ find_issue(line_no, column1, location, number, allowed_trailing_digits, issue_meta) 107 108 find_issues(t, acc, allowed_trailing_digits, issue_meta) 109 end 110 111 defp find_issues( 112 [{:float, {line_no, column1, number} = location, _} | t], 113 acc, 114 allowed_trailing_digits, 115 issue_meta 116 ) do 117 acc = 118 acc ++ find_issue(line_no, column1, location, number, allowed_trailing_digits, issue_meta) 119 120 find_issues(t, acc, allowed_trailing_digits, issue_meta) 121 end 122 123 # tuple for Elixir <= 1.5.x 124 defp find_issues( 125 [{:number, {line_no, column1, _column2} = location, number} | t], 126 acc, 127 allowed_trailing_digits, 128 issue_meta 129 ) do 130 acc = 131 acc ++ find_issue(line_no, column1, location, number, allowed_trailing_digits, issue_meta) 132 133 find_issues(t, acc, allowed_trailing_digits, issue_meta) 134 end 135 136 defp find_issue(line_no, column1, location, number, allowed_trailing_digits, issue_meta) do 137 source = source_fragment(location, issue_meta) 138 underscored_versions = number_with_underscores(number, allowed_trailing_digits, source) 139 140 if decimal_in_source?(source) && not Enum.member?(underscored_versions, source) do 141 [ 142 issue_for( 143 issue_meta, 144 line_no, 145 column1, 146 source, 147 underscored_versions 148 ) 149 ] 150 else 151 [] 152 end 153 end 154 155 defp number_with_underscores(number, allowed_trailing_digits, _) when is_integer(number) do 156 number 157 |> to_string 158 |> add_underscores_to_number_string(allowed_trailing_digits) 159 end 160 161 defp number_with_underscores(number, allowed_trailing_digits, source_fragment) 162 when is_number(number) do 163 case String.split(source_fragment, ".", parts: 2) do 164 [num, decimal] -> 165 add_underscores_to_number_string(num, allowed_trailing_digits) 166 |> Enum.map(fn base -> Enum.join([base, decimal], ".") end) 167 168 [num] -> 169 add_underscores_to_number_string(num, allowed_trailing_digits) 170 end 171 end 172 173 defp add_underscores_to_number_string(string, allowed_trailing_digits) do 174 without_trailing_digits = 175 string 176 |> String.reverse() 177 |> String.replace(~r/(\d{3})(?=\d)/, "\\1_") 178 |> String.reverse() 179 180 all_trailing_digit_versions = 181 Enum.map(allowed_trailing_digits, fn trailing_digits -> 182 if String.length(string) > trailing_digits do 183 base = 184 String.slice(string, 0..(-1 * trailing_digits - 1)) 185 |> String.reverse() 186 |> String.replace(~r/(\d{3})(?=\d)/, "\\1_") 187 |> String.reverse() 188 189 trailing = String.slice(string, (-1 * trailing_digits)..-1) 190 191 "#{base}_#{trailing}" 192 end 193 end) 194 195 ([without_trailing_digits] ++ all_trailing_digit_versions) 196 |> Enum.reject(&is_nil/1) 197 |> Enum.uniq() 198 end 199 200 defp issue_for(issue_meta, line_no, column, trigger, expected) do 201 params = IssueMeta.params(issue_meta) 202 only_greater_than = Params.get(params, :only_greater_than, __MODULE__) 203 204 format_issue( 205 issue_meta, 206 message: 207 "Numbers larger than #{only_greater_than} should be written with underscores: #{Enum.join(expected, " or ")}", 208 line_no: line_no, 209 column: column, 210 trigger: trigger 211 ) 212 end 213 214 defp decimal_in_source?(source) do 215 case String.slice(source, 0, 2) do 216 "0b" -> false 217 "0o" -> false 218 "0x" -> false 219 "" -> false 220 _ -> true 221 end 222 end 223 224 defp source_fragment({line_no, column1, _}, issue_meta) do 225 line = 226 issue_meta 227 |> IssueMeta.source_file() 228 |> SourceFile.line_at(line_no) 229 230 beginning_of_number = 231 ~r/[^0-9_oxb]*([0-9_oxb]+$)/ 232 |> Regex.run(String.slice(line, 1..column1)) 233 |> List.wrap() 234 |> List.last() 235 |> to_string() 236 237 ending_of_number = 238 ~r/^([0-9_\.]+)/ 239 |> Regex.run(String.slice(line, (column1 + 1)..-1)) 240 |> List.wrap() 241 |> List.last() 242 |> to_string() 243 |> String.replace(~r/\.\..*/, "") 244 245 beginning_of_number <> ending_of_number 246 end 247 end