Skip to content

Commit c723fca

Browse files
authored
Styler-ify more Credo rules: FilterReject, RejectFilter, MapMap, and and !is_* guards (#15)
* Styler-ify more Credo rules: FilterReject, RejectFilter, MapMap, and !is_* guards Adds pipe-collapsing for Enum.filter |> Enum.reject (and the reverse), Enum.map |> Enum.map, and rewrites !is_nil/is_*/etc. to use `not`. Also extends the empty-check rewrite to cover String.length and byte_size. * Add check for shadowed vars * Drop byte_size empty-check rewrite byte_size(x) > 0 and x != "" are equivalent and equally efficient on binaries (byte_size reads the size header in O(1), and binary inequality short-circuits on that same header). Neither form is more idiomatic than the other, so there's no clear win to rewriting one to the other. Keep the String.length rewrite, which is a real perf win. * MapMap: prefer f2's iter-var name when only f2 names it
1 parent fad8a2f commit c723fca

5 files changed

Lines changed: 506 additions & 4 deletions

File tree

docs/credo.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,14 @@ Disabling the rules means updating your `.credo.exs` depending on your configura
3737
{Credo.Check.Refactor.CondStatements, false},
3838
{Credo.Check.Refactor.FilterCount, false},
3939
{Credo.Check.Refactor.FilterFilter, false},
40+
{Credo.Check.Refactor.FilterReject, false},
4041
{Credo.Check.Refactor.MapInto, false},
4142
{Credo.Check.Refactor.MapJoin, false},
4243
{Credo.Check.Refactor.NegatedConditionsInUnless, false},
4344
{Credo.Check.Refactor.NegatedConditionsWithElse, false},
4445
{Credo.Check.Refactor.PipeChainStart, false},
4546
{Credo.Check.Refactor.RedundantWithClauseResult, false},
47+
{Credo.Check.Refactor.RejectFilter, false},
4648
{Credo.Check.Refactor.RejectReject, false},
4749
{Credo.Check.Refactor.UnlessWithElse, false},
4850
{Credo.Check.Refactor.WithClauses, false},

lib/style/pipes.ex

Lines changed: 251 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,12 @@ defmodule Styler.Style.Pipes do
2020
* Credo.Check.Readability.SinglePipe
2121
* Credo.Check.Refactor.FilterCount
2222
* Credo.Check.Refactor.FilterFilter
23+
* Credo.Check.Refactor.FilterReject
2324
* Credo.Check.Refactor.MapInto
2425
* Credo.Check.Refactor.MapJoin
26+
* Credo.Check.Refactor.MapMap
2527
* Credo.Check.Refactor.PipeChainStart, excluded_functions: ["from"]
28+
* Credo.Check.Refactor.RejectFilter
2629
* Credo.Check.Refactor.RejectReject
2730
"""
2831

@@ -393,6 +396,59 @@ defmodule Styler.Style.Pipes do
393396
),
394397
do: {:|>, pm, [lhs, {reject, fm, [combined_predicate(f1, f2, :||, fm)]}]}
395398

399+
# `lhs |> Enum.filter(f1) |> Enum.reject(f2)` => `lhs |> Enum.filter(fn item -> f1.(item) && !f2.(item) end)`
400+
# (Credo.Check.Refactor.FilterReject)
401+
defp fix_pipe(
402+
pipe_chain(
403+
pm,
404+
lhs,
405+
{{:., _, [{_, _, [:Enum]}, :filter]} = filter, fm, [f1]},
406+
{{:., _, [{_, _, [:Enum]}, :reject]}, _, [f2]}
407+
)
408+
),
409+
do: {:|>, pm, [lhs, {filter, fm, [combined_predicate(f1, f2, :&&, fm, negate_f2: true)]}]}
410+
411+
# `lhs |> Enum.reject(f1) |> Enum.filter(f2)` => `lhs |> Enum.filter(fn item -> !f1.(item) && f2.(item) end)`
412+
# The merged call collapses to `Enum.filter` (as Credo recommends) — `f1` was the original reject,
413+
# so we negate it; `f2` was the original filter, so it stays.
414+
# (Credo.Check.Refactor.RejectFilter)
415+
defp fix_pipe(
416+
pipe_chain(
417+
pm,
418+
lhs,
419+
{{:., _, [{_, _, [:Enum]}, :reject]}, fm, [f1]},
420+
{{:., _, [{_, _, [:Enum]}, :filter]} = filter, _, [f2]}
421+
)
422+
),
423+
do: {:|>, pm, [lhs, {filter, fm, [combined_predicate(f1, f2, :&&, fm, negate_f1: true)]}]}
424+
425+
# `lhs |> Enum.map(f1) |> Enum.map(f2)` => single `Enum.map` whose body is the inlined nested call. We seed the body
426+
# with a one-step pipe inside f1's slot - Styler's existing `f(pipe, args)` walk then unfolds the f2 call into the
427+
# rest of the pipe chain. If either side can't be cleanly inlined, f1 doesn't pipify (e.g. it inlined to an operator),
428+
# or f2 doesn't put its placeholder in position 1 (so the seed pipe wouldn't unfold), skip — leaving the original
429+
# two-map chain. (Credo.Check.Refactor.MapMap)
430+
defp fix_pipe(
431+
pipe_chain(
432+
pm,
433+
lhs,
434+
{{:., _, [{_, _, [:Enum]}, :map]} = map, fm, [f1]},
435+
{{:., _, [{_, _, [:Enum]}, :map]}, _, [f2]}
436+
) = node
437+
) do
438+
with true <- inlineable?(f1) and inlineable?(f2) and placeholder_in_first_position?(f2),
439+
item_name = iteration_var_name(f1, f2),
440+
false <- shadows_free_var?(item_name, f1, f2),
441+
item = {item_name, [line: fm[:line]], nil},
442+
inlined_f1 = inline_capture(f1, item, fm[:line]),
443+
{:|>, _, _} = f1_seed <- pipify(inlined_f1) do
444+
body = inline_capture(f2, f1_seed, fm[:line])
445+
lambda = {:fn, [closing: [line: fm[:line]], line: fm[:line]], [{:->, [line: fm[:line]], [[item], body]}]}
446+
{:|>, pm, [lhs, {map, fm, [lambda]}]}
447+
else
448+
_ -> node
449+
end
450+
end
451+
396452
# `lhs |> Stream.map(fun) |> Stream.run()` => `lhs |> Enum.each(fun)`
397453
# `lhs |> Stream.each(fun) |> Stream.run()` => `lhs |> Enum.each(fun)`
398454
defp fix_pipe(
@@ -504,15 +560,207 @@ defmodule Styler.Style.Pipes do
504560

505561
# Combines two 1-arity predicates into a single anonymous function: `fn item -> f1.(item) <op> f2.(item) end`.
506562
# 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
563+
# or an explicit `fn x -> ... end`. Used by FilterFilter (op: `&&`), RejectReject (op: `||`), and
564+
# the mixed FilterReject / RejectFilter rules (op: `&&` with one side wrapped in `!`).
565+
defp combined_predicate(f1, f2, op, m, opts \\ []) do
509566
line = m[:line]
510567
item = {:item, [line: line], nil}
511-
body = {op, [line: line], [predicate_call(f1, item, line), predicate_call(f2, item, line)]}
568+
call_f1 = maybe_negate(predicate_call(f1, item, line), opts[:negate_f1] == true, line)
569+
call_f2 = maybe_negate(predicate_call(f2, item, line), opts[:negate_f2] == true, line)
570+
body = {op, [line: line], [call_f1, call_f2]}
512571
{:fn, [closing: [line: line], line: line], [{:->, [line: line], [[item], body]}]}
513572
end
514573

574+
defp maybe_negate(call, true, line), do: {:!, [line: line], [call]}
575+
defp maybe_negate(call, false, _line), do: call
576+
515577
defp predicate_call(fun, arg, line) do
516578
{{:., [line: line], [fun]}, [closing: [line: line], line: line], [arg]}
517579
end
580+
581+
# &Mod.fun/1 → Mod.fun(arg). The `:closing` meta is what tells Styler's `f(pipe, args)` rule
582+
# this is a real call (not a macro) and is safe to pipify.
583+
defp inline_capture(
584+
{:&, _, [{:/, _, [{{:., _, [{:__aliases__, _, mods}, name]}, _, []}, {:__block__, _, [1]}]}]},
585+
arg,
586+
line
587+
) do
588+
{{:., [line: line], [{:__aliases__, [line: line], mods}, name]}, [closing: [line: line], line: line], [arg]}
589+
end
590+
591+
# &fun/1 → fun(arg)
592+
defp inline_capture({:&, _, [{:/, _, [{name, _, ctx}, {:__block__, _, [1]}]}]}, arg, line)
593+
when is_atom(name) and is_atom(ctx) do
594+
{name, [closing: [line: line], line: line], [arg]}
595+
end
596+
597+
# &expr — safe to inline iff `&1` appears exactly once, no `&n` for n > 1, and there are
598+
# no nested `&(...)` capture forms in the body (their `&1`s belong to a different scope).
599+
defp inline_capture({:&, _, [body]}, arg, _line) do
600+
case placeholder_uses(body) do
601+
{1, false, false} -> substitute_placeholder(body, arg)
602+
_ -> nil
603+
end
604+
end
605+
606+
# `fn x -> body end` — safe to inline iff `x` appears exactly once in body, no nested `fn`/`&`
607+
# could shadow it, and `x` isn't `_` (which we'd be substituting into ignore-position).
608+
defp inline_capture({:fn, _, [{:->, _, [[{name, _, ctx}], body]}]}, arg, _line)
609+
when is_atom(name) and is_atom(ctx) and name != :_ do
610+
case fn_var_uses(body, name) do
611+
{1, false} -> substitute_fn_var(body, name, arg)
612+
_ -> nil
613+
end
614+
end
615+
616+
defp inline_capture(_, _, _), do: nil
617+
618+
# Mirrors the inline_capture clauses above — returns true exactly when inline_capture would succeed.
619+
defp inlineable?({:&, _, [{:/, _, [{{:., _, [{:__aliases__, _, _}, _]}, _, []}, {:__block__, _, [1]}]}]}), do: true
620+
621+
defp inlineable?({:&, _, [{:/, _, [{name, _, ctx}, {:__block__, _, [1]}]}]}) when is_atom(name) and is_atom(ctx),
622+
do: true
623+
624+
defp inlineable?({:&, _, [body]}), do: match?({1, false, false}, placeholder_uses(body))
625+
626+
defp inlineable?({:fn, _, [{:->, _, [[{name, _, ctx}], body]}]}) when is_atom(name) and is_atom(ctx) and name != :_,
627+
do: match?({1, false}, fn_var_uses(body, name))
628+
629+
defp inlineable?(_), do: false
630+
631+
# If either side is an inline `fn x -> ...`, prefer that var name for the merged lambda - the source already named the
632+
# iteration value. Prefer f1's name when both are named. Otherwise, fall back to `arg1`.
633+
defp iteration_var_name(f1, f2), do: fn_var_name(f1) || fn_var_name(f2) || :arg1
634+
635+
defp fn_var_name({:fn, _, [{:->, _, [[{name, _, ctx}], _]}]}) when is_atom(name) and is_atom(ctx) and name != :_,
636+
do: name
637+
638+
defp fn_var_name(_), do: nil
639+
640+
# The merged lambda introduces a fresh binding for `name`. If that same name appears as a free variable in either
641+
# side's body, it referred to a closure binding in the source - after merging, the new lambda's parameter would shadow
642+
# it, silently changing semantics. Conservatively report any reference to `name` outside the side's own parameter as a
643+
# shadow risk; refs inside a nested `fn`/`&` are technically rebindable but `inlineable?` already rejects most such
644+
# cases.
645+
defp shadows_free_var?(name, f1, f2), do: free_var_in?(name, f1) or free_var_in?(name, f2)
646+
647+
defp free_var_in?(name, {:fn, _, [{:->, _, [[{param, _, ctx}], body]}]}) when is_atom(param) and is_atom(ctx),
648+
do: param != name and var_in_ast?(body, name)
649+
650+
defp free_var_in?(name, {:&, _, [body]}), do: var_in_ast?(body, name)
651+
defp free_var_in?(_, _), do: false
652+
653+
defp var_in_ast?(ast, name) do
654+
{_, found} =
655+
Macro.prewalk(ast, false, fn
656+
node, true -> {node, true}
657+
{var, _, ctx} = node, false when var == name and is_atom(ctx) -> {node, true}
658+
node, acc -> {node, acc}
659+
end)
660+
661+
found
662+
end
663+
664+
# The seed-pipe trick only unfolds when f2's placeholder lands in arg position 1 of an outer call.
665+
# If it lands in position 2+, we'd produce something like `Mod.fun(other, pipe)`, which Styler's
666+
# `f(pipe, args)` rule won't touch and leaves an awkward partial pipe stranded inside an arg list.
667+
defp placeholder_in_first_position?({:&, _, [{:/, _, _}]}), do: true
668+
669+
defp placeholder_in_first_position?({:&, _, [{name, _, [{:&, _, [1]} | _]}]})
670+
when is_atom(name) and name not in @special_ops,
671+
do: true
672+
673+
defp placeholder_in_first_position?({:&, _, [{{:., _, _}, _, [{:&, _, [1]} | _]}]}), do: true
674+
675+
defp placeholder_in_first_position?({:fn, _, [{:->, _, [[{name, _, ctx}], {fname, _, [{var, _, vctx} | _]}]}]})
676+
when is_atom(name) and is_atom(ctx) and name != :_ and var == name and is_atom(vctx) and is_atom(fname) and
677+
fname not in @special_ops,
678+
do: true
679+
680+
defp placeholder_in_first_position?({:fn, _, [{:->, _, [[{name, _, ctx}], {{:., _, _}, _, [{var, _, vctx} | _]}]}]})
681+
when is_atom(name) and is_atom(ctx) and name != :_ and var == name and is_atom(vctx),
682+
do: true
683+
684+
defp placeholder_in_first_position?(_), do: false
685+
686+
defp fn_var_uses(ast, name) do
687+
{_, acc} =
688+
Macro.prewalk(ast, {0, false}, fn
689+
{:fn, _, _} = node, {count, _} ->
690+
{node, {count, true}}
691+
692+
{:&, _, _} = node, {count, _} ->
693+
{node, {count, true}}
694+
695+
{var, _, ctx} = node, {count, has_nested} when var == name and is_atom(ctx) ->
696+
{node, {count + 1, has_nested}}
697+
698+
node, acc ->
699+
{node, acc}
700+
end)
701+
702+
acc
703+
end
704+
705+
# Mirrors substitute_placeholder/2 — replace the var without descending into substituted `arg` or
706+
# into nested `fn`/`&` (which have their own scoping).
707+
defp substitute_fn_var({:fn, _, _} = node, _name, _arg), do: node
708+
defp substitute_fn_var({:&, _, _} = node, _name, _arg), do: node
709+
defp substitute_fn_var({var, _, ctx}, name, arg) when var == name and is_atom(ctx), do: arg
710+
711+
defp substitute_fn_var({a, m, args}, name, arg) when is_list(args),
712+
do: {substitute_fn_var(a, name, arg), m, Enum.map(args, &substitute_fn_var(&1, name, arg))}
713+
714+
defp substitute_fn_var({a, b}, name, arg), do: {substitute_fn_var(a, name, arg), substitute_fn_var(b, name, arg)}
715+
716+
defp substitute_fn_var(list, name, arg) when is_list(list), do: Enum.map(list, &substitute_fn_var(&1, name, arg))
717+
718+
defp substitute_fn_var(other, _name, _arg), do: other
719+
720+
# Convert a nested function-call AST (e.g. `f(g(h(x), y), z)`) into pipe form (`x |> h(y) |> g(z) |> f()`).
721+
# Stops at non-call nodes, at operator atoms (`arg + 1` shouldn't become `arg |> +(1)`), and at
722+
# already-piped subtrees (which are already in the desired shape).
723+
defp pipify({:|>, _, _} = pipe), do: pipe
724+
725+
defp pipify({{:., _, _} = dot, m, [first | rest]}), do: {:|>, [line: m[:line]], [pipify(first), {dot, m, rest}]}
726+
727+
defp pipify({name, m, [first | rest]}) when is_atom(name) and is_list(rest) and name not in @special_ops,
728+
do: {:|>, [line: m[:line]], [pipify(first), {name, m, rest}]}
729+
730+
defp pipify(other), do: other
731+
732+
# Returns `{count_of_&1, saw_higher_index?, saw_nested_capture?}`. The third flag prevents us
733+
# from inlining cases where the body contains a nested `&(...)` — its `&1`s are scoped to that
734+
# inner capture, not to the body we're inlining.
735+
defp placeholder_uses(ast) do
736+
{_, acc} =
737+
Macro.prewalk(ast, {0, false, false}, fn
738+
{:&, _, [n]} = node, {count, higher, has_capture} when is_integer(n) ->
739+
if n == 1,
740+
do: {node, {count + 1, higher, has_capture}},
741+
else: {node, {count, true, has_capture}}
742+
743+
{:&, _, [_body]} = node, {count, higher, _} ->
744+
{node, {count, higher, true}}
745+
746+
node, acc ->
747+
{node, acc}
748+
end)
749+
750+
acc
751+
end
752+
753+
# Replaces every `&1` in `ast` with `arg`, *without* descending into the substituted-in `arg`
754+
# (whose `&1`s, if any, are not in our scope) or into nested `&(...)` capture forms.
755+
defp substitute_placeholder({:&, _, [1]}, arg), do: arg
756+
defp substitute_placeholder({:&, _, _} = capture, _arg), do: capture
757+
758+
defp substitute_placeholder({a, m, args}, arg) when is_list(args),
759+
do: {substitute_placeholder(a, arg), m, Enum.map(args, &substitute_placeholder(&1, arg))}
760+
761+
defp substitute_placeholder({a, b}, arg), do: {substitute_placeholder(a, arg), substitute_placeholder(b, arg)}
762+
763+
defp substitute_placeholder(list, arg) when is_list(list), do: Enum.map(list, &substitute_placeholder(&1, arg))
764+
765+
defp substitute_placeholder(other, _arg), do: other
518766
end

lib/style/single_node.ex

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,19 +24,32 @@ defmodule Styler.Style.SingleNode do
2424
* Credo.Check.Refactor.RedundantWithClauseResult
2525
* Credo.Check.Refactor.WithClauses
2626
* Credo.Check.Warning.ExpensiveEmptyEnumCheck
27+
28+
Also rewrites `!is_nil(x)` (and the other `is_*` guard predicates) to `not is_nil(x)`,
29+
matching our existing preference for `not` over `!` on guard-style predicates.
2730
"""
2831

2932
@behaviour Styler.Style
3033

3134
@closing_delimiters [~s|"|, ")", "}", "|", "]", "'", ">", "/"]
3235

