Current section
Files
Jump to
Current section
Files
lib/pattern/prefer_string_slice_for_trim_last_char.ex
defmodule Credence.Pattern.PreferStringSliceForTrimLastChar do
@moduledoc """
Detects the verbose `case String.graphemes(str)` idiom for removing the
last character from a string, and rewrites it to the idiomatic
`String.slice(str, 0..-2//1)`.
## Bad
case String.graphemes(str) do
[] -> ""
[_last] -> ""
[_head | _tail] -> String.slice(str, 0, String.length(str) - 1)
end
## Good
String.slice(str, 0..-2//1)
"""
use Credence.Pattern.Rule
alias Credence.RuleHelpers
@impl true
def check(ast, _opts) do
{_ast, issues} =
Macro.prewalk(ast, [], fn
node, issues ->
case match_trim_last_char(node) do
{:ok, _var} ->
{node, [build_issue(node) | issues]}
:error ->
{node, issues}
end
end)
Enum.reverse(issues)
end
@impl true
def fix_patches(ast, opts) do
source = Keyword.fetch!(opts, :source)
RuleHelpers.patches_from_ast_transform(ast, source, &transform_ast/1)
end
defp transform_ast(ast) do
Macro.postwalk(ast, fn
node ->
case match_trim_last_char(node) do
{:ok, var} ->
build_replacement(var)
:error ->
node
end
end)
end
# Match: case String.graphemes(var) do
# [] -> ""
# [_last] -> ""
# [_head | _tail] -> String.slice(var, 0, String.length(var) - 1)
# end
defp match_trim_last_char({:case, _meta, [subject, clauses_kw]}) do
with {:ok, var} <- match_graphemes_subject(subject),
{:ok, clauses} <- extract_do_clauses(clauses_kw),
true <- length(clauses) == 3,
true <- match_empty_clause?(Enum.at(clauses, 0)),
true <- match_single_clause?(Enum.at(clauses, 1)),
true <- match_head_tail_clause?(Enum.at(clauses, 2), var) do
{:ok, var}
else
_ -> :error
end
end
defp match_trim_last_char(_), do: :error
# Match: String.graphemes(var)
defp match_graphemes_subject(
{{:., _, [{:__aliases__, _, [:String]}, :graphemes]}, _, [{var, _, nil}]}
)
when is_atom(var) do
{:ok, var}
end
defp match_graphemes_subject(_), do: :error
# Extract clauses from the do block
defp extract_do_clauses([{{:__block__, _, [:do]}, clauses}]) when is_list(clauses) do
{:ok, clauses}
end
defp extract_do_clauses(_), do: :error
# Match: [] -> ""
defp match_empty_clause?({:->, _, [[{:__block__, _, [[]]}], {:__block__, _, [""]}]}) do
true
end
defp match_empty_clause?(_), do: false
# Match: [_last] -> ""
defp match_single_clause?(
{:->, _,
[
[
{:__block__, _, [[{var, _, nil}]]}
],
{:__block__, _, [""]}
]}
)
when is_atom(var) do
true
end
defp match_single_clause?(_), do: false
# Match: [_head | _tail] -> String.slice(var, 0, String.length(var) - 1)
defp match_head_tail_clause?(
{:->, _,
[
[
{:__block__, _,
[
[
{:|, _,
[
{head, _, nil},
{tail, _, nil}
]}
]
]}
],
body
]},
var
)
when is_atom(head) and is_atom(tail) do
match_slice_body?(body, var)
end
defp match_head_tail_clause?(_, _), do: false
# Match: String.slice(var, 0, String.length(var) - 1)
defp match_slice_body?(
{{:., _, [{:__aliases__, _, [:String]}, :slice]}, _,
[
{var, _, nil},
{:__block__, _, [0]},
{:-, _,
[
{{:., _, [{:__aliases__, _, [:String]}, :length]}, _, [{var2, _, nil}]},
{:__block__, _, [1]}
]}
]},
var
)
when is_atom(var) and is_atom(var2) and var == var2 do
true
end
defp match_slice_body?(_, _), do: false
# Build: String.slice(var, 0..-2//1)
defp build_replacement(var) do
{:__block__, [],
[
{{:., [], [{:__aliases__, [], [:String]}, :slice]}, [],
[
{var, [], nil},
{:..//, [],
[
{:__block__, [], [0]},
{:-, [], [{:__block__, [], [2]}]},
{:__block__, [], [1]}
]}
]}
]}
end
defp build_issue(node) do
meta =
case node do
{_, m, _} when is_list(m) -> m
_ -> []
end
%Issue{
rule: :prefer_string_slice_for_trim_last_char,
message:
"Use `String.slice(str, 0..-2//1)` instead of a verbose `case` on " <>
"`String.graphemes/1` to remove the last character.",
meta: %{line: Keyword.get(meta, :line)}
}
end
end