Current section

Files

Jump to
credence lib pattern no_redundant_assignment.ex
Raw

lib/pattern/no_redundant_assignment.ex

defmodule Credence.Pattern.NoRedundantAssignment do
@moduledoc """
Detects a single plain variable being assigned and immediately returned as
the last two statements of a block.
This is a common verbosity pattern where the assignment adds no value. In
Elixir, the last expression in a block is its return value, so the
intermediate binding is unnecessary.
## Example
# Bad
result = compute(x)
result
# Good
compute(x)
Only a single plain variable is fixed. Tuple/list patterns (`{a, b} =
process(input); {a, b}`) are left alone: collapsing them would discard the
match's implicit arity assertion, so the rewrite would not be strictly
behavior-preserving.
## Auto-fix
Replaces the last two statements with just the RHS of the assignment.
"""
use Credence.Pattern.Rule
alias Credence.{Issue, RuleHelpers}
@impl true
def check(ast, _opts) do
{_ast, issues} =
Macro.prewalk(ast, [], fn
{:__block__, _meta, statements} = node, acc when is_list(statements) ->
case check_last_pair(statements) do
{:flag, issue} -> {node, [issue | acc]}
:clean -> {node, acc}
end
node, acc ->
{node, acc}
end)
Enum.reverse(issues)
end
@impl true
def fix_patches(ast, _opts) do
# Emit surgical patches rather than rebuilding the whole `__block__`: the
# block goes from N statements to N-1, which the AST-diff cannot align
# (an unequal del/ins gap) and so re-renders — and reformats — the entire
# block, including unrelated sibling statements. Instead, replace just the
# `lhs = rhs` statement with `rhs` and delete the trailing variable's line.
{_ast, patches} =
Macro.prewalk(ast, [], fn
{:__block__, _meta, statements} = node, acc
when is_list(statements) and length(statements) >= 2 ->
{node, block_patches(statements) ++ acc}
node, acc ->
{node, acc}
end)
patches
end
defp block_patches(statements) do
second_to_last = Enum.at(statements, -2)
last = Enum.at(statements, -1)
case second_to_last do
{:=, _, [lhs, rhs]} ->
if fixable_pattern?(lhs) and structurally_identical?(lhs, last) do
rhs = preserve_comments(rhs, second_to_last, last)
[
%{
range: Sourceror.get_range(second_to_last),
change: RuleHelpers.render_replacement(rhs, %{})
},
RuleHelpers.deletion_patch(last)
]
|> Enum.reject(&is_nil/1)
else
[]
end
_ ->
[]
end
end
# Checks if the last two statements in a block form a redundant
# assign-and-return pattern.
defp check_last_pair(statements) when length(statements) >= 2 do
second_to_last = Enum.at(statements, -2)
last = Enum.at(statements, -1)
case second_to_last do
{:=, meta, [lhs, _rhs]} ->
if fixable_pattern?(lhs) and structurally_identical?(lhs, last) do
{:flag, build_issue(meta)}
else
:clean
end
_ ->
:clean
end
end
defp check_last_pair(_), do: :clean
# Only a single plain variable is fixable. The tuple/list forms
# (`{a, b} = f(); {a, b}` -> `f()`) were dropped: collapsing them discards the
# implicit arity assertion of the match, so the rewrite is not strictly
# behavior-preserving (it would no longer raise on a wrong-shaped return).
defp fixable_pattern?({:__block__, _, [inner]}), do: fixable_pattern?(inner)
defp fixable_pattern?({name, _, ctx}) when is_atom(name) and is_atom(ctx) and name != :_,
do: true
defp fixable_pattern?(_), do: false
# Two AST nodes are structurally identical if they represent the
# same source code, ignoring position metadata. Using Macro.to_string
# as the normalizer handles all AST representation differences
# (2-tuples vs {:{}, _, _}, __block__ wrapping, cons cells, etc).
defp structurally_identical?(a, b) do
Macro.to_string(a) == Macro.to_string(b)
end
defp build_issue(meta) do
%Issue{
rule: :no_redundant_assignment,
message:
"Variable is assigned and immediately returned. " <>
"The assignment is redundant — return the expression directly.",
meta: %{line: Keyword.get(meta, :line)}
}
end
# The assignment (lhs + `=`) and the trailing variable are both discarded; any
# comment that sat on them (e.g. a `# why` line before the assignment) must
# land on the surviving rhs so the fix never silently drops it.
defp preserve_comments(rhs, {:=, assign_meta, [lhs, _rhs]}, last) do
before = Keyword.get(assign_meta, :leading_comments, []) ++ RuleHelpers.collect_comments(lhs)
after_ =
Keyword.get(assign_meta, :trailing_comments, []) ++ RuleHelpers.collect_comments(last)
RuleHelpers.carry_comments(rhs, before, after_)
end
end