Current section
Files
Jump to
Current section
Files
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 fixable?, do: true
# ── Check ──────────────────────────────────────────────────────
@impl true
def check(ast, _opts) do
{_ast, issues} =
Macro.prewalk(ast, [], fn node, acc ->
case detect(node) do
{:ok, meta} -> {node, [build_issue(meta) | acc]}
:skip -> {node, acc}
end
end)
Enum.reverse(issues)
end
# Direct call: Keyword.get(list, integer_key)
defp detect({{:., _, [{:__aliases__, _, [:Keyword]}, :get]}, meta, [_list, key]}) do
if integer_literal?(key), do: {:ok, meta}, else: :skip
end
# Piped call: expr |> Keyword.get(integer_key)
defp detect({{:., _, [{:__aliases__, _, [:Keyword]}, :get]}, meta, [key]}) do
if integer_literal?(key), do: {:ok, meta}, else: :skip
end
defp detect(_), do: :skip
# In the AST, positive integers are bare values (is_integer/1).
# Negative integers are {:-, _, [positive_integer]} (unary minus).
defp integer_literal?(n) when is_integer(n), do: true
defp integer_literal?({:-, _, [n]}) when is_integer(n), do: true
defp integer_literal?(_), do: false
# ── Fix ────────────────────────────────────────────────────────
# Direct: Keyword.get(var, integer)
@direct_re ~r/Keyword\.get\((\w+),\s*(-?\d+)\)/
# Piped: |> Keyword.get(integer)
@piped_re ~r/Keyword\.get\((-?\d+)\)/
@impl true
def fix(source, _opts) do
source
|> String.split("\n")
|> Enum.map(&fix_line/1)
|> Enum.join("\n")
end
defp fix_line(line) do
line
|> fix_direct()
|> fix_piped()
end
defp fix_direct(line) do
Regex.replace(@direct_re, line, fn _full, var, index_str ->
direct_replacement(var, String.to_integer(index_str))
end)
end
defp fix_piped(line) do
Regex.replace(@piped_re, line, fn _full, index_str ->
piped_replacement(String.to_integer(index_str))
end)
end
# ── Replacements ───────────────────────────────────────────────
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})"
# ── Issue ──────────────────────────────────────────────────────
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