36+
@is_guards ~w(
37+
is_atom is_binary is_bitstring is_boolean is_exception is_float is_function
38+
is_integer is_list is_map is_map_key is_nil is_number is_pid is_port
39+
is_reference is_struct is_tuple
40+
)a
41+
3342
# `|> Timex.now()` => `|> Timex.now()`
3443
# skip over pipes into `Timex.now/1` so that we don't accidentally rewrite it as DateTime.utc_now/1
3544
def run({{:|>, _, [_, {{:., _, [{:__aliases__, _, [:Timex]}, :now]}, _, []}]}, _} = zipper, ctx),
3645
do: {:skip, zipper, ctx}
3746

3847
def run({node, meta}, ctx), do: {:cont, {style(node), meta}, ctx}
3948

49+
# `!is_nil(x)` => `not is_nil(x)` (and same for the other built-in `is_*` guard predicates).
50+
# Style preference: `not` reads more naturally with type guards.
51+
defp style({:!, m, [{guard, _, _} = check]}) when guard in @is_guards, do: {:not, m, [check]}
52+
4053
defp style({:assert, meta, [{:!=, _, [x, {:__block__, _, [nil]}]}]}), do: style({:assert, meta, [x]})
4154
# refute nilly -> assert
4255
defp style({:refute, meta, [{:is_nil, _, [x]}]}), do: style({:assert, meta, [x]})
@@ -207,7 +220,8 @@ defmodule Styler.Style.SingleNode do
207220

