Current section

Files

Jump to
styler lib style pipes.ex
Raw

lib/style/pipes.ex

# 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"]
"""
@behaviour Styler.Style
alias Styler.Zipper
@blocks ~w(case if with cond for unless)a
# we're in a multi-pipe, so only need to fix pipe_start
def run({{:|>, _, [{:|>, _, _} | _]}, _} = zipper), do: zipper |> check_start() |> Zipper.next()
# this is a single pipe, since valid pipelines are consumed by the previous head
def run({{:|>, meta, [lhs, {fun, _, args}]}, _} = zipper) do
if valid_pipe_start?(lhs) do
# `a |> f(b, c)` => `f(a, b, c)`
Zipper.replace(zipper, {fun, meta, [lhs | args]})
else
zipper = fix_start(zipper)
{maybe_block, _, _} = lhs
if maybe_block in @blocks do
# extracting a block means this is now `if_result |> single_pipe(a, b)`
# recursing will give us `single_pipe(if_result, a, b)`
run(zipper)
else
# fixing the start when it was a function call added another pipe to the chain, and so it's no longer
# a single pipe
zipper
end
end
end
def run(zipper), do: zipper
# walking down a pipeline.
# for reference, `a |> b() |> c()` is encoded `{:|>, [{:|>, _, [a, b]}, c]}`
# that is, the outermost ast is the last step of the chain, and the innermost pipe is the first step of the chain
defp check_start({{:|>, _, [{:|>, _, _} | _]}, _} = zipper), do: zipper |> Zipper.next() |> check_start()
# we found the pipe starting expression!
defp check_start({{:|>, _, [lhs, _]}, _} = zip), do: if(valid_pipe_start?(lhs), do: zip, else: fix_start(zip))
defp check_start(zipper), do: zipper
# this rewrites pipes that begin with blocks to save the result of the block expression into its own (non-hygienic!)
# variable, and then use that variable as the start of the pipe. the variable is named after the type of block:
# `case_result` or `if_result`
#
# before:
#
# case ... do
# ...
# end
# |> a()
# |> b()
#
# after:
#
# case_result =
# case ... do
# ...
# end
#
# case_result
# |> a()
# |> b()
defp fix_start({{:|>, pipe_meta, [{block, _, _} = expression, rhs]}, _} = zipper) when block in @blocks do
variable = {:"#{block}_result", [], nil}
zipper
|> Zipper.replace({:|>, pipe_meta, [variable, rhs]})
|> find_valid_assignment_location()
|> Zipper.insert_left({:=, [], [variable, expression]})
end
# this rewrites other invalid pipe starts: `Module.foo(...) |> ...` and `foo(...) |> ....`
defp fix_start({{:|>, pipe_meta, [lhs, rhs]}, _} = zipper) do
lhs_rewrite =
case lhs do
# `Module.foo(a, ...)` => `a |> Module.foo(...)`
{{:., dot_meta, dot_args}, args_meta, [arg | args]} ->
{:|>, args_meta, [arg, {{:., [], dot_args}, dot_meta, args}]}
# `foo(a, ...)` => `a |> foo(...)`
{atom, meta, [arg | args]} ->
{:|>, [], [arg, {atom, meta, args}]}
end
zipper |> Zipper.replace({:|>, pipe_meta, [lhs_rewrite, rhs]}) |> Zipper.next()
end
# this really needs a better name.
# essentially what we're doing is walking up the tree in search of a parent where it would be syntactically valid
# to insert a new "assignment" node (`x = y`)
# as we walk up the tree, our parent will be either
# 1. an invalid node for an assignment (still in the pipeline or in another assignment)
# 2. the start of the context (function def start)
# 3. something else!
# for 1, we keep going up
# for 2, we wrap ourselves in a new block parent (where we can insert a sibling node)
# for 3, we're done - wherever it is we are, our parent already supports us inserting a sibling node
defp find_valid_assignment_location(zipper) do
case Zipper.up(zipper) do
# still trying to find our way up the pipe, keep walking...
{{:|>, _, _}, _} = parent -> find_valid_assignment_location(parent)
# the parent of this pipe is an assignment like
#
# baz =
# block do ... end
# |> ...
#
# so we need to step up again and see what the assignment's parent is, with the goal of inserting our new
# assignment before the assignment built from the pipe chain, like:
#
# block_result = block do ... end
# baz =
# block_result
# |> ...
{{:=, _, _}, _} = parent -> find_valid_assignment_location(parent)
# we're in a function which is an immediate pipeline, like:
#
# def fun do
# block do end
# |> f()
# end
{{{:__block__, _, _}, {:|>, _, _}}, _} -> wrap_in_block(zipper)
# similar to the function definition, except it's an anonymous function this time
#
# fn ->
# case do end
# |> b()
# end
{{:->, _, [_, {:|>, _, _} | _]}, _} -> wrap_in_block(zipper)
# a snippet or script where the problem block has no parent
nil -> wrap_in_block(zipper)
# since its parent isn't one of the problem AST above, the current zipper must be a valid place to insert the node
_ -> zipper
end
end
# give it a block parent, then step back to the pipe - we can insert next to it now that it's in a block
defp wrap_in_block({node, _} = zipper) do
zipper
|> Zipper.replace({:__block__, [], [node]})
|> Zipper.next()
end
# 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
# 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