# Copyright 2023 Adobe. All rights reserved. # This file is licensed to you under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. You may obtain a copy # of the License at http://www.apache.org/licenses/LICENSE-2.0 # Unless required by applicable law or agreed to in writing, software distributed under # the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS # OF ANY KIND, either express or implied. See the License for the specific language # governing permissions and limitations under the License. defmodule Styler.Style.Pipes do @moduledoc """ Styles pipes! In particular, don't make pipe chains of only one pipe, and some persnickety pipe chain start stuff. Rewrites for the following Credo rules: * Credo.Check.Readability.BlockPipe * Credo.Check.Readability.SinglePipe * Credo.Check.Refactor.PipeChainStart, excluded_functions: ["from"] The following two rules are only corrected within pipe chains; nested functions aren't fixed * Credo.Check.Refactor.FilterCount * Credo.Check.Refactor.MapJoin * Credo.Check.Refactor.MapInto """ @behaviour Styler.Style alias Styler.Style alias Styler.Zipper @blocks ~w(case if with cond for unless)a def run({{:|>, _, _}, _} = zipper, ctx) do case fix_pipe_start(zipper) do {{:|>, _, _}, _} = zipper -> case Zipper.traverse(zipper, fn {node, meta} -> {optimize(node), meta} end) do {{:|>, _, [{:|>, _, _}, _]}, _} = chain_zipper -> {:cont, find_pipe_start(chain_zipper), ctx} {{:|>, _, [lhs, {fun, meta, args}]}, _} = single_pipe_zipper -> lhs = Style.delete_line_meta(lhs) function_call_zipper = Zipper.replace(single_pipe_zipper, {fun, meta, [lhs | args || []]}) {:cont, function_call_zipper, ctx} end non_pipe -> {:cont, non_pipe, ctx} end end def run(zipper, ctx), do: {:cont, zipper, ctx} defp fix_pipe_start({pipe, zmeta} = zipper) do {{:|>, pipe_meta, [lhs, rhs]}, _} = start_zipper = find_pipe_start({pipe, nil}) if valid_pipe_start?(lhs) do zipper else {lhs_rewrite, new_assignment} = extract_start(lhs) {pipe, nil} = start_zipper |> Zipper.replace({:|>, pipe_meta, [lhs_rewrite, rhs]}) |> Zipper.top() if new_assignment do # It's important to note that with this branch, we're no longer # focused on the pipe! We'll return to it in a future iteration of traverse_while {pipe, zmeta} |> Style.ensure_block_parent() |> Zipper.insert_left(new_assignment) |> Zipper.left() else {pipe, zmeta} end end end defp find_pipe_start(zipper) do Zipper.find(zipper, fn {:|>, _, [{:|>, _, _}, _]} -> false {:|>, _, _} -> true end) end # `block do ... end |> ...` # =======================> # block_result = # block do # ... # end # # block_result # |> ... defp extract_start({block, _, _} = lhs) when block in @blocks do variable = {:"#{block}_result", [], nil} new_assignment = {:=, [], [variable, lhs]} {variable, new_assignment} end # `foo(a, ...) |> ...` => `a |> foo(...) |> ...` defp extract_start({fun, meta, [arg | args]}) do {{:|>, [], [arg, {fun, meta, args}]}, nil} end # `pipe_chain(a, b, c)` generates the ast for `a |> b |> c` # the intention is to make it a little easier to see what the optimize functions are matching on =) defmacrop pipe_chain(a, b, c) do quote do: {:|>, _, [{:|>, _, [unquote(a), unquote(b)]}, unquote(c)]} end # `lhs |> Enum.filter(filterer) |> Enum.count()` => `lhs |> Enum.count(count)` defp optimize( pipe_chain( lhs, {{:., _, [{_, _, [:Enum]}, :filter]}, _, [filterer]}, {{:., _, [{_, _, [:Enum]}, :count]} = count, _, []} ) ) do {:|>, [], [lhs, {count, [], [filterer]}]} end # `lhs |> Enum.map(mapper) |> Enum.join(joiner)` => `lhs |> Enum.map_join(joiner, mapper)` defp optimize( pipe_chain( lhs, {{:., _, [{_, _, [:Enum]}, :map]}, _, [mapper]}, {{:., _, [{_, _, [:Enum]}, :join]}, _, [joiner]} ) ) do # Delete line info to keep things shrunk on the rewrite joiner = Style.delete_line_meta(joiner) mapper = Style.delete_line_meta(mapper) rhs = {{:., [], [{:__aliases__, [], [:Enum]}, :map_join]}, [], [joiner, mapper]} {:|>, [], [lhs, rhs]} end # `lhs |> Enum.map(mapper) |> Enum.into(empty_map)` => `lhs |> Map.new(mapper) # or # `lhs |> Enum.map(mapper) |> Enum.into(collectable)` => `lhs |> Enum.into(collectable, mapper) defp optimize( pipe_chain( lhs, {{:., _, [{_, _, [:Enum]}, :map]}, _, [mapper]}, {{:., _, [{_, _, [:Enum]}, :into]} = into, _, [collectable]} ) ) do mapper = Style.delete_line_meta(mapper) rhs = if empty_map?(collectable), do: {{:., [], [{:__aliases__, [], [:Map]}, :new]}, [], [mapper]}, else: {into, [], [Style.delete_line_meta(collectable), mapper]} {:|>, [], [lhs, rhs]} end defp optimize({:|>, meta, [lhs, {{:., dm, [{_, _, [:Enum]}, :into]}, _, [collectable]}]} = node) do if empty_map?(collectable), do: {:|>, meta, [lhs, {{:., dm, [{:__aliases__, [], [:Map]}, :new]}, [], []}]}, else: node end defp optimize({:|>, meta, [lhs, {{:., dm, [{_, _, [:Enum]}, :into]}, _, [collectable, mapper]}]} = node) do if empty_map?(collectable), do: {:|>, meta, [lhs, {{:., dm, [{:__aliases__, [], [:Map]}, :new]}, [], [Style.delete_line_meta(mapper)]}]}, else: node end defp optimize(node), do: node defp empty_map?({:%{}, _, []}), do: true defp empty_map?({{:., _, [{_, _, [:Map]}, :new]}, _, []}), do: true defp empty_map?(_), do: false # literal wrapper defp valid_pipe_start?({:__block__, _, _}), do: true defp valid_pipe_start?({:__aliases__, _, _}), do: true defp valid_pipe_start?({:unquote, _, _}), do: true # ecto defp valid_pipe_start?({:from, _, _}), do: true defp valid_pipe_start?({{:., _, [{_, _, [:Query]}, :from]}, _, _}), do: true defp valid_pipe_start?({{:., _, [{_, _, [:Ecto, :Query]}, :from]}, _, _}), do: true # most of these values were lifted directly from credo's pipe_chain_start.ex @value_constructors ~w(% %{} .. <<>> @ {} & fn)a @simple_operators ~w(++ -- && ||)a @math_operators ~w(- * + / > < <= >= ==)a @binary_operators ~w(<> <- ||| &&& <<< >>> <<~ ~>> <~ ~> <~> <|> ^^^ ~~~)a defp valid_pipe_start?({op, _, _}) when op in @value_constructors or op in @simple_operators or op in @math_operators or op in @binary_operators, do: true # variable defp valid_pipe_start?({atom, _, nil}) when is_atom(atom), do: true # 0-arity function_call() defp valid_pipe_start?({atom, _, []}) when is_atom(atom), do: true # function_call(with, args) or sigils. sigils are allowed, function w/ args is not defp valid_pipe_start?({atom, _, [_ | _]}) when is_atom(atom), do: String.match?("#{atom}", ~r/^sigil_[a-zA-Z]$/) # map[:access] defp valid_pipe_start?({{:., _, [Access, :get]}, _, _}), do: true # Module.function_call() defp valid_pipe_start?({{:., _, _}, _, []}), do: true # '__#{val}__' are compiled to List.to_charlist("__#{val}__") # we want to consider these charlists a valid pipe chain start defp valid_pipe_start?({{:., _, [List, :to_charlist]}, _, [[_ | _]]}), do: true # Module.function_call(with, parameters) defp valid_pipe_start?({{:., _, _}, _, _}), do: false defp valid_pipe_start?(_), do: true end