Packages
styler
1.10.1
1.11.0
1.10.1
1.10.0
1.9.1
1.9.0
1.8.0
1.7.0
1.6.0
1.5.1
1.5.0
1.4.2
1.4.1
1.4.0
1.3.3
1.3.2
1.3.1
1.3.0
1.2.1
1.2.0
1.1.2
1.1.1
1.1.0
1.0.0
1.0.0-rc.2
1.0.0-rc.1
1.0.0-rc.0
1.0.0-alpha.0
0.11.9
0.11.8
0.11.7
0.11.6
0.11.5
0.11.4
0.11.3
0.11.2
0.11.1
0.11.0
0.10.5
0.10.4
retired
0.10.3
0.10.2
0.10.1
0.10.0
0.9.7
0.9.6
0.9.5
0.9.4
0.9.3
0.9.2
retired
0.9.1
retired
0.9.0
0.8.5
0.8.4
0.8.3
0.8.2
0.8.1
0.8.0
0.7.14
0.7.13
0.7.12
0.7.11
0.7.10
0.7.9
0.7.8
0.7.7
0.7.6
0.7.5
0.7.4
0.7.3
0.7.2
0.7.1
0.7.0
0.6.1
0.6.0
0.5.2
0.5.1
0.5.0
0.4.1
0.4.0
0.3.1
0.3.0
0.2.0
0.1.1
0.1.0
A code-style enforcer that will just FIFY instead of complaining
Current section
Files
Jump to
Current section
Files
lib/style/blocks.ex
# Copyright 2024 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.Blocks do
@moduledoc """
Simple 1-1 rewrites all crammed into one module to make for more efficient traversals
Credo Rules addressed:
* Credo.Check.Consistency.ParameterPatternMatching
* Credo.Check.Readability.LargeNumbers
* Credo.Check.Readability.ParenthesesOnZeroArityDefs
* Credo.Check.Readability.PreferImplicitTry
* Credo.Check.Readability.WithSingleClause
* Credo.Check.Refactor.CaseTrivialMatches
* Credo.Check.Refactor.CondStatements
* Credo.Check.Refactor.RedundantWithClauseResult
* Credo.Check.Refactor.WithClauses
"""
@behaviour Styler.Style
alias Styler.Style
alias Styler.Zipper
defguardp is_negator(n) when elem(n, 0) in [:!, :not, :!=, :!==]
# case statement with exactly 2 `->` cases
# rewrite to `if` if it's any of 3 trivial cases
def run({{:case, _, [head, [{_, [{:->, _, [[lhs_a], a]}, {:->, _, [[lhs_b], b]}]}]]}, _} = zipper, ctx) do
case {lhs_a, lhs_b} do
{{_, _, [true]}, {_, _, [false]}} -> if_ast(zipper, head, a, b, ctx)
{{_, _, [true]}, {:_, _, _}} -> if_ast(zipper, head, a, b, ctx)
{{_, _, [false]}, {_, _, [true]}} -> if_ast(zipper, head, b, a, ctx)
_ -> {:cont, zipper, ctx}
end
end
# case statements with 1 clause: thanks 🤖!
#
# ideally rewrite to use `=`, but can't do that when there's a `when` clause
def run({{:case, _, [_, [{_, [{:->, _, [[{:when, _, _} | _] | _]}]}]]}, _} = zipper, ctx), do: {:cont, zipper, ctx}
#
def run({{:case, m, [head, [{_, [{:->, _, [[lhs], rhs]}]}]]}, _} = zipper, ctx) do
rhs =
case rhs do
{:__block__, _, children} -> children
node -> [node]
end
zipper =
case Zipper.up(zipper) do
{{:=, am, [parent_lhs, _case_statement_rhs]}, _} = zipper ->
# this was a `x = case head, do: (lhs -> rhs)`. make it `x = lhs = head; x = rhs`
meta = [line: am[:line]]
# change the if there are multiple clauses in the case, have the final one be the new rhs of parent_lhs
# the meta for the final equality has an incorrect line, but it shouldn't mess with comments so leaving it be
siblings = List.update_at(rhs, -1, &{:=, meta, [parent_lhs, &1]})
zipper
|> Zipper.replace({:=, meta, [lhs, head]})
|> Zipper.insert_siblings(siblings)
_ ->
zipper
|> Style.find_nearest_block()
|> Zipper.replace({:=, [line: m[:line]], [lhs, head]})
|> Zipper.insert_siblings(rhs)
end
{:cont, zipper, ctx}
end
def run({{:cond, _, [[{do_, clauses}]]}, _} = zipper, ctx) do
# ensure all final `atom -> final_clause` use `true` for consistency.
# `:else` is cute but consistency is all.
rewrite_literal_to_true = fn
{:->, am, [[{:__block__, bm, [truthy]}], body]} when truthy not in [nil, false] ->
{:->, am, [[{:__block__, bm, [true]}], body]}
# Surely this never happens buuuuut?
# %{} ->; {} ->
{:->, am, [[{literal, bm, _}], body]} when literal in [:{}, :%{}] ->
{:->, am, [[{:__block__, bm, [true]}], body]}
other ->
other
end
case List.update_at(clauses, -1, rewrite_literal_to_true) do
# # Credo.Check.Refactor.CondStatements
[{:->, _, [[head], a]}, {:->, _, [[{:__block__, _, [true]}], b]}] -> if_ast(zipper, head, a, b, ctx)
clauses -> {:cont, Zipper.replace_children(zipper, [[{do_, clauses}]]), ctx}
end
end
# Credo.Check.Readability.WithSingleClause
# rewrite `with success <- single_statement do body else ...elses end`
# to `case single_statement do success -> body; ...elses end`
def run({{:with, m, [{:<-, am, [success, single_statement]}, [body, elses]]}, zm}, ctx) do
{{:__block__, do_meta, [:do]}, body} = body
{{:__block__, _, [:else]}, elses} = elses
elses =
case elses do
# unwrap a stab ala `, else: (_ -> :ok)`. these became literals in 1.17
{:__block__, _, [[{:->, _, _}] = stab]} -> stab
elses -> elses
end
# drops keyword formatting etc
do_meta = [line: do_meta[:line]]
clauses = [{{:__block__, am, [:do]}, [{:->, do_meta, [[success], body]} | elses]}]
end_line = Style.max_line(elses) + 1
# fun fact: i added the detailed meta just because i noticed it was missing while debugging something ...
# ... and it fixed the bug 🤷
case_meta = [
end_of_expression: [newlines: 1, line: end_line],
do: do_meta,
end: [line: end_line],
line: m[:line]
]
# recurse in case this new case should be rewritten to a `if`, etc
run({{:case, case_meta, [single_statement, clauses]}, zm}, ctx)
end
# `with true <- x, do: bar` =>`if x, do: bar`
def run({{:with, m, [{:<-, _, [{_, _, [true]}, rhs]}, [do_kwl]]}, _} = zipper, ctx) do
children =
case rhs do
# `true <- foo || {:error, :shouldve_used_an_if_statement}``
# turn the rhs of an `||` into an else body
{:||, _, [head, else_body]} ->
[head, [do_kwl, {{:__block__, [line: m[:line] + 2], [:else]}, Style.shift_line(else_body, 3)}]]
_ ->
[rhs, [do_kwl]]
end
{:cont, Zipper.replace(zipper, {:if, m, children}), ctx}
end
# Credo.Check.Refactor.WithClauses
def run({{:with, _, children}, _} = zipper, ctx) when is_list(children) do
do_block? = Enum.any?(children, &Style.do_block?/1)
arrow_or_match? = Enum.any?(children, &(left_arrow?(&1) || match?({:=, _, _}, &1)))
cond do
# we can style this!
do_block? and arrow_or_match? ->
style_with_statement(zipper, ctx)
# `with (head_statements) do: x (else ...)`
do_block? ->
# head statements can be the empty list, if it matters
{head_statements, [[{{:__block__, _, [:do]}, body} | _]]} = Enum.split_while(children, &(not Style.do_block?(&1)))
[first | rest] = head_statements ++ [body]
# replace this `with` statement with its headers + body
zipper = zipper |> Zipper.replace(first) |> Zipper.insert_siblings(rest)
{:cont, zipper, ctx}
# maybe this isn't a with statement - could be a function named `with` or something.
true ->
{:cont, zipper, ctx}
end
end
def run({{:unless, m, [head, do_else]}, _} = zipper, ctx) do
zipper
|> Zipper.replace({:if, m, [invert(head), do_else]})
|> run(ctx)
end
def run({{:if, m, children}, _} = zipper, ctx) do
case children do
# double negator
# if !!x, do: y[, else: ...] => if x, do: y[, else: ...]
[{_, _, [nb]} = na, do_else] when is_negator(na) and is_negator(nb) ->
zipper |> Zipper.replace({:if, m, [invert(nb), do_else]}) |> run(ctx)
# Credo.Check.Refactor.NegatedConditionsWithElse
# if !x, do: y, else: z => if x, do: z, else: y
[negator, [{do_, do_body}, {else_, else_body}]] when is_negator(negator) ->
zipper |> Zipper.replace({:if, m, [invert(negator), [{do_, else_body}, {else_, do_body}]]}) |> run(ctx)
# drop `else end`
[head, [do_block, {_, {:__block__, _, []}}]] ->
{:cont, Zipper.replace(zipper, {:if, m, [head, [do_block]]}), ctx}
# drop `else: nil`
[head, [do_block, {_, {:__block__, _, [nil]}}]] ->
{:cont, Zipper.replace(zipper, {:if, m, [head, [do_block]]}), ctx}
[head, [do_, else_]] ->
if Style.max_line(do_) > Style.max_line(else_) do
# we inverted the if/else blocks of this `if` statement in a previous pass (due to negators or unless)
# shift comments etc to make it happy now
if_ast(zipper, head, do_, else_, ctx)
else
{:cont, zipper, ctx}
end
_ ->
{:cont, zipper, ctx}
end
end
def run(zipper, ctx), do: {:cont, zipper, ctx}
# with statements can do _a lot_, so this beast of a function likewise does a lot.
defp style_with_statement({{:with, with_meta, children}, _} = zipper, ctx) do
{preroll, children} =
children
|> Enum.map(fn
# `_ <- rhs` => `rhs`
{:<-, _, [{:_, _, _}, rhs]} -> rhs
# `lhs <- rhs` => `lhs = rhs`
{:<-, m, [{atom, _, nil} = lhs, rhs]} when is_atom(atom) -> {:=, m, [lhs, rhs]}
child -> child
end)
|> Enum.split_while(&(not left_arrow?(&1)))
# after rewriting `x <- y()` to `x = y()` there are no more arrows.
# this never should've been a with statement at all! we can just replace it with assignments
if Enum.empty?(children) do
{:cont, replace_with_statement(zipper, preroll), ctx}
else
[[{{_, do_meta, _} = do_block, do_body} | elses] | reversed_clauses] = Enum.reverse(children)
{postroll, reversed_clauses} = Enum.split_while(reversed_clauses, &(not left_arrow?(&1)))
[{:<-, final_clause_meta, [lhs, rhs]} = _final_clause | rest] = reversed_clauses
# drop singleton identity else clauses like `else foo -> foo end`
elses =
with [{{_, _, [:else]}, [{:->, _, [[left], right]}]}] <- elses,
true <- nodes_equivalent?(left, right),
do: [],
else: (_ -> elses)
# Remove Redundant body
{postroll, reversed_clauses, do_body} =
if Enum.empty?(postroll) and Enum.empty?(elses) and nodes_equivalent?(lhs, do_body) do
# removing redundant RHS can expose more non-arrows behind it, so repeat our postroll process
{postroll, reversed_clauses} = Enum.split_while(rest, &(not left_arrow?(&1)))
{postroll, reversed_clauses, rhs}
else
{postroll, reversed_clauses, do_body}
end
# Put the postroll into the body
{reversed_clauses, do_body} =
if Enum.any?(postroll) do
{node, do_body_meta, do_children} = do_body
do_children = if node == :__block__, do: do_children, else: [do_body]
do_body = {:__block__, Keyword.take(do_body_meta, [:line]), Enum.reverse(postroll, do_children)}
{reversed_clauses, do_body}
else
{reversed_clauses, do_body}
end
final_clause_line = final_clause_meta[:line]
do_line =
cond do
do_meta[:format] == :keyword && final_clause_line + 1 >= do_meta[:line] -> do_meta[:line]
do_meta[:format] == :keyword -> final_clause_line + 1
true -> final_clause_line
end
do_block = Macro.update_meta(do_block, &Keyword.put(&1, :line, do_line))
# disable keyword `, do:` since there will be multiple statements in the body
with_meta =
if Enum.any?(postroll),
do: Keyword.merge(with_meta, do: [line: with_meta[:line]], end: [line: Style.max_line(children) + 1]),
else: with_meta
with_children = Enum.reverse(reversed_clauses, [[{do_block, do_body} | elses]])
zipper = Zipper.replace(zipper, {:with, with_meta, with_children})
cond do
# oops! RedundantWithClauseResult removed the final arrow in this. no more need for a with statement!
Enum.empty?(reversed_clauses) ->
{:cont, replace_with_statement(zipper, preroll ++ with_children), ctx}
# recurse if the # of `<-` have changed (this `with` could now be eligible for a `case` rewrite)
Enum.any?(preroll) ->
# put the preroll before the with statement in either a block we create or the existing parent block
zipper
|> Style.find_nearest_block()
|> Zipper.prepend_siblings(preroll)
|> run(ctx)
# the # of `<-` changed, so we should have another look at this with statement
Enum.any?(postroll) ->
run(zipper, ctx)
true ->
# of clauses didn't change, so don't reecurse or we'll loop FOREEEVEERR
{:cont, zipper, ctx}
end
end
end
# `with a <- b(), c <- d(), do: :ok, else: (_ -> :error)`
# =>
# `a = b(); c = d(); :ok`
defp replace_with_statement(zipper, preroll) do
[[{_do, do_body} | _elses] | preroll] = Enum.reverse(preroll)
block =
case do_body do
{:__block__, _, [{_, _, _} | _] = children} ->
Enum.reverse(preroll, children)
_ ->
# RedundantWithClauseResult except we rewrote the `<-` to an `=`
# `with a, b, x <- y(), do: x` => `a; b; y`
case preroll do
[{:=, _, [lhs, rhs]} | rest] ->
if nodes_equivalent?(lhs, do_body),
do: Enum.reverse(rest, [rhs]),
else: Enum.reverse(preroll, [do_body])
_ ->
Enum.reverse(preroll, [do_body])
end
end
case Style.ensure_block_parent(zipper) do
{:ok, zipper} ->
zipper
|> Zipper.prepend_siblings(block)
|> Zipper.remove()
:error ->
# this is a very sad case, where the `with` is an arg to a function or the rhs of an assignment.
# for now, just hacking a block with parens where the with use to be
# x = with a, b, c, do: d
# =>
# x =
# (
# a
# b
# c
# d
# )
# @TODO would be nice to change to
# a
# b
# c
# x = d
Zipper.update(zipper, fn {:with, meta, _} -> {:__block__, Keyword.take(meta, [:line]), block} end)
end
end
defp left_arrow?({:<-, _, _}), do: true
defp left_arrow?(_), do: false
defp nodes_equivalent?(a, b), do: Style.without_meta(a) == Style.without_meta(b)
defp if_ast(zipper, head, {_, _, _} = do_body, {_, _, _} = else_body, ctx) do
do_ = {{:__block__, [line: nil], [:do]}, do_body}
else_ = {{:__block__, [line: nil], [:else]}, else_body}
if_ast(zipper, head, do_, else_, ctx)
end
defp if_ast(zipper, {_, meta, _} = head, {do_kw, do_body}, {else_kw, else_body}, ctx) do
line = meta[:line]
# ... why am i doing this again? hmm.
do_body = Macro.update_meta(do_body, &Keyword.delete(&1, :end_of_expression))
else_body = Macro.update_meta(else_body, &Keyword.delete(&1, :end_of_expression))
max_do_line = Style.max_line(do_body)
max_else_line = Style.max_line(else_body)
end_line = max(max_do_line, max_else_line)
# Change ast meta and comment lines to fit the `if` ast
{do_, else_, comments} =
if max_do_line >= max_else_line do
# we're swapping the ordering of two blocks of code
# and so must swap the lines of the ast & comments to keep comments where they belong!
# the math is: move B up by the length of A, and move A down by the length of B plus one (for the else keyword)
else_size = max_else_line - line
do_size = max_do_line - max_else_line
shifts = [
# move comments in the `else_body` down by the size of the `do_body`
{line..max_else_line, do_size},
# move comments in `do_body` up by the size of the `else_body`
{(max_else_line + 1)..max_do_line, -else_size}
]
do_ = {Style.set_line(do_kw, line), Style.shift_line(do_body, -else_size)}
else_ = {Style.set_line(else_kw, max_else_line), Style.shift_line(else_body, do_size)}
{do_, else_, Style.shift_comments(ctx.comments, shifts)}
else
# much simpler case -- just scootch things in the else down by 1 for the `else` keyword.
do_ = {{:__block__, [line: line], [:do]}, do_body}
else_ = Style.shift_line({{:__block__, [line: max_do_line], [:else]}, else_body}, 1)
{do_, else_, Style.shift_comments(ctx.comments, max_do_line..max_else_line, 1)}
end
zipper
|> Zipper.replace({:if, [do: [line: line], end: [line: end_line], line: line], [head, [do_, else_]]})
|> run(%{ctx | comments: comments})
end
defp invert({:!=, m, [a, b]}), do: {:==, m, [a, b]}
defp invert({:!==, m, [a, b]}), do: {:===, m, [a, b]}
defp invert({:==, m, [a, b]}), do: {:!=, m, [a, b]}
defp invert({:===, m, [a, b]}), do: {:!==, m, [a, b]}
defp invert({:!, _, [condition]}), do: condition
defp invert({:not, _, [condition]}), do: condition
defp invert({:in, m, [_, _]} = ast), do: {:not, m, [ast]}
defp invert({_, m, _} = ast), do: {:!, [line: m[:line]], [ast]}
end