string_sigils.ex (3823B)
1 defmodule Credo.Check.Readability.StringSigils do 2 alias Credo.Code.Heredocs 3 alias Credo.SourceFile 4 5 use Credo.Check, 6 base_priority: :low, 7 param_defaults: [ 8 maximum_allowed_quotes: 3 9 ], 10 explanations: [ 11 check: ~S""" 12 If you used quoted strings that contain quotes, you might want to consider 13 switching to the use of sigils instead. 14 15 # okay 16 17 "<a href=\"http://elixirweekly.net\">#\{text}</a>" 18 19 # not okay, lots of escaped quotes 20 21 "<a href=\"http://elixirweekly.net\" target=\"_blank\">#\{text}</a>" 22 23 # refactor to 24 25 ~S(<a href="http://elixirweekly.net" target="_blank">#\{text}</a>) 26 27 This allows us to remove the noise which results from the need to escape 28 quotes within quotes. 29 30 Like all `Readability` issues, this one is not a technical concern. 31 But you can improve the odds of others reading and liking your code by making 32 it easier to follow. 33 """, 34 params: [ 35 maximum_allowed_quotes: "The maximum amount of escaped quotes you want to tolerate." 36 ] 37 ] 38 39 @quote_codepoint 34 40 41 @doc false 42 @impl true 43 def run(%SourceFile{} = source_file, params) do 44 issue_meta = IssueMeta.for(source_file, params) 45 46 maximum_allowed_quotes = Params.get(params, :maximum_allowed_quotes, __MODULE__) 47 48 case remove_heredocs_and_convert_to_ast(source_file) do 49 {:ok, ast} -> 50 Credo.Code.prewalk(ast, &traverse(&1, &2, issue_meta, maximum_allowed_quotes)) 51 52 {:error, errors} -> 53 IO.warn("Unexpected error while parsing #{source_file.filename}: #{inspect(errors)}") 54 [] 55 end 56 end 57 58 defp remove_heredocs_and_convert_to_ast(source_file) do 59 source_file 60 |> Heredocs.replace_with_spaces() 61 |> Credo.Code.ast() 62 end 63 64 defp traverse( 65 {maybe_sigil, meta, [str | rest_ast]} = ast, 66 issues, 67 issue_meta, 68 maximum_allowed_quotes 69 ) do 70 line_no = meta[:line] 71 72 cond do 73 is_sigil(maybe_sigil) -> 74 {rest_ast, issues} 75 76 is_binary(str) -> 77 { 78 rest_ast, 79 issues_for_string_literal( 80 str, 81 maximum_allowed_quotes, 82 issues, 83 issue_meta, 84 line_no 85 ) 86 } 87 88 true -> 89 {ast, issues} 90 end 91 end 92 93 defp traverse(ast, issues, _issue_meta, _maximum_allowed_quotes) do 94 {ast, issues} 95 end 96 97 defp is_sigil(maybe_sigil) when is_atom(maybe_sigil) do 98 maybe_sigil 99 |> Atom.to_string() 100 |> String.starts_with?("sigil_") 101 end 102 103 defp is_sigil(_), do: false 104 105 defp issues_for_string_literal( 106 string, 107 maximum_allowed_quotes, 108 issues, 109 issue_meta, 110 line_no 111 ) do 112 if too_many_quotes?(string, maximum_allowed_quotes) do 113 [issue_for(issue_meta, line_no, string, maximum_allowed_quotes) | issues] 114 else 115 issues 116 end 117 end 118 119 defp too_many_quotes?(string, limit) do 120 too_many_quotes?(string, 0, limit) 121 end 122 123 defp too_many_quotes?(_string, count, limit) when count > limit do 124 true 125 end 126 127 defp too_many_quotes?(<<>>, _count, _limit) do 128 false 129 end 130 131 defp too_many_quotes?(<<c::utf8, rest::binary>>, count, limit) 132 when c == @quote_codepoint do 133 too_many_quotes?(rest, count + 1, limit) 134 end 135 136 defp too_many_quotes?(<<_::utf8, rest::binary>>, count, limit) do 137 too_many_quotes?(rest, count, limit) 138 end 139 140 defp too_many_quotes?(<<_::binary>>, _count, _limit) do 141 false 142 end 143 144 defp issue_for(issue_meta, line_no, trigger, maximum_allowed_quotes) do 145 format_issue( 146 issue_meta, 147 message: 148 "More than #{maximum_allowed_quotes} quotes found inside string literal, consider using a sigil instead.", 149 trigger: trigger, 150 line_no: line_no 151 ) 152 end 153 end