Current section

Files

Jump to
credence lib pattern no_keyword_get_integer_key.ex
Raw

lib/pattern/no_keyword_get_integer_key.ex

defmodule Credence.Pattern.NoKeywordGetIntegerKey do
@moduledoc """
Detects `Keyword.get(list, integer)` where the key is an integer literal.
`Keyword.get/2` requires atom keys — its guard is `when is_atom(key)`.
Passing an integer key always crashes at runtime with `FunctionClauseError`.
LLMs produce this when translating Python's `list[-1]` (last element)
or `dict[index]` into Elixir, reaching for `Keyword.get` as the
closest-looking dictionary lookup.
## Bad
Keyword.get(acc, -1)
Keyword.get(list, 0)
acc |> Keyword.get(-1)
## Good
List.last(acc)
List.first(list)
acc |> List.last()
## What is flagged
Any call to `Keyword.get` with exactly two arguments where the second
is an integer literal (direct call), or one argument that is an integer
literal (piped call). Three-argument calls are not flagged.
## Auto-fix
Rewrites based on the index value:
Keyword.get(var, -1) → List.last(var)
Keyword.get(var, 0) → List.first(var)
Keyword.get(var, n) → Enum.at(var, n)
Only fixes when the list argument is a simple variable name.
"""
use Credence.Pattern.Rule
alias Credence.Issue
@impl true
def check(ast, _opts) do
piped = piped_get_positions(ast)
{_ast, issues} =
Macro.prewalk(ast, [], fn node, acc ->
case detect(node, piped) do
{:ok, meta} -> {node, [build_issue(meta) | acc]}
:skip -> {node, acc}
end
end)
Enum.reverse(issues)
end
# Direct call: Keyword.get(list, integer_key). Only when NOT piped — a 2-arg
# node that is the RHS of a pipe is really `Keyword.get/3` (piped list + key +
# default), so its second arg is the DEFAULT, not the key (the rule does not
# flag 3-arg calls).
defp detect({{:., _, [{:__aliases__, _, [:Keyword]}, :get]}, meta, [_list, key]}, piped) do
if not MapSet.member?(piped, position(meta)) and integer_literal?(key),
do: {:ok, meta},
else: :skip
end
# Piped call: expr |> Keyword.get(integer_key). A 1-arg node only ever occurs
# in a pipe (Keyword.get needs ≥2 args), so the sole arg is the key.
defp detect({{:., _, [{:__aliases__, _, [:Keyword]}, :get]}, meta, [key]}, _piped) do
if integer_literal?(key), do: {:ok, meta}, else: :skip
end
defp detect(_, _), do: :skip
# Positions of `Keyword.get` calls that are the RHS of a `|>` (their list
# argument comes from the pipe, shifting the explicit args left by one).
defp piped_get_positions(ast) do
{_ast, set} =
Macro.prewalk(ast, MapSet.new(), fn
{:|>, _, [_lhs, {{:., _, [{:__aliases__, _, [:Keyword]}, :get]}, meta, _}]} = node, acc ->
{node, MapSet.put(acc, position(meta))}
node, acc ->
{node, acc}
end)
set
end
defp position(meta), do: {Keyword.get(meta, :line), Keyword.get(meta, :column)}
# Positives are `{:__block__, _, [n]}` (Sourceror wraps int literals).
# Negatives are `{:-, _, [{:__block__, _, [n]}]}` — unary minus over
# a wrapped positive.
defp integer_literal?({:__block__, _, [n]}) when is_integer(n), do: true
defp integer_literal?({:-, _, [{:__block__, _, [n]}]}) when is_integer(n), do: true
defp integer_literal?(_), do: false
@impl true
def fix_patches(ast, _opts) do
piped = piped_get_positions(ast)
{_ast, patches} =
Macro.prewalk(ast, [], fn node, acc ->
case detect_fix(node, piped) do
{:ok, patch} -> {node, [patch | acc]}
:skip -> {node, acc}
end
end)
Enum.reverse(patches)
end
# Direct: Keyword.get(list, integer) — only when list is a simple var
# (matches the legacy regex's `(\w+)` capture group) and the call is NOT piped
# (a piped 2-arg node is `Keyword.get/3`, whose second arg is the default).
defp detect_fix(
{{:., _, [{:__aliases__, _, [:Keyword]}, :get]}, meta, [list, key]} = node,
piped
) do
with false <- MapSet.member?(piped, position(meta)),
{:ok, n} <- integer_value(key),
{:ok, var} <- simple_var(list) do
{:ok, %{range: Sourceror.get_range(node), change: direct_replacement(var, n)}}
else
_ -> :skip
end
end
# Piped: expr |> Keyword.get(integer)
defp detect_fix({{:., _, [{:__aliases__, _, [:Keyword]}, :get]}, _meta, [key]} = node, _piped) do
case integer_value(key) do
{:ok, n} ->
{:ok, %{range: Sourceror.get_range(node), change: piped_replacement(n)}}
:error ->
:skip
end
end
defp detect_fix(_, _), do: :skip
defp integer_value({:__block__, _, [n]}) when is_integer(n), do: {:ok, n}
defp integer_value({:-, _, [{:__block__, _, [n]}]}) when is_integer(n), do: {:ok, -n}
defp integer_value(_), do: :error
defp simple_var({name, _, ctx}) when is_atom(name) and is_atom(ctx),
do: {:ok, Atom.to_string(name)}
defp simple_var(_), do: :error
defp direct_replacement(var, -1), do: "List.last(#{var})"
defp direct_replacement(var, 0), do: "List.first(#{var})"
defp direct_replacement(var, n), do: "Enum.at(#{var}, #{n})"
defp piped_replacement(-1), do: "List.last()"
defp piped_replacement(0), do: "List.first()"
defp piped_replacement(n), do: "Enum.at(#{n})"
defp build_issue(meta) do
%Issue{
rule: :no_keyword_get_integer_key,
message: """
`Keyword.get/2` requires atom keys — integer keys always crash \
at runtime with `FunctionClauseError`.
This is usually a Python `list[-1]` translation. Use list \
access functions instead:
Keyword.get(list, -1) → List.last(list)
Keyword.get(list, 0) → List.first(list)
Keyword.get(list, n) → Enum.at(list, n)
""",
meta: %{line: Keyword.get(meta, :line)}
}
end
end