interpolation_helper.ex (7409B)
1 defmodule Credo.Code.InterpolationHelper do 2 @moduledoc false 3 4 alias Credo.Code.Token 5 6 @doc false 7 def replace_interpolations(source, char \\ " ", filename \\ "nofilename") 8 when is_binary(source) do 9 positions = interpolation_positions(source, filename) 10 lines = String.split(source, "\n") 11 12 positions 13 |> Enum.reverse() 14 |> Enum.reduce(lines, &replace_line(&1, &2, char)) 15 |> Enum.join("\n") 16 end 17 18 defp replace_line({line_no, col_start, line_no, col_end}, lines, char) do 19 List.update_at( 20 lines, 21 line_no - 1, 22 &replace_line(&1, col_start, col_end, char) 23 ) 24 end 25 26 defp replace_line(position, lines, char) do 27 {line_no_start, col_start, line_no_end, col_end} = position 28 29 Enum.reduce(line_no_start..line_no_end, lines, fn 30 line_no, memo 31 when line_no == line_no_start -> 32 List.update_at( 33 memo, 34 line_no - 1, 35 &replace_line(&1, col_start, String.length(&1) + 1, char) 36 ) 37 38 line_no, memo 39 when line_no == line_no_end -> 40 List.update_at( 41 memo, 42 line_no - 1, 43 &replace_line(&1, 1, col_end, char) 44 ) 45 46 line_no, memo 47 when line_no < line_no_end -> 48 List.update_at( 49 memo, 50 line_no - 1, 51 &replace_line(&1, 1, String.length(&1) + 1, char) 52 ) 53 end) 54 end 55 56 defp replace_line(line, col_start, col_end, char) do 57 length = max(col_end - col_start, 0) 58 line = String.to_charlist(line) 59 part1 = Enum.slice(line, 0, col_start - 1) 60 part2 = String.to_charlist(String.duplicate(char, length)) 61 part3 = Enum.slice(line, (col_end - 1)..-1) 62 List.to_string(part1 ++ part2 ++ part3) 63 end 64 65 @doc false 66 def interpolation_positions(source, filename \\ "nofilename") do 67 source 68 |> Credo.Code.to_tokens(filename) 69 |> Enum.flat_map(&map_interpolations(&1, source)) 70 |> Enum.reject(&is_nil/1) 71 end 72 73 # 74 # Elixir >= 1.13.0 75 # 76 defp map_interpolations( 77 {:bin_heredoc, {_line_no, _col_start, _}, _, _list} = token, 78 source 79 ) do 80 handle_heredoc(token, source) 81 end 82 83 # 84 # Elixir >= 1.11.0 85 # 86 defp map_interpolations( 87 {:sigil, {_line_no, _col_start, nil}, _, list, _empty_list, nil, _another_binary} = 88 token, 89 source 90 ) do 91 handle_atom_string_or_sigil(token, list, source) 92 end 93 94 # 95 # Elixir >= 1.6.0 96 # 97 defp map_interpolations( 98 {:sigil, {_line_no, _col_start, nil}, _, list, _, _sigil_start_char} = token, 99 source 100 ) do 101 handle_atom_string_or_sigil(token, list, source) 102 end 103 104 defp map_interpolations( 105 {:bin_heredoc, {_line_no, _col_start, _}, _list} = token, 106 source 107 ) do 108 handle_heredoc(token, source) 109 end 110 111 defp map_interpolations( 112 {:bin_string, {_line_no, _col_start, _}, list} = token, 113 source 114 ) do 115 handle_atom_string_or_sigil(token, list, source) 116 end 117 118 defp map_interpolations( 119 {:kw_identifier_unsafe, {_line_no, _col_start, _}, list} = token, 120 source 121 ) do 122 handle_atom_string_or_sigil(token, list, source) 123 end 124 125 defp map_interpolations( 126 {:atom_unsafe, {_line_no, _col_start, _}, list} = token, 127 source 128 ) do 129 handle_atom_string_or_sigil(token, list, source) 130 end 131 132 defp map_interpolations(_token, _source) do 133 [] 134 end 135 136 defp handle_atom_string_or_sigil(_token, list, source) do 137 find_interpolations(list, source) 138 end 139 140 defp handle_heredoc({_atom, {line_no, _, _}, list}, source) do 141 first_line_in_heredoc = get_line(source, line_no + 1) 142 143 # TODO: this seems to be wrong. the closing """ determines the 144 # indentation, not the first line of the heredoc. 145 padding_in_first_line = determine_heredoc_padding_at_start_of_line(first_line_in_heredoc) 146 147 list 148 |> find_interpolations(source) 149 |> Enum.reject(&is_nil/1) 150 |> add_to_col_start_and_end(padding_in_first_line) 151 end 152 153 # Elixir 1.13+ 154 defp handle_heredoc({atom, {line_no, col_start, col_end}, _, list}, source) do 155 handle_heredoc({atom, {line_no, col_start, col_end}, list}, source) 156 end 157 158 defp find_interpolations(list, source) when is_list(list) do 159 Enum.map(list, &find_interpolations(&1, source)) 160 end 161 162 # Elixir < 1.9.0 163 # 164 defp find_interpolations( 165 {{line_no, col_start, nil}, {line_no_end, col_end, nil}, list} = _token, 166 _source 167 ) 168 when is_list(list) do 169 {line_no, col_start, line_no_end, col_end + 1} 170 end 171 172 # {{1, 25, 32}, [{:identifier, {1, 27, 31}, :name}]} 173 defp find_interpolations({{_line_no, _col_start2, _}, _list} = token, source) do 174 {line_no, col_start, line_no_end, col_end} = Token.position(token) 175 176 col_end = 177 if line_no_end > line_no && col_end == 1 do 178 # This means we encountered :eol and jumped in the next line. 179 # We need to add the closing `}`. 180 col_end + 1 181 else 182 col_end 183 end 184 185 line = get_line(source, line_no_end) 186 187 # `col_end - 1` to account for the closing `}` 188 rest_of_line = get_rest_of_line(line, col_end - 1) 189 190 # IO.inspect(rest_of_line, label: "rest_of_line") 191 192 padding = determine_padding_at_start_of_line(rest_of_line, ~r/^\s*\}/) 193 194 # -1 to remove the accounted-for `}` 195 padding = max(padding - 1, 0) 196 197 # IO.inspect(padding, label: "padding") 198 199 {line_no, col_start, line_no_end, col_end + padding} 200 end 201 202 # Elixir >= 1.9.0 203 # 204 # {{1, 25, nil}, {1, 31, nil}, [{:identifier, {1, 27, nil}, :name}]} 205 defp find_interpolations( 206 {{_line_no, _col_start, nil}, {_line_no2, _col_start2, nil}, _list} = token, 207 source 208 ) do 209 {line_no, col_start, line_no_end, col_end} = Token.position(token) 210 211 col_end = 212 if line_no_end > line_no && col_end == 1 do 213 # This means we encountered :eol and jumped in the next line. 214 # We need to add the closing `}`. 215 col_end + 1 216 else 217 col_end 218 end 219 220 line = get_line(source, line_no_end) 221 222 # `col_end - 1` to account for the closing `}` 223 rest_of_line = get_rest_of_line(line, col_end - 1) 224 225 # IO.inspect(rest_of_line, label: "rest_of_line") 226 227 padding = determine_padding_at_start_of_line(rest_of_line, ~r/^\s*\}/) 228 229 # -1 to remove the accounted-for `}` 230 padding = max(padding - 1, 0) 231 232 {line_no, col_start, line_no_end, col_end + padding} 233 end 234 235 defp find_interpolations(_value, _source) do 236 nil 237 end 238 239 if Version.match?(System.version(), ">= 1.12.0-rc") do 240 # Elixir >= 1.12.0 241 # 242 defp determine_heredoc_padding_at_start_of_line(_line), do: 0 243 else 244 # Elixir < 1.12.0 245 # 246 defp determine_heredoc_padding_at_start_of_line(line), 247 do: determine_padding_at_start_of_line(line, ~r/^\s+/) 248 end 249 250 defp determine_padding_at_start_of_line(line, regex) do 251 regex 252 |> Regex.run(line) 253 |> List.wrap() 254 |> Enum.join() 255 |> String.length() 256 end 257 258 defp add_to_col_start_and_end(positions, padding) do 259 Enum.map(positions, fn {line_no, col_start, line_no_end, col_end} -> 260 {line_no, col_start + padding, line_no_end, col_end + padding} 261 end) 262 end 263 264 defp get_line(source, line_no) do 265 source 266 |> String.split("\n") 267 |> Enum.at(line_no - 1) 268 end 269 270 defp get_rest_of_line(line, col_end) do 271 # col-1 to account for col being 1-based 272 start = max(col_end - 1, 0) 273 274 String.slice(line, start..-1) 275 end 276 end