Current section

Files

Jump to
credence lib rule no_underscore_function_name.ex
Raw

lib/rule/no_underscore_function_name.ex

defmodule Credence.Rule.NoUnderscoreFunctionName do
@moduledoc """
Detects function names that use a leading underscore to indicate privacy,
a convention borrowed from Python that is non-idiomatic in Elixir.
## Why this matters
In Python, `_private_method` signals "internal use." In Elixir, `defp`
is the privacy mechanism, and a leading underscore on a name signals
"unused variable" — not "private function." LLMs frequently generate
helper functions like `_factorial`, `_do_find`, or `_fibonacci` because
their training data mixes Python and Elixir conventions.
The Elixir convention for recursive helpers is the `do_` prefix:
# Flagged — Python convention
defp _factorial(0, acc), do: acc
defp _factorial(n, acc), do: _factorial(n - 1, n * acc)
# Idiomatic — Elixir convention
defp do_factorial(0, acc), do: acc
defp do_factorial(n, acc), do: do_factorial(n - 1, n * acc)
## Detection scope
Flags any `def` or `defp` clause where the function name starts with
a single underscore. Names starting with double underscores (`__`)
are excluded — those are legitimate Elixir/Erlang callbacks such as
`__using__/1`, `__before_compile__/1`, and `__info__/1`.
## Severity
`:warning`
"""
@behaviour Credence.Rule
alias Credence.Issue
@impl true
def check(ast, _opts) do
{_ast, issues} =
Macro.prewalk(ast, [], fn node, issues ->
case check_node(node) do
{:ok, issue} -> {node, [issue | issues]}
:error -> {node, issues}
end
end)
Enum.reverse(issues)
end
# ------------------------------------------------------------
# NODE MATCHING
# ------------------------------------------------------------
# Guarded: def/defp name(args) when guard, do: body
# Must come before the unguarded clause because {:when, _, _}
# also matches {fn_name, _, args} where fn_name == :when.
defp check_node({def_type, meta, [{:when, _, [{fn_name, _, args}, _guard]}, _body]})
when def_type in [:def, :defp] and is_atom(fn_name) and is_list(args) do
if underscore_prefixed?(fn_name) do
{:ok, build_issue(def_type, fn_name, length(args), meta)}
else
:error
end
end
# Unguarded: def/defp name(args), do: body
defp check_node({def_type, meta, [{fn_name, _, args}, _body]})
when def_type in [:def, :defp] and is_atom(fn_name) and is_list(args) do
if underscore_prefixed?(fn_name) do
{:ok, build_issue(def_type, fn_name, length(args), meta)}
else
:error
end
end
defp check_node(_), do: :error
# ------------------------------------------------------------
# HELPERS
# ------------------------------------------------------------
defp underscore_prefixed?(name) do
str = Atom.to_string(name)
String.starts_with?(str, "_") and not String.starts_with?(str, "__")
end
defp suggested_name(fn_name) do
fn_name
|> Atom.to_string()
|> String.replace_leading("_", "do_")
end
# ------------------------------------------------------------
# MESSAGE GENERATION
# ------------------------------------------------------------
defp build_issue(def_type, fn_name, arity, meta) do
%Issue{
rule: :no_underscore_function_name,
severity: :warning,
message: build_message(def_type, fn_name, arity),
meta: %{line: Keyword.get(meta, :line)}
}
end
defp build_message(def_type, fn_name, arity) do
suggested = suggested_name(fn_name)
"""
`#{def_type} #{fn_name}/#{arity}` uses a Python-style underscore prefix.
In Elixir, `defp` already makes a function private. The leading \
underscore convention signals "unused variable," not "private function."
Use the `do_` prefix instead:
defp #{suggested}(...)
"""
end
end