@@ -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.
426+ # We seed the body with a one-step pipe inside f1's slot — Styler's existing `f(pipe, args)` walk
427+ # then unfolds the f2 call into the rest of the pipe chain. If either side can't be cleanly inlined,
428+ # f1 doesn't pipify (e.g. it inlined to an operator), or f2 doesn't put its placeholder in position
429+ # 1 (so the seed pipe wouldn't unfold), skip — leaving the original two-map chain.
430+ # (Credo.Check.Refactor.MapMap)
431+ defp fix_pipe (
432+ pipe_chain (
433+ pm ,
434+ lhs ,
435+ { { :. , _ , [ { _ , _ , [ :Enum ] } , :map ] } = map , fm , [ f1 ] } ,
436+ { { :. , _ , [ { _ , _ , [ :Enum ] } , :map ] } , _ , [ f2 ] }
437+ ) = node
438+ ) do
439+ with true <- inlineable? ( f1 ) and inlineable? ( f2 ) and placeholder_in_first_position? ( f2 ) ,
440+ item_name = iteration_var_name ( f1 ) ,
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,181 @@ 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
632+ # source already named the iteration value. Otherwise, fall back to `arg1`.
633+ defp iteration_var_name ( { :fn , _ , [ { :-> , _ , [ [ { name , _ , ctx } ] , _ ] } ] } ) when is_atom ( name ) and is_atom ( ctx ) and name != :_ ,
634+ do: name
635+
636+ defp iteration_var_name ( _ ) , do: :arg1
637+
638+ # The seed-pipe trick only unfolds when f2's placeholder lands in arg position 1 of an outer call.
639+ # If it lands in position 2+, we'd produce something like `Mod.fun(other, pipe)`, which Styler's
640+ # `f(pipe, args)` rule won't touch and leaves an awkward partial pipe stranded inside an arg list.
641+ defp placeholder_in_first_position? ( { :& , _ , [ { :/ , _ , _ } ] } ) , do: true
642+
643+ defp placeholder_in_first_position? ( { :& , _ , [ { name , _ , [ { :& , _ , [ 1 ] } | _ ] } ] } )
644+ when is_atom ( name ) and name not in @ special_ops ,
645+ do: true
646+
647+ defp placeholder_in_first_position? ( { :& , _ , [ { { :. , _ , _ } , _ , [ { :& , _ , [ 1 ] } | _ ] } ] } ) , do: true
648+
649+ defp placeholder_in_first_position? ( { :fn , _ , [ { :-> , _ , [ [ { name , _ , ctx } ] , { fname , _ , [ { var , _ , vctx } | _ ] } ] } ] } )
650+ when is_atom ( name ) and is_atom ( ctx ) and name != :_ and var == name and is_atom ( vctx ) and is_atom ( fname ) and
651+ fname not in @ special_ops ,
652+ do: true
653+
654+ defp placeholder_in_first_position? ( { :fn , _ , [ { :-> , _ , [ [ { name , _ , ctx } ] , { { :. , _ , _ } , _ , [ { var , _ , vctx } | _ ] } ] } ] } )
655+ when is_atom ( name ) and is_atom ( ctx ) and name != :_ and var == name and is_atom ( vctx ) ,
656+ do: true
657+
658+ defp placeholder_in_first_position? ( _ ) , do: false
659+
660+ defp fn_var_uses ( ast , name ) do
661+ { _ , acc } =
662+ Macro . prewalk ( ast , { 0 , false } , fn
663+ { :fn , _ , _ } = node , { count , _ } ->
664+ { node , { count , true } }
665+
666+ { :& , _ , _ } = node , { count , _ } ->
667+ { node , { count , true } }
668+
669+ { var , _ , ctx } = node , { count , has_nested } when var == name and is_atom ( ctx ) ->
670+ { node , { count + 1 , has_nested } }
671+
672+ node , acc ->
673+ { node , acc }
674+ end )
675+
676+ acc
677+ end
678+
679+ # Mirrors substitute_placeholder/2 — replace the var without descending into substituted `arg` or
680+ # into nested `fn`/`&` (which have their own scoping).
681+ defp substitute_fn_var ( { :fn , _ , _ } = node , _name , _arg ) , do: node
682+ defp substitute_fn_var ( { :& , _ , _ } = node , _name , _arg ) , do: node
683+ defp substitute_fn_var ( { var , _ , ctx } , name , arg ) when var == name and is_atom ( ctx ) , do: arg
684+
685+ defp substitute_fn_var ( { a , m , args } , name , arg ) when is_list ( args ) ,
686+ do: { substitute_fn_var ( a , name , arg ) , m , Enum . map ( args , & substitute_fn_var ( & 1 , name , arg ) ) }
687+
688+ defp substitute_fn_var ( { a , b } , name , arg ) , do: { substitute_fn_var ( a , name , arg ) , substitute_fn_var ( b , name , arg ) }
689+
690+ defp substitute_fn_var ( list , name , arg ) when is_list ( list ) , do: Enum . map ( list , & substitute_fn_var ( & 1 , name , arg ) )
691+
692+ defp substitute_fn_var ( other , _name , _arg ) , do: other
693+
694+ # Convert a nested function-call AST (e.g. `f(g(h(x), y), z)`) into pipe form (`x |> h(y) |> g(z) |> f()`).
695+ # Stops at non-call nodes, at operator atoms (`arg + 1` shouldn't become `arg |> +(1)`), and at
696+ # already-piped subtrees (which are already in the desired shape).
697+ defp pipify ( { :|> , _ , _ } = pipe ) , do: pipe
698+
699+ defp pipify ( { { :. , _ , _ } = dot , m , [ first | rest ] } ) , do: { :|> , [ line: m [ :line ] ] , [ pipify ( first ) , { dot , m , rest } ] }
700+
701+ defp pipify ( { name , m , [ first | rest ] } ) when is_atom ( name ) and is_list ( rest ) and name not in @ special_ops ,
702+ do: { :|> , [ line: m [ :line ] ] , [ pipify ( first ) , { name , m , rest } ] }
703+
704+ defp pipify ( other ) , do: other
705+
706+ # Returns `{count_of_&1, saw_higher_index?, saw_nested_capture?}`. The third flag prevents us
707+ # from inlining cases where the body contains a nested `&(...)` — its `&1`s are scoped to that
708+ # inner capture, not to the body we're inlining.
709+ defp placeholder_uses ( ast ) do
710+ { _ , acc } =
711+ Macro . prewalk ( ast , { 0 , false , false } , fn
712+ { :& , _ , [ n ] } = node , { count , higher , has_capture } when is_integer ( n ) ->
713+ if n == 1 ,
714+ do: { node , { count + 1 , higher , has_capture } } ,
715+ else: { node , { count , true , has_capture } }
716+
717+ { :& , _ , [ _body ] } = node , { count , higher , _ } ->
718+ { node , { count , higher , true } }
719+
720+ node , acc ->
721+ { node , acc }
722+ end )
723+
724+ acc
725+ end
726+
727+ # Replaces every `&1` in `ast` with `arg`, *without* descending into the substituted-in `arg`
728+ # (whose `&1`s, if any, are not in our scope) or into nested `&(...)` capture forms.
729+ defp substitute_placeholder ( { :& , _ , [ 1 ] } , arg ) , do: arg
730+ defp substitute_placeholder ( { :& , _ , _ } = capture , _arg ) , do: capture
731+
732+ defp substitute_placeholder ( { a , m , args } , arg ) when is_list ( args ) ,
733+ do: { substitute_placeholder ( a , arg ) , m , Enum . map ( args , & substitute_placeholder ( & 1 , arg ) ) }
734+
735+ defp substitute_placeholder ( { a , b } , arg ) , do: { substitute_placeholder ( a , arg ) , substitute_placeholder ( b , arg ) }
736+
737+ defp substitute_placeholder ( list , arg ) when is_list ( list ) , do: Enum . map ( list , & substitute_placeholder ( & 1 , arg ) )
738+
739+ defp substitute_placeholder ( other , _arg ) , do: other
518740end
0 commit comments