Current section
Files
Jump to
Current section
Files
lib/pattern/no_sort_then_reverse.ex
defmodule Credence.Pattern.NoSortThenReverse do
@moduledoc """
Performance & readability rule: Detects the pattern of calling `Enum.sort/1,2`
followed by `Enum.reverse/1` on the result, where the sort direction can be
statically determined.
Sorting ascending then reversing is equivalent to `Enum.sort(list, :desc)`
but wastes a full O(n) pass for the reversal.
## Recognised direction forms
Enum.sort(nums) # default :asc
Enum.sort(nums, :asc) # explicit atom
Enum.sort(nums, :desc) # explicit atom
Enum.sort(nums, &>=/2) # capture → :desc
Enum.sort(nums, &<=/2) # capture → :asc
Enum.sort(nums, fn a, b -> a > b end) # anonymous comparator → :desc
Enum.sort(nums, fn a, b -> b > a end) # flipped comparator → :asc
## Not flagged
Unresolvable directions such as `Enum.sort(nums, dir) |> Enum.reverse()` or
opaque comparators like `Enum.sort(nums, &MyModule.compare/2) |> Enum.reverse()`
are not flagged because we cannot determine the flipped direction.
"""
use Credence.Pattern.Rule
alias Credence.Issue
alias Credence.RuleHelpers
@impl true
def check(ast, _opts) do
{_ast, issues} =
Macro.prewalk(ast, [], fn
# Pipeline form: ... |> Enum.sort(...) |> Enum.reverse()
{:|>, meta, [left, right]} = node, issues ->
sort_node = rightmost(left)
context = if match?({:|>, _, _}, left), do: :pipe, else: :direct
if remote_call?(right, :Enum, :reverse) and
remote_call?(sort_node, :Enum, :sort) and
resolvable_direction?(call_args(sort_node), context) do
{node, [build_issue(meta) | issues]}
else
{node, issues}
end
# Nested call form: Enum.reverse(Enum.sort(...))
{{:., _, [{:__aliases__, _, [:Enum]}, :reverse]}, meta,
[{{:., _, [{:__aliases__, _, [:Enum]}, :sort]}, _, sort_args}]} = node,
issues ->
if resolvable_direction?(sort_args, :direct) do
{node, [build_issue(meta) | issues]}
else
{node, issues}
end
node, issues ->
{node, issues}
end)
Enum.reverse(issues)
end
@impl true
def fix_patches(ast, _opts) do
RuleHelpers.patches_from_postwalk(ast, fn
# Pipeline: ... |> Enum.sort(...) |> Enum.reverse()
{:|>, pipe_meta, [left, reverse_node]} = node ->
if remote_call?(reverse_node, :Enum, :reverse) do
fix_pipeline(left, pipe_meta, node)
else
node
end
# Nested: Enum.reverse(Enum.sort(...))
{{:., _, [{:__aliases__, _, [:Enum]}, :reverse]}, _, [sort_node]} = node ->
if remote_call?(sort_node, :Enum, :sort) do
fix_nested_sort(sort_node, node)
else
node
end
node ->
node
end)
end
# Multi-step pipe: before |> Enum.sort() |> Enum.reverse()
defp fix_pipeline({:|>, _, [before_sort, sort_node]}, pipe_meta, fallback) do
if remote_call?(sort_node, :Enum, :sort) do
args = call_args(sort_node) |> normalize_args()
if fixable_pipe_args?(args) do
{:|>, pipe_meta, [before_sort, build_sort_call(pipe_flip_args(args))]}
else
fallback
end
else
fallback
end
end
# Direct call piped to reverse: Enum.sort(x) |> Enum.reverse()
defp fix_pipeline(sort_node, _pipe_meta, fallback) do
if remote_call?(sort_node, :Enum, :sort) do
args = call_args(sort_node) |> normalize_args()
if fixable_direct_args?(args) do
build_sort_call(direct_flip_args(args))
else
fallback
end
else
fallback
end
end
# Nested: Enum.reverse(Enum.sort(...))
defp fix_nested_sort(sort_node, fallback) do
args = call_args(sort_node) |> normalize_args()
if fixable_direct_args?(args) do
build_sort_call(direct_flip_args(args))
else
fallback
end
end
# Sourceror wraps literal atoms (and other literals) in
# {:__block__, meta, [value]} nodes to preserve source metadata.
# Unwrap them so our pattern-matching helpers see plain atoms.
defp normalize_args(args), do: Enum.map(args, &normalize_arg/1)
defp normalize_arg({:__block__, _, [literal]}) when is_atom(literal), do: literal
defp normalize_arg(other), do: other
# Pipe context: no subject (pipe provides it), only sort direction
defp fixable_pipe_args?(args), do: pipe_sort_direction(args) != :unknown
defp pipe_flip_args(args) do
case pipe_sort_direction(args) do
:asc -> [:desc]
:desc -> []
end
end
# Direct context: first arg is the subject
defp fixable_direct_args?(args), do: direct_sort_direction(args) != :unknown
defp direct_flip_args(args) do
subject = hd(args)
case direct_sort_direction(args) do
:asc -> [subject, :desc]
:desc -> [subject]
end
end
# Used by check/2 — works on raw (non-normalized) sort args
defp resolvable_direction?(sort_args, context) do
normalized = normalize_args(sort_args)
case context do
:pipe -> pipe_sort_direction(normalized) != :unknown
:direct -> direct_sort_direction(normalized) != :unknown
end
end
# Pipe args (no subject)
defp pipe_sort_direction([]), do: :asc
defp pipe_sort_direction([:asc]), do: :asc
defp pipe_sort_direction([:desc]), do: :desc
defp pipe_sort_direction([comparator]), do: resolve_comparator(comparator)
defp pipe_sort_direction(_), do: :unknown
# Direct args (first is subject)
defp direct_sort_direction([_subject]), do: :asc
defp direct_sort_direction([_subject, :asc]), do: :asc
defp direct_sort_direction([_subject, :desc]), do: :desc
defp direct_sort_direction([_subject, comparator]), do: resolve_comparator(comparator)
defp direct_sort_direction(_), do: :unknown
# Function captures: &>=/2, &>/2 → :desc; &<=/2, &</2 → :asc
defp resolve_comparator({:&, _, [{:/, _, [{op, _, _}, 2]}]})
when op in [:>=, :>],
do: :desc
defp resolve_comparator({:&, _, [{:/, _, [{op, _, _}, 2]}]})
when op in [:<=, :<],
do: :asc
defp resolve_comparator({:&, _, [{:/, _, [{op, _, _}, {:__block__, _, [2]}]}]})
when op in [:>=, :>],
do: :desc
defp resolve_comparator({:&, _, [{:/, _, [{op, _, _}, {:__block__, _, [2]}]}]})
when op in [:<=, :<],
do: :asc
# Anonymous comparators: fn a, b -> a OP b end
defp resolve_comparator({:fn, _, [{:->, _, [[p1, p2], {op, _, [left, right]}]}]})
when op in [:>, :>=] do
cond do
same_var?(p1, left) and same_var?(p2, right) -> :desc
same_var?(p2, left) and same_var?(p1, right) -> :asc
true -> :unknown
end
end
defp resolve_comparator({:fn, _, [{:->, _, [[p1, p2], {op, _, [left, right]}]}]})
when op in [:<, :<=] do
cond do
same_var?(p1, left) and same_var?(p2, right) -> :asc
same_var?(p2, left) and same_var?(p1, right) -> :desc
true -> :unknown
end
end
defp resolve_comparator(_), do: :unknown
defp same_var?({name, _, _}, {name, _, _}) when is_atom(name), do: true
defp same_var?(_, _), do: false
defp build_sort_call(args) do
{{:., [], [{:__aliases__, [], [:Enum]}, :sort]}, [], args}
end
defp call_args({{:., _, _}, _, args}), do: args
defp call_args(_), do: []
defp rightmost({:|>, _, [_, right]}), do: right
defp rightmost(other), do: other
defp remote_call?(node, mod, func) do
match?({{:., _, [{:__aliases__, _, [^mod]}, ^func]}, _, _}, node)
end
defp build_issue(meta) do
%Issue{
rule: :no_sort_then_reverse,
message:
"Avoid `Enum.sort/1` followed by `Enum.reverse/1`. " <>
"Use `Enum.sort(list, :desc)` instead to sort in descending order in a single pass.",
meta: %{line: Keyword.get(meta, :line)}
}
end
end