Current section

Files

Jump to
credence lib pattern no_enum_at_binary_search.ex
Raw

lib/pattern/no_enum_at_binary_search.ex

defmodule Credence.Pattern.NoEnumAtBinarySearch do
@moduledoc """
Performance rule: Flags `Enum.at/2` inside **recursive** binary search functions.
Elixir lists are linked lists. `Enum.at/2` is an O(n) operation. Using it
inside a recursive binary search results in O(n log n) complexity, defeating
the purpose of the algorithm.
## Not auto-fixable
Recursive functions require a manual refactor: create a wrapper function
that calls `List.to_tuple/1`, change the recursive helper's signature to
accept the tuple, and update all recursive call sites. This structural
change cannot be performed safely by an automated tool.
## Bad
def search(list, target, low, high) when low <= high do
mid = low + div(high - low, 2)
mid_val = Enum.at(list, mid) # O(n) on every recursive call
cond do
mid_val == target -> mid
mid_val < target -> search(list, target, mid + 1, high)
true -> search(list, target, low, mid - 1)
end
end
## Good
def search(list, target) do
tuple = List.to_tuple(list)
do_search(tuple, target, 0, tuple_size(tuple) - 1)
end
defp do_search(tuple, target, low, high) when low <= high do
mid = low + div(high - low, 2)
mid_val = elem(tuple, mid) # O(1)
cond do
mid_val == target -> mid
mid_val < target -> do_search(tuple, target, mid + 1, high)
true -> do_search(tuple, target, low, mid - 1)
end
end
See also `Credence.Pattern.NoEnumAtMidpointAccess` which catches the same
anti-pattern in non-recursive functions and can auto-fix it.
"""
use Credence.Pattern.Rule
alias Credence.Issue
@impl true
def fixable?, do: false
@impl true
def check(ast, _opts) do
ast
|> collect_function_defs()
|> Enum.filter(fn {name, body} -> recursive?(body, name) end)
|> Enum.flat_map(fn {_name, body} -> find_issues_in_body(body) end)
end
defp collect_function_defs(ast) do
{_, fns} =
Macro.prewalk(ast, [], fn
{kind, _meta, [head, body_kw]} = node, acc
when kind in [:def, :defp] and is_list(body_kw) ->
name = extract_func_name(head)
body = extract_do_body(body_kw)
if name != nil and body != nil do
{node, [{name, body} | acc]}
else
{node, acc}
end
node, acc ->
{node, acc}
end)
fns
end
defp find_issues_in_body(body) do
{_, {issues, _mids}} =
Macro.prewalk(body, {[], MapSet.new()}, fn
{:=, _, [{var, _, _}, expr]} = node, {issues, mids} when is_atom(var) ->
mids = if midpoint_expr?(expr), do: MapSet.put(mids, var), else: mids
{node, {issues, mids}}
# Direct: Enum.at(list, mid)
{{:., _, [{:__aliases__, _, [:Enum]}, :at]}, meta, [_list, index]} = node, {issues, mids} ->
if flagged_index?(index, mids) do
{node, {[trigger_issue(meta) | issues], mids}}
else
{node, {issues, mids}}
end
# Piped: list |> Enum.at(mid)
{:|>, meta, [_list, {{:., _, [{:__aliases__, _, [:Enum]}, :at]}, _, [index]}]} = node,
{issues, mids} ->
if flagged_index?(index, mids) do
{node, {[trigger_issue(meta) | issues], mids}}
else
{node, {issues, mids}}
end
node, acc ->
{node, acc}
end)
Enum.reverse(issues)
end
defp extract_do_body(body_kw) when is_list(body_kw) do
Enum.find_value(body_kw, fn
{{:__block__, _, [:do]}, body} -> body
{:do, body} -> body
_ -> nil
end)
end
defp extract_func_name({:when, _, [{name, _, _} | _]}), do: name
defp extract_func_name({name, _, _}) when is_atom(name), do: name
defp extract_func_name(_), do: nil
defp recursive?(_, nil), do: true
defp recursive?(body, func_name) do
{_, found} =
Macro.prewalk(body, false, fn
{^func_name, _, args} = node, _ when is_list(args) -> {node, true}
node, acc -> {node, acc}
end)
found
end
defp flagged_index?(index, mids) do
mid_var?(index, mids) or midpoint_expr?(index)
end
defp mid_var?({var, _, _}, mids) when is_atom(var), do: MapSet.member?(mids, var)
defp mid_var?(_, _), do: false
defp unwrap_literal({:__block__, _, [val]}), do: val
defp unwrap_literal(val), do: val
defp midpoint_expr?({:+, _, [_low, {:div, _, [{:-, _, [_, _]}, d]}]}),
do: unwrap_literal(d) == 2
defp midpoint_expr?({:div, _, [{:+, _, [_, _]}, d]}),
do: unwrap_literal(d) == 2
defp midpoint_expr?({:+, _, [{:div, _, [{:-, _, [_, _]}, d]}, _]}),
do: unwrap_literal(d) == 2
defp midpoint_expr?(_), do: false
defp trigger_issue(meta) do
%Issue{
rule: :no_enum_at_binary_search,
message:
"Using `Enum.at/2` with a dynamic index on a list is O(n). " <>
"For binary search or frequent random access, convert the list " <>
"to a tuple with `List.to_tuple/1` and use `elem/2` for O(1) access.",
meta: %{line: Keyword.get(meta, :line)}
}
end
end