Skip to content

Commit 0462740

Browse files
committed
Add four more Credo rule rewrites
- Refactor.FilterFilter: Enum.filter |> Enum.filter => single filter combined with && - Refactor.RejectReject: Enum.reject |> Enum.reject => single reject combined with || - Warning.ExpensiveEmptyEnumCheck: length(x) == 0 => x == []; Enum.count(x) == 0 => Enum.empty?(x) - Warning.RaiseInsideRescue: raise foo inside a rescue clause => reraise foo, __STACKTRACE__
1 parent b063690 commit 0462740

7 files changed

Lines changed: 382 additions & 0 deletions

File tree

docs/credo.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,16 @@ Disabling the rules means updating your `.credo.exs` depending on your configura
3636
{Credo.Check.Refactor.CaseTrivialMatches, false},
3737
{Credo.Check.Refactor.CondStatements, false},
3838
{Credo.Check.Refactor.FilterCount, false},
39+
{Credo.Check.Refactor.FilterFilter, false},
3940
{Credo.Check.Refactor.MapInto, false},
4041
{Credo.Check.Refactor.MapJoin, false},
4142
{Credo.Check.Refactor.NegatedConditionsInUnless, false},
4243
{Credo.Check.Refactor.NegatedConditionsWithElse, false},
4344
{Credo.Check.Refactor.PipeChainStart, false},
4445
{Credo.Check.Refactor.RedundantWithClauseResult, false},
46+
{Credo.Check.Refactor.RejectReject, false},
4547
{Credo.Check.Refactor.UnlessWithElse, false},
4648
{Credo.Check.Refactor.WithClauses, false},
49+
{Credo.Check.Warning.ExpensiveEmptyEnumCheck, false},
50+
{Credo.Check.Warning.RaiseInsideRescue, false},
4751
```

lib/style/blocks.ex

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ defmodule Styler.Style.Blocks do
2323
* Credo.Check.Refactor.CondStatements
2424
* Credo.Check.Refactor.RedundantWithClauseResult
2525
* Credo.Check.Refactor.WithClauses
26+
* Credo.Check.Warning.RaiseInsideRescue
2627
"""
2728

2829
alias Styler.Style
@@ -216,6 +217,19 @@ defmodule Styler.Style.Blocks do
216217
end
217218
end
218219

220+
# `try ... rescue clauses ... end` — within each rescue clause body, rewrite `raise foo` to
221+
# `reraise foo, __STACKTRACE__` so the original stacktrace is preserved.
222+
# (Credo.Check.Warning.RaiseInsideRescue)
223+
def run({{:try, _, [_]} = node, meta}, ctx) do
224+
{:cont, {rewrite_rescue_raises(node), meta}, ctx}
225+
end
226+
227+
# `def fname, do: ..., rescue: clauses` — same rewrite within the rescue clauses.
228+
def run({{op, _, [_head, kwlist]} = node, meta}, ctx)
229+
when op in [:def, :defp, :defmacro, :defmacrop] and is_list(kwlist) do
230+
{:cont, {rewrite_rescue_raises(node), meta}, ctx}
231+
end
232+
219233
def run(zipper, ctx), do: {:cont, zipper, ctx}
220234

221235
# with statements can do _a lot_, so this beast of a function likewise does a lot.
@@ -423,4 +437,46 @@ defmodule Styler.Style.Blocks do
423437
defp invert({:not, _, [condition]}), do: condition
424438
defp invert({:in, m, [_, _]} = ast), do: {:not, m, [ast]}
425439
defp invert({_, m, _} = ast), do: {:!, [line: m[:line]], [ast]}
440+
441+
# RaiseInsideRescue helpers — rewrites raises within the rescue clauses of a try or def.
442+
defp rewrite_rescue_raises({:try, m, [kwlist]}) do
443+
{:try, m, [Enum.map(kwlist, &rewrite_kw_pair/1)]}
444+
end
445+
446+
defp rewrite_rescue_raises({op, m, [head, kwlist]}) when op in [:def, :defp, :defmacro, :defmacrop] do
447+
{op, m, [head, Enum.map(kwlist, &rewrite_kw_pair/1)]}
448+
end
449+
450+
defp rewrite_rescue_raises(node), do: node
451+
452+
# `:rescue` keys appear under literal_encoder as {:__block__, _, [:rescue]}; bare atom is the safety net.
453+
defp rewrite_kw_pair({{:__block__, _, [:rescue]} = key, clauses}), do: {key, rewrite_rescue_clauses(clauses)}
454+
defp rewrite_kw_pair({:rescue, clauses}), do: {:rescue, rewrite_rescue_clauses(clauses)}
455+
defp rewrite_kw_pair(other), do: other
456+
457+
defp rewrite_rescue_clauses(clauses) when is_list(clauses) do
458+
Enum.map(clauses, fn
459+
{:->, m, [match, body]} -> {:->, m, [match, rewrite_raises_in_rescue_body(body)]}
460+
other -> other
461+
end)
462+
end
463+
464+
defp rewrite_rescue_clauses(other), do: other
465+
466+
# Walks the rescue body rewriting `raise foo` to `reraise foo, __STACKTRACE__`. Doesn't descend into
467+
# nested `try`, `fn`, or nested defs — those are different rescue contexts (or none at all).
468+
defp rewrite_raises_in_rescue_body(ast), do: walk_raise(ast)
469+
470+
defp walk_raise({:raise, m, args}) when is_list(args) and args != [] do
471+
{:reraise, m, args ++ [{:__STACKTRACE__, [line: m[:line]], nil}]}
472+
end
473+
474+
defp walk_raise({:try, _, _} = node), do: node
475+
defp walk_raise({:fn, _, _} = node), do: node
476+
defp walk_raise({op, _, _} = node) when op in [:def, :defp, :defmacro, :defmacrop], do: node
477+
478+
defp walk_raise({a, m, b}) when is_list(b), do: {walk_raise(a), m, Enum.map(b, &walk_raise/1)}
479+
defp walk_raise({a, b}), do: {walk_raise(a), walk_raise(b)}
480+
defp walk_raise(list) when is_list(list), do: Enum.map(list, &walk_raise/1)
481+
defp walk_raise(other), do: other
426482
end

lib/style/pipes.ex

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,11 @@ defmodule Styler.Style.Pipes do
1919
* Credo.Check.Readability.PipeIntoAnonymousFunctions
2020
* Credo.Check.Readability.SinglePipe
2121
* Credo.Check.Refactor.FilterCount
22+
* Credo.Check.Refactor.FilterFilter
2223
* Credo.Check.Refactor.MapInto
2324
* Credo.Check.Refactor.MapJoin
2425
* Credo.Check.Refactor.PipeChainStart, excluded_functions: ["from"]
26+
* Credo.Check.Refactor.RejectReject
2527
"""
2628

2729
alias Styler.Style
@@ -367,6 +369,30 @@ defmodule Styler.Style.Pipes do
367369
when mod in @enum,
368370
do: {:|>, pm, [lhs, {count, meta, [filterer]}]}
369371

372+
# `lhs |> Enum.filter(f1) |> Enum.filter(f2)` => `lhs |> Enum.filter(fn item -> f1.(item) && f2.(item) end)`
373+
# (Credo.Check.Refactor.FilterFilter)
374+
defp fix_pipe(
375+
pipe_chain(
376+
pm,
377+
lhs,
378+
{{:., _, [{_, _, [:Enum]}, :filter]} = filter, fm, [f1]},
379+
{{:., _, [{_, _, [:Enum]}, :filter]}, _, [f2]}
380+
)
381+
),
382+
do: {:|>, pm, [lhs, {filter, fm, [combined_predicate(f1, f2, :&&, fm)]}]}
383+
384+
# `lhs |> Enum.reject(f1) |> Enum.reject(f2)` => `lhs |> Enum.reject(fn item -> f1.(item) || f2.(item) end)`
385+
# (Credo.Check.Refactor.RejectReject)
386+
defp fix_pipe(
387+
pipe_chain(
388+
pm,
389+
lhs,
390+
{{:., _, [{_, _, [:Enum]}, :reject]} = reject, fm, [f1]},
391+
{{:., _, [{_, _, [:Enum]}, :reject]}, _, [f2]}
392+
)
393+
),
394+
do: {:|>, pm, [lhs, {reject, fm, [combined_predicate(f1, f2, :||, fm)]}]}
395+
370396
# `lhs |> Stream.map(fun) |> Stream.run()` => `lhs |> Enum.each(fun)`
371397
# `lhs |> Stream.each(fun) |> Stream.run()` => `lhs |> Enum.each(fun)`
372398
defp fix_pipe(
@@ -475,4 +501,18 @@ defmodule Styler.Style.Pipes do
475501
# function_call(with, args) or sigils. sigils are allowed, function w/ args is not
476502
defp valid_pipe_start?({fun, _, _args}) when is_atom(fun), do: String.match?("#{fun}", ~r/^sigil_[a-zA-Z]$/)
477503
defp valid_pipe_start?(_), do: true
504+
505+
# Combines two 1-arity predicates into a single anonymous function: `fn item -> f1.(item) <op> f2.(item) end`.
506+
# Universal form that's correct regardless of whether each predicate is a capture, an `&(...)` shortform,
507+
# or an explicit `fn x -> ... end`. Used by FilterFilter (op: `&&`) and RejectReject (op: `||`).
508+
defp combined_predicate(f1, f2, op, m) do
509+
line = m[:line]
510+
item = {:item, [line: line], nil}
511+
body = {op, [line: line], [predicate_call(f1, item, line), predicate_call(f2, item, line)]}
512+
{:fn, [closing: [line: line], line: line], [{:->, [line: line], [[item], body]}]}
513+
end
514+
515+
defp predicate_call(fun, arg, line) do
516+
{{:., [line: line], [fun]}, [closing: [line: line], line: line], [arg]}
517+
end
478518
end

lib/style/single_node.ex

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ defmodule Styler.Style.SingleNode do
2323
* Credo.Check.Refactor.CondStatements
2424
* Credo.Check.Refactor.RedundantWithClauseResult
2525
* Credo.Check.Refactor.WithClauses
26+
* Credo.Check.Warning.ExpensiveEmptyEnumCheck
2627
"""
2728

2829
@behaviour Styler.Style
@@ -204,6 +205,13 @@ defmodule Styler.Style.SingleNode do
204205
{{:., dm, [{:__aliases__, am, [mod]}, fun]}, funm, args}
205206
end
206207

208+
# `length(x) <op> 0|1` => `x == []` or `x != []`. Avoids walking the whole list to check emptiness.
209+
# `Enum.count(x) <op> 0|1` => `Enum.empty?(x)` or `not Enum.empty?(x)` (same reason).
210+
# (Credo.Check.Warning.ExpensiveEmptyEnumCheck)
211+
defp style({op, m, [lhs, rhs]} = ast) when op in [:==, :!=, :===, :!==, :>, :<, :>=, :<=] do
212+
rewrite_empty_check(op, lhs, rhs, m) || ast
213+
end
214+
207215
# Remove parens from 0 arity funs (Credo.Check.Readability.ParenthesesOnZeroArityDefs)
208216
defp style({def, dm, [{fun, funm, []} | rest]}) when def in ~w(def defp)a and is_atom(fun),
209217
do: style({def, dm, [{fun, Keyword.delete(funm, :closing), nil} | rest]})
@@ -337,4 +345,57 @@ defmodule Styler.Style.SingleNode do
337345

338346
defp add_underscores([a, b, c, d | rest], acc), do: add_underscores([d | rest], [?_, c, b, a | acc])
339347
defp add_underscores(reversed_list, acc), do: Enum.reverse(reversed_list, acc)
348+
349+
# ExpensiveEmptyEnumCheck helpers
350+
# Picks out a `length(x)` or `Enum.count(x)` call paired with a literal `0` or `1` and rewrites
351+
# the entire comparison. Returns nil for any shape that isn't a recognized empty-check pattern.
352+
defp rewrite_empty_check(op, lhs, rhs, m) do
353+
case {size_call(lhs), int_literal(rhs), size_call(rhs), int_literal(lhs)} do
354+
{kind, n, _, _} when not is_nil(kind) and not is_nil(n) -> emit_empty_check(op, kind, n, m)
355+
{_, _, kind, n} when not is_nil(kind) and not is_nil(n) -> emit_empty_check(swap_op(op), kind, n, m)
356+
_ -> nil
357+
end
358+
end
359+
360+
defp size_call({:length, _, [x]}), do: {:length, x}
361+
defp size_call({{:., _, [{:__aliases__, _, [:Enum]}, :count]}, _, [x]}), do: {:enum_count, x}
362+
defp size_call(_), do: nil
363+
364+
defp int_literal({:__block__, _, [n]}) when n in [0, 1], do: n
365+
defp int_literal(_), do: nil
366+
367+
# `length(x) <= 0` is also "empty" because length is non-negative; same for `length(x) >= 0` (tautology, skip).
368+
defp empty_class(:==, 0), do: :empty
369+
defp empty_class(:===, 0), do: :empty
370+
defp empty_class(:!=, 0), do: :not_empty
371+
defp empty_class(:!==, 0), do: :not_empty
372+
defp empty_class(:>, 0), do: :not_empty
373+
defp empty_class(:<=, 0), do: :empty
374+
defp empty_class(:>=, 1), do: :not_empty
375+
defp empty_class(:<, 1), do: :empty
376+
defp empty_class(_, _), do: nil
377+
378+
defp swap_op(:>), do: :<
379+
defp swap_op(:<), do: :>
380+
defp swap_op(:>=), do: :<=
381+
defp swap_op(:<=), do: :>=
382+
defp swap_op(op), do: op
383+
384+
defp emit_empty_check(op, {:length, x}, n, m) do
385+
case empty_class(op, n) do
386+
:empty -> {:==, m, [x, {:__block__, [line: m[:line]], [[]]}]}
387+
:not_empty -> {:!=, m, [x, {:__block__, [line: m[:line]], [[]]}]}
388+
nil -> nil
389+
end
390+
end
391+
392+
defp emit_empty_check(op, {:enum_count, x}, n, m) do
393+
empty_call = {{:., m, [{:__aliases__, m, [:Enum]}, :empty?]}, m, [x]}
394+
395+
case empty_class(op, n) do
396+
:empty -> empty_call
397+
:not_empty -> {:not, m, [empty_call]}
398+
nil -> nil
399+
end
400+
end
340401
end

test/style/blocks_test.exs

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1058,4 +1058,134 @@ defmodule Styler.Style.BlocksTest do
10581058
)
10591059
end
10601060
end
1061+
1062+
describe "RaiseInsideRescue" do
1063+
test "rewrites raise to reraise inside try/rescue" do
1064+
assert_style(
1065+
"""
1066+
try do
1067+
foo()
1068+
rescue
1069+
e in RuntimeError -> raise e
1070+
end
1071+
""",
1072+
"""
1073+
try do
1074+
foo()
1075+
rescue
1076+
e in RuntimeError -> reraise e, __STACKTRACE__
1077+
end
1078+
"""
1079+
)
1080+
end
1081+
1082+
test "rewrites raise inside a multi-line rescue body" do
1083+
assert_style(
1084+
"""
1085+
try do
1086+
foo()
1087+
rescue
1088+
e ->
1089+
Logger.error("oops")
1090+
raise e
1091+
end
1092+
""",
1093+
"""
1094+
try do
1095+
foo()
1096+
rescue
1097+
e ->
1098+
Logger.error("oops")
1099+
reraise e, __STACKTRACE__
1100+
end
1101+
"""
1102+
)
1103+
end
1104+
1105+
test "rewrites raise with multiple args" do
1106+
assert_style(
1107+
"""
1108+
try do
1109+
foo()
1110+
rescue
1111+
_ -> raise ArgumentError, "bad"
1112+
end
1113+
""",
1114+
"""
1115+
try do
1116+
foo()
1117+
rescue
1118+
_ -> reraise ArgumentError, "bad", __STACKTRACE__
1119+
end
1120+
"""
1121+
)
1122+
end
1123+
1124+
test "rewrites raises inside def's rescue clause" do
1125+
assert_style(
1126+
"""
1127+
def foo do
1128+
bar()
1129+
rescue
1130+
e -> raise e
1131+
end
1132+
""",
1133+
"""
1134+
def foo do
1135+
bar()
1136+
rescue
1137+
e -> reraise e, __STACKTRACE__
1138+
end
1139+
"""
1140+
)
1141+
end
1142+
1143+
test "leaves raise in the do block alone" do
1144+
assert_style("""
1145+
try do
1146+
raise "oops"
1147+
rescue
1148+
e -> Logger.error(e)
1149+
end
1150+
""")
1151+
end
1152+
1153+
test "leaves an existing reraise alone" do
1154+
assert_style("""
1155+
try do
1156+
foo()
1157+
rescue
1158+
e -> reraise e, __STACKTRACE__
1159+
end
1160+
""")
1161+
end
1162+
1163+
test "doesn't descend into a nested try" do
1164+
# The nested `raise` is inside the inner try's `do` block — it should be left alone
1165+
# (Styler will visit the inner try on its own and apply its own rewrites).
1166+
assert_style("""
1167+
try do
1168+
foo()
1169+
rescue
1170+
_ ->
1171+
try do
1172+
raise "inner do"
1173+
rescue
1174+
inner -> reraise inner, __STACKTRACE__
1175+
end
1176+
end
1177+
""")
1178+
end
1179+
1180+
test "doesn't descend into anonymous functions inside the rescue body" do
1181+
assert_style("""
1182+
try do
1183+
foo()
1184+
rescue
1185+
_ ->
1186+
fn -> raise "later" end
1187+
end
1188+
""")
1189+
end
1190+
end
10611191
end

0 commit comments

Comments
 (0)