Packages
styler
1.12.0
1.12.0
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, m, [head, [{_, [{:->, _, [[lhs_a], _]} = a, {:->, _, [[lhs_b], _]} = b]}]]}, _} = zipper, ctx) do
end_line = m[:end][:line]
case {lhs_a, lhs_b} do
{{_, _, [true]}, {_, _, [false]}} -> arrows_to_if(zipper, head, a, b, end_line, ctx)
{{_, _, [true]}, {:_, _, _}} -> arrows_to_if(zipper, head, a, b, end_line, ctx)
{{_, _, [false]}, {_, _, [true]}} -> arrows_to_if(zipper, head, b, a, end_line, 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, m, [[{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] ->
arrows_to_if(zipper, head, a, b, m[:end][:line], 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) ->
# end of expression hack ensure that the else body keeps dangling comments its block
else_line = Style.meta(else_)[:line]
do_body = Macro.update_meta(do_body, &Keyword.put(&1, :end_of_expression, line: else_line, newlines: 1))
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
organize_if(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)
# hacks comments above the arrows to have the same line number as the start of the body,
# and hacks the body of the last of a/b to have an end of expression equal to where the `end` keyword is to make sure
# dangling comments get caught
# would be lovely to not hack things so hard but c'est la vie for now
defp arrows_to_if(zipper, head, {:->, am, [_, a]}, {:->, bm, [_, b]}, end_line, ctx) do
ctx =
ctx
|> Map.update!(:comments, &lower_arrow_comments_to_body(&1, am, a))
|> Map.update!(:comments, &lower_arrow_comments_to_body(&1, bm, b))
# hacking the end_of_expression helps ensure that the (previously) last clause catches dangling comments
[a, b] =
if Style.first_line(a) < Style.first_line(b) do
b = Macro.update_meta(b, &Keyword.put(&1, :end_of_expression, line: end_line, newlines: 1))
[a, b]
else
a = Macro.update_meta(a, &Keyword.put(&1, :end_of_expression, line: end_line, newlines: 1))
[a, b]
end
do_ = {{:__block__, [line: nil], [:do]}, a}
else_ = {{:__block__, [line: nil], [:else]}, b}
organize_if(zipper, head, do_, else_, ctx)
end
defp lower_arrow_comments_to_body(comments, arrow_meta, body) do
arrow_line = arrow_meta[:line]
if Style.first_line(body) == arrow_line do
comments
else
{mine, rest} = Style.comments_for_lines(comments, arrow_line, arrow_line)
mine = Enum.map(mine, &%{&1 | line: &1.line + 1})
Enum.sort_by(rest ++ mine, & &1.line)
end
end
defp organize_if(zipper, {_, meta, _} = head, {do_kw, do_body}, {else_kw, else_body}, ctx) do
head_line = meta[:line]
{[do_body, else_body], comments} = Style.order_line_meta_and_comments([do_body, else_body], ctx.comments, head_line)
else_line = Style.max_line(do_body)
end_line = Style.max_line(else_body) + 1
# clean up the dangling comments hack if this was a conversion
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))
do_ = {Style.set_line(do_kw, head_line), do_body}
else_ = {Style.set_line(else_kw, else_line), else_body}
zipper
|> Zipper.replace({:if, [do: [line: head_line], end: [line: end_line], line: head_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