208221
# `length(x) <op> 0|1` => `x == []` or `x != []`. Avoids walking the whole list to check emptiness.
209222
# `Enum.count(x) <op> 0|1` => `Enum.empty?(x)` or `not Enum.empty?(x)` (same reason).
210-
# (Credo.Check.Warning.ExpensiveEmptyEnumCheck)
223+
# `String.length(x) <op> 0|1` => `x == ""` or `x != ""`. Avoids walking the whole string.
224+
# (Credo.Check.Warning.ExpensiveEmptyEnumCheck, plus the String equivalent)
211225
defp style({op, m, [lhs, rhs]} = ast) when op in [:==, :!=, :===, :!==, :>, :<, :>=, :<=] do
212226
rewrite_empty_check(op, lhs, rhs, m) || ast
213227
end
@@ -359,6 +373,7 @@ defmodule Styler.Style.SingleNode do
359373

360374
defp size_call({:length, _, [x]}), do: {:length, x}
361375
defp size_call({{:., _, [{:__aliases__, _, [:Enum]}, :count]}, _, [x]}), do: {:enum_count, x}
376+
defp size_call({{:., _, [{:__aliases__, _, [:String]}, :length]}, _, [x]}), do: {:string_length, x}
362377
defp size_call(_), do: nil
363378

364379
defp int_literal({:__block__, _, [n]}) when n in [0, 1], do: n
@@ -398,4 +413,12 @@ defmodule Styler.Style.SingleNode do
398413
nil -> nil
399414
end
400415
end
416+
417+
defp emit_empty_check(op, {:string_length, x}, n, m) do
418+
case empty_class(op, n) do
419+
:empty -> {:==, m, [x, {:__block__, [line: m[:line]], [""]}]}
420+
:not_empty -> {:!=, m, [x, {:__block__, [line: m[:line]], [""]}]}
421+
nil -> nil
422+
end
423+
end
401424
end

0 commit comments

Comments
 (0)