@@ -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
518766end
0 commit comments