Skip to content

Commit efc71c9

Browse files
committed
FilterFilter et al: inline predicates, skip multi-statement bodies
The four pipe-collapse rules (FilterFilter, RejectReject, FilterReject, RejectFilter) used to wrap each predicate as `f.(item)` no matter what, producing nasty output like `(&(&1 in list)).(item) && (fn x -> ... end).(item)` whenever the source used a capture or anonymous fn. Now they reuse the MapMap rule's inlining machinery to substitute the iteration variable directly into the predicate body, and skip the rewrite entirely when either side has a multi-statement `fn` body — combining those under `&&`/`||` would change short-circuit semantics and read worse than the original two-pipe chain.
1 parent c723fca commit efc71c9

2 files changed

Lines changed: 110 additions & 26 deletions

File tree

lib/style/pipes.ex

Lines changed: 73 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -372,43 +372,55 @@ defmodule Styler.Style.Pipes do
372372
when mod in @enum,
373373
do: {:|>, pm, [lhs, {count, meta, [filterer]}]}
374374

375-
# `lhs |> Enum.filter(f1) |> Enum.filter(f2)` => `lhs |> Enum.filter(fn item -> f1.(item) && f2.(item) end)`
375+
# `lhs |> Enum.filter(f1) |> Enum.filter(f2)` => `lhs |> Enum.filter(fn item -> f1_inlined && f2_inlined end)`
376376
# (Credo.Check.Refactor.FilterFilter)
377377
defp fix_pipe(
378378
pipe_chain(
379379
pm,
380380
lhs,
381381
{{:., _, [{_, _, [:Enum]}, :filter]} = filter, fm, [f1]},
382382
{{:., _, [{_, _, [:Enum]}, :filter]}, _, [f2]}
383-
)
384-
),
385-
do: {:|>, pm, [lhs, {filter, fm, [combined_predicate(f1, f2, :&&, fm)]}]}
383+
) = node
384+
) do
385+
case combined_predicate(f1, f2, :&&, fm) do
386+
nil -> node
387+
combined -> {:|>, pm, [lhs, {filter, fm, [combined]}]}
388+
end
389+
end
386390

387-
# `lhs |> Enum.reject(f1) |> Enum.reject(f2)` => `lhs |> Enum.reject(fn item -> f1.(item) || f2.(item) end)`
391+
# `lhs |> Enum.reject(f1) |> Enum.reject(f2)` => `lhs |> Enum.reject(fn item -> f1_inlined || f2_inlined end)`
388392
# (Credo.Check.Refactor.RejectReject)
389393
defp fix_pipe(
390394
pipe_chain(
391395
pm,
392396
lhs,
393397
{{:., _, [{_, _, [:Enum]}, :reject]} = reject, fm, [f1]},
394398
{{:., _, [{_, _, [:Enum]}, :reject]}, _, [f2]}
395-
)
396-
),
397-
do: {:|>, pm, [lhs, {reject, fm, [combined_predicate(f1, f2, :||, fm)]}]}
399+
) = node
400+
) do
401+
case combined_predicate(f1, f2, :||, fm) do
402+
nil -> node
403+
combined -> {:|>, pm, [lhs, {reject, fm, [combined]}]}
404+
end
405+
end
398406

399-
# `lhs |> Enum.filter(f1) |> Enum.reject(f2)` => `lhs |> Enum.filter(fn item -> f1.(item) && !f2.(item) end)`
407+
# `lhs |> Enum.filter(f1) |> Enum.reject(f2)` => `lhs |> Enum.filter(fn item -> f1_inlined && !f2_inlined end)`
400408
# (Credo.Check.Refactor.FilterReject)
401409
defp fix_pipe(
402410
pipe_chain(
403411
pm,
404412
lhs,
405413
{{:., _, [{_, _, [:Enum]}, :filter]} = filter, fm, [f1]},
406414
{{:., _, [{_, _, [:Enum]}, :reject]}, _, [f2]}
407-
)
408-
),
409-
do: {:|>, pm, [lhs, {filter, fm, [combined_predicate(f1, f2, :&&, fm, negate_f2: true)]}]}
415+
) = node
416+
) do
417+
case combined_predicate(f1, f2, :&&, fm, negate_f2: true) do
418+
nil -> node
419+
combined -> {:|>, pm, [lhs, {filter, fm, [combined]}]}
420+
end
421+
end
410422

