Current section
Files
Jump to
Current section
Files
lib/function_matcher.ex
defmodule Credence.FunctionMatcher do
@moduledoc """
Finds the closest matching defined function for an undefined function call.
When the compiler reports `undefined function NAME/ARITY`, this module
searches the source for defined functions in the same module with matching
arity and ranks them by name similarity. Used by `UndefinedLocalFunction`
and `UndefinedFunction` to fix misspelled or mangled function calls.
## Scoring ladder
| Score | Match type |
|-------|-----------------------------------------------|
| 100 | Exact name + `?` suffix (`palindrome` → `palindrome?`) |
| 95 | Exact name + `!` suffix (`save` → `save!`) |
| 90 | `__` demangles to `?` (`perfect__` → `perfect?`) |
| 85 | `__` demangles to `!` (`save__` → `save!`) |
| 80 | Candidate is prefix of name (`fib` ← `fibonacci`) |
| 75 | Name is prefix of candidate (`find` → `find_largest`) |
| 70 | One contains the other (`fibonacci` ∈ `do_fibonacci`) |
| 0–60 | Jaro distance scaled to 0–60 |
The module never "gives up" — if any function with matching arity exists,
it's returned. The pipeline validates the fix by compiling.
"""
@type candidate :: %{
name: String.t(),
arity: non_neg_integer(),
visibility: :def | :defp,
score: non_neg_integer()
}
@doc """
Returns the best matching function name, or `:no_candidates` if the
module has zero functions with the given arity.
"""
@spec suggest(String.t(), String.t(), String.t(), non_neg_integer(), keyword()) ::
{:ok, String.t()} | :no_candidates
def suggest(source, module_name, undefined_name, arity, opts \\ []) do
case candidates(source, module_name, undefined_name, arity, opts) do
[] -> :no_candidates
[best | _] -> {:ok, best.name}
end
end
@doc """
Returns all candidate functions with matching arity, sorted by score
descending. Each candidate has `:name`, `:arity`, `:visibility`, and `:score`.
"""
@spec candidates(String.t(), String.t(), String.t(), non_neg_integer(), keyword()) ::
[candidate()]
def candidates(source, module_name, undefined_name, arity, opts \\ []) do
visibility = Keyword.get(opts, :visibility, :any)
source
|> defined_functions(module_name)
|> Enum.uniq_by(fn %{name: name, arity: a} -> {name, a} end)
|> filter_by_arity(arity)
|> filter_by_visibility(visibility)
|> Enum.map(fn func -> Map.put(func, :score, score(undefined_name, func.name)) end)
|> Enum.sort_by(& &1.score, :desc)
end
defp defined_functions(source, module_name) do
case Sourceror.parse_string(source) do
{:ok, ast} ->
ast
|> find_module_body(module_name)
|> extract_functions()
{:error, _} ->
[]
end
end
defp find_module_body(ast, module_name) do
{_, result} =
Macro.prewalk(ast, nil, fn
{:defmodule, _, [{:__aliases__, _, parts}, kw]} = node, acc when is_list(kw) ->
if module_parts_match?(parts, module_name) do
case Credence.RuleHelpers.extract_do_body(kw) do
{:ok, body} -> {node, body}
:error -> {node, acc}
end
else
{node, acc}
end
node, acc ->
{node, acc}
end)
case result do
nil -> []
{:__block__, _, statements} -> statements
single -> [single]
end
end
defp module_parts_match?(parts, module_name) do
parts
|> Enum.map_join(".", &Atom.to_string/1)
|> Kernel.==(module_name)
end
defp extract_functions(body) do
Enum.flat_map(body, fn
# def/defp with guard: def foo(x) when is_integer(x), do: ...
{visibility, _, [{:when, _, [{name, _, args}, _guard]} | _]}
when visibility in [:def, :defp] and is_atom(name) ->
[%{name: Atom.to_string(name), arity: arg_count(args), visibility: visibility}]
# def/defp without guard: def foo(x), do: ...
{visibility, _, [{name, _, args} | _]}
when visibility in [:def, :defp] and is_atom(name) ->
[%{name: Atom.to_string(name), arity: arg_count(args), visibility: visibility}]
_ ->
[]
end)
end
defp arg_count(nil), do: 0
defp arg_count(args) when is_list(args), do: length(args)
defp arg_count(_), do: 0
defp filter_by_arity(functions, arity) do
Enum.filter(functions, fn %{arity: a} -> a == arity end)
end
defp filter_by_visibility(functions, :any), do: functions
defp filter_by_visibility(functions, :public_only) do
Enum.filter(functions, fn %{visibility: v} -> v == :def end)
end
defp score(undefined, candidate) do
cond do
# Exact name + ? suffix (palindrome → palindrome?)
candidate == undefined <> "?" ->
100
# Exact name + ! suffix (save → save!)
candidate == undefined <> "!" ->
95
# __ demangles to ? (perfect__ → perfect?)
String.contains?(undefined, "__") and
candidate == String.replace(undefined, "__", "?") ->
90
# __ demangles to ! (save__ → save!)
String.contains?(undefined, "__") and
candidate == String.replace(undefined, "__", "!") ->
85
# Candidate is a prefix of the undefined name (fib ← fibonacci)
candidate != undefined and String.starts_with?(undefined, candidate) ->
80
# Undefined name is a prefix of the candidate (find → find_largest)
candidate != undefined and String.starts_with?(candidate, undefined) ->
75
# One contains the other (fibonacci ∈ do_fibonacci)
candidate != undefined and
(String.contains?(candidate, undefined) or
String.contains?(undefined, candidate)) ->
70
# Fall back to Jaro distance scaled to 0–60
true ->
jaro = String.jaro_distance(undefined, candidate)
round(jaro * 60)
end
end
end