Current section
Files
Jump to
Current section
Files
lib/style/blocks.ex
# Copyright 2024 Adobe. All rights reserved.
# Copyright 2025 SmartRent. 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 Quokka.Style.Blocks do
@moduledoc """
Simple 1-1 rewrites all crammed into one module to make for more efficient traversals
Credo Rules addressed:
* Credo.Check.Readability.LargeNumbers
* Credo.Check.Readability.ParenthesesOnZeroArityDefs
* Credo.Check.Readability.WithSingleClause
* Credo.Check.Refactor.CondStatements
* Credo.Check.Refactor.RedundantWithClauseResult
* Credo.Check.Refactor.WithClauses
"""
@behaviour Quokka.Style
alias Quokka.Style
alias Quokka.Zipper
defguardp is_negator(n) when elem(n, 0) in [:!, :not, :!=, :!==]
defguardp is_empty_body(n) when elem(n, 0) == :__block__ and elem(n, 2) in [[nil], []]
# Credo.Check.Refactor.CondStatements
def run({{:cond, _, [[{_, [{:->, _, [[head], a]}, {:->, _, [[{:__block__, _, [truthy]}], b]}]}]]}, _} = zipper, ctx)
when is_atom(truthy) and truthy not in [nil, false],
do: if_ast(zipper, head, a, b, ctx)
# 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` and `else: nil`
[head, [do_block, {_, else_body}]] when is_empty_body(else_body) ->
{:cont, Zipper.replace(zipper, {:if, m, [head, [do_block]]}), ctx}
# invert and drop `do: nil`
[head, [{do_, do_body}, {_, else_body}]] when is_empty_body(do_body) ->
{:cont, Zipper.replace(zipper, {:if, m, [invert(head), [{do_, else_body}]]}), 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