411-
# `lhs |> Enum.reject(f1) |> Enum.filter(f2)` => `lhs |> Enum.filter(fn item -> !f1.(item) && f2.(item) end)`
423+
# `lhs |> Enum.reject(f1) |> Enum.filter(f2)` => `lhs |> Enum.filter(fn item -> !f1_inlined && f2_inlined end)`
412424
# The merged call collapses to `Enum.filter` (as Credo recommends) — `f1` was the original reject,
413425
# so we negate it; `f2` was the original filter, so it stays.
414426
# (Credo.Check.Refactor.RejectFilter)
@@ -418,9 +430,13 @@ defmodule Styler.Style.Pipes do
418430
lhs,
419431
{{:., _, [{_, _, [:Enum]}, :reject]}, fm, [f1]},
420432
{{:., _, [{_, _, [:Enum]}, :filter]} = filter, _, [f2]}
421-
)
422-
),
423-
do: {:|>, pm, [lhs, {filter, fm, [combined_predicate(f1, f2, :&&, fm, negate_f1: true)]}]}
433+
) = node
434+
) do
435+
case combined_predicate(f1, f2, :&&, fm, negate_f1: true) do
436+
nil -> node
437+
combined -> {:|>, pm, [lhs, {filter, fm, [combined]}]}
438+
end
439+
end
424440

425441
# `lhs |> Enum.map(f1) |> Enum.map(f2)` => single `Enum.map` whose body is the inlined nested call. We seed the body
426442
# with a one-step pipe inside f1's slot - Styler's existing `f(pipe, args)` walk then unfolds the f2 call into the
@@ -558,19 +574,52 @@ defmodule Styler.Style.Pipes do
558574
defp valid_pipe_start?({fun, _, _args}) when is_atom(fun), do: String.match?("#{fun}", ~r/^sigil_[a-zA-Z]$/)
559575
defp valid_pipe_start?(_), do: true
560576

561-
# Combines two 1-arity predicates into a single anonymous function: `fn item -> f1.(item) <op> f2.(item) end`.
562-
# Universal form that's correct regardless of whether each predicate is a capture, an `&(...)` shortform,
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 `!`).
577+
# Combines two 1-arity predicates into a single anonymous function: `fn item -> f1_term <op> f2_term end`.
578+
# Returns nil if either side can't be combined cleanly — the caller skips the rewrite. We inline
579+
# captures and inline-fn predicates whose body is a single expression (so combining with `&&`/`||`
580+
# is sound). Bare references (like `f1`) fall back to `f1.(item)`. Anything else (multi-statement
581+
# `fn` bodies, captures with multiple `&1` uses, nested closures) is left alone — the original
582+
# two-pipe form is more readable than what we'd produce. Used by FilterFilter (op: `&&`),
583+
# RejectReject (op: `||`), and the mixed FilterReject / RejectFilter rules.
565584
defp combined_predicate(f1, f2, op, m, opts \\ []) do
566585
line = m[:line]
567586
item = {:item, [line: line], nil}
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]}
571-
{:fn, [closing: [line: line], line: line], [{:->, [line: line], [[item], body]}]}
587+
588+
with call_f1 when not is_nil(call_f1) <- predicate_term(f1, item, line),
589+
call_f2 when not is_nil(call_f2) <- predicate_term(f2, item, line) do
590+
call_f1 = maybe_negate(call_f1, opts[:negate_f1] == true, line)
591+
call_f2 = maybe_negate(call_f2, opts[:negate_f2] == true, line)
592+
body = {op, [line: line], [call_f1, call_f2]}
593+
{:fn, [closing: [line: line], line: line], [{:->, [line: line], [[item], body]}]}
594+
end
595+
end
596+
597+
defp predicate_term(fun, item, line) do
598+
cond do
599+
inlineable_simple?(fun) -> Style.set_line(inline_capture(fun, item, line), line)
600+
bare_reference?(fun) -> predicate_call(fun, item, line)
601+
true -> nil
602+
end
572603
end
573604

605+
# Inlineable AND the body is a single expression. Multi-statement `fn`/`&` bodies don't compose
606+
# cleanly under `&&`/`||` — short-circuit semantics would change and the formatted output is uglier
607+
# than the original two-pipe chain.
608+
defp inlineable_simple?({:&, _, [{:/, _, _}]} = fun), do: inlineable?(fun)
609+
610+
defp inlineable_simple?({:&, _, [body]} = fun), do: inlineable?(fun) and not multi_statement_block?(body)
611+
612+
defp inlineable_simple?({:fn, _, [{:->, _, [_, body]}]} = fun),
613+
do: inlineable?(fun) and not multi_statement_block?(body)
614+
615+
defp inlineable_simple?(_), do: false
616+
617+
defp multi_statement_block?({:__block__, _, [_, _ | _]}), do: true
618+
defp multi_statement_block?(_), do: false
619+
620+
defp bare_reference?({var, _, ctx}) when is_atom(var) and is_atom(ctx), do: true
621+
defp bare_reference?(_), do: false
622+
574623
defp maybe_negate(call, true, line), do: {:!, [line: line], [call]}
575624
defp maybe_negate(call, false, _line), do: call
576625

test/style/pipes_test.exs

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -656,19 +656,54 @@ defmodule Styler.Style.PipesTest do
656656
)
657657
end
658658

659-
test "FilterFilter: combines captures and inline predicates" do
659+
test "FilterFilter: inlines captures and inline-fn predicates" do
660660
assert_style(
661661
"""
662662
list
663663
|> Enum.filter(&String.contains?(&1, "x"))
664664
|> Enum.filter(fn s -> String.contains?(s, "a") end)
665665
""",
666666
"""
667-
Enum.filter(list, fn item -> (&String.contains?(&1, "x")).(item) && (fn s -> String.contains?(s, "a") end).(item) end)
667+
Enum.filter(list, fn item -> String.contains?(item, "x") && String.contains?(item, "a") end)
668668
"""
669669
)
670670
end
671671

672+
test "RejectFilter: inlines `&fun/1` style captures" do
673+
assert_style(
674+
"""
675+
list
676+
|> Enum.reject(&is_nil/1)
677+
|> Enum.filter(&is_map/1)
678+
""",
679+
"""
680+
Enum.filter(list, fn item -> not is_nil(item) && is_map(item) end)
681+
"""
682+
)
683+
end
684+
685+
test "FilterFilter / RejectFilter: skips rewrite when a predicate has a multi-statement fn body" do
686+
# The collapsed lambda would be uglier than the original chain — leave it alone.
687+
assert_style("""
688+
schema_modules
689+
|> Enum.reject(&(&1 in context.ignore_list))
690+
|> Enum.filter(fn schema_module ->
691+
schema = schema_module.schema()
692+
schema_title = schema.title
693+
not is_nil(schema.example) and not MapSet.member?(set, schema_title)
694+
end)
695+
""")
696+
697+
assert_style("""
698+
list
699+
|> Enum.filter(fn x ->
700+
y = x.a
701+
y > 0
702+
end)
703+
|> Enum.filter(f2)
704+
""")
705+
end
706+
672707
test "FilterFilter: leaves non-Enum.filter chains alone" do
673708
assert_style("""
674709
list

0 commit comments

Comments
 (0)