Skip to content

Instantly share code, notes, and snippets.

@Nezteb
Created July 14, 2025 21:10
Show Gist options
  • Select an option

  • Save Nezteb/be83b29f67d43a74dd220d6e7621f7e4 to your computer and use it in GitHub Desktop.

Select an option

Save Nezteb/be83b29f67d43a74dd220d6e7621f7e4 to your computer and use it in GitHub Desktop.
if Code.ensure_loaded?(Credo.Check) do
defmodule Credo.Check.Warning.EnumSortByFieldAccess do
@moduledoc """
Custom Credo check to detect Enum.sort_by/2 calls with field access that should use Enum.sort_by/3.
When sorting by any field access, you should provide a third argument to specify
the comparison function for clarity and potentially better performance.
## Examples
Bad:
Enum.sort_by(items, & &1.name)
Enum.sort_by(logs, fn log -> log.inserted_at end)
Enum.sort_by(users, & &1.profile.age)
Good:
Enum.sort_by(items, & &1.name, :asc)
Enum.sort_by(logs, fn log -> log.inserted_at end, {:desc, NaiveDateTime})
Enum.sort_by(users, & &1.profile.age, :desc)
"""
use Credo.Check,
base_priority: :high,
category: :warning,
explanations: [
check: """
When using `Enum.sort_by/2` with field access, consider using `Enum.sort_by/3`
and provide an explicit comparison argument as the third parameter.
This makes the sorting intention explicit and can provide better performance
for struct fields like DateTime, NaiveDateTime, Date, etc.
## Examples
Instead of:
Enum.sort_by(logs, & &1.inserted_at)
Use:
Enum.sort_by(logs, & &1.inserted_at, :asc)
# or for struct fields:
Enum.sort_by(logs, & &1.inserted_at, {:desc, NaiveDateTime})
"""
]
alias Credo.Code
def run(source_file, params \\ []) do
issue_meta = IssueMeta.for(source_file, params)
Code.prewalk(source_file, &traverse(&1, &2, issue_meta))
end
# Handle pipe calls: collection |> Enum.sort_by(sort_fn)
defp traverse(
{:|>, _, [_collection, {{:., _, [{:__aliases__, _, [:Enum]}, :sort_by]}, meta, [sort_fn]}]} = ast,
issues,
issue_meta
) do
if has_field_access?(sort_fn) do
{ast, [issue_for(meta[:line], issue_meta) | issues]}
else
{ast, issues}
end
end
# Handle direct calls: Enum.sort_by(collection, sort_fn)
defp traverse(
{{:., _, [{:__aliases__, _, [:Enum]}, :sort_by]}, meta, [_collection, sort_fn]} = ast,
issues,
issue_meta
) do
if has_field_access?(sort_fn) do
{ast, [issue_for(meta[:line], issue_meta) | issues]}
else
{ast, issues}
end
end
defp traverse({{:., _, [{:__aliases__, _, [:Enum]}, :sort_by]}, _meta, args} = ast, issues, _issue_meta) do
# For debugging only
IO.puts("Unhandled Enum.sort_by:")
IO.inspect(args)
{ast, issues}
end
defp traverse(ast, issues, _issue_meta), do: {ast, issues}
# This covers capture syntax, anonymous functions, and nested field access
defp has_field_access?({:&, _, [{:., _, [{:&, _, [1]}, _field_name]}]}) do
# Pattern: & &1.field_name (simple field access)
true
end
defp has_field_access?({:&, _, [{{:., _, [{:&, _, [1]}, _field_name]}, _, []}]}) do
# Pattern: & &1.field_name (function call syntax in AST)
true
end
defp has_field_access?({:fn, _, [{:->, _, [{var_name, _, nil}], body}]}) do
# Pattern: fn x -> ... end - check if body contains field access
contains_field_access_in_body?(body, var_name)
end
defp has_field_access?(_), do: false
# Check if the function body contains field access on the parameter
defp contains_field_access_in_body?({:., _, [{var_name, _, nil}, _field]}, var_name), do: true
defp contains_field_access_in_body?({{:., _, [{var_name, _, nil}, _field]}, _, _}, var_name), do: true
defp contains_field_access_in_body?(_body, _var_name), do: false
defp issue_for(line_no, issue_meta) do
format_issue(
issue_meta,
message: """
Consider using Enum.sort_by/3 when sorting by field access. \
Add a third argument to specify sort direction and comparison (e.g., :asc, :desc, or {:asc, ModuleName}).
""",
trigger: "Enum.sort_by",
line_no: line_no
)
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment