# 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 do @moduledoc """ A Style takes AST and returns a transformed version of that AST. Because these transformations involve traversing trees (the "T" in "AST"), we wrap the AST in a structure called a Zipper to facilitate walking the trees. """ alias Styler.Zipper @type context :: %{ comment: [map()], file: :stdin | String.t() } @doc """ `run` will be used with `Zipper.traverse_while/3`, meaning it will be executed on every node of the AST. You can skip traversing parts of the tree by returning a Zipper that's further along in the traversal, for example by calling `Zipper.skip(zipper)` to skip an entire subtree you know is of no interest to your Style. """ @callback run(Zipper.zipper(), context()) :: {Zipper.command(), Zipper.zipper(), context()} @doc """ Deletes `:line` and `newlines` from the node's meta If you expected `{:foo, foo_meta, [bar, baz, bop]` to give you a a single line like foo(bar, baz, bop) but instead got foo( bar, baz, bop ) then it's likely that at least one of `bar`, `baz`, and/or `bop` have `:line` meta that's confusing the formatter and causing the multilining. This function fixes that problem. {:foo, foo_meta, Enum.map([bar, baz, bop], &Styler.Style.drop_line_meta/1)} # => foo(bar, baz, bop) """ def drop_line_meta(ast_node), do: update_all_meta(ast_node, &Keyword.drop(&1, [:line, :newlines])) @doc "Sets `:line`, `:closing`, and `:last` to all be on `line` and deletes `:newlines`" def set_line_meta_to_line(ast_node, line) do update_all_meta(ast_node, fn meta -> meta |> Keyword.replace(:line, line) |> Keyword.replace(:closing, line: line) |> Keyword.replace(:last, line: line) |> Keyword.delete(:newlines) end) end @doc "Traverses an ast node, updating all nodes' meta with `meta_fun`" def update_all_meta(node, meta_fun) do node |> Zipper.zip() |> Zipper.traverse(fn zipper -> Zipper.update(zipper, &Macro.update_meta(&1, meta_fun)) end) |> Zipper.root() end @doc """ Ensure the parent node can have multiple children. If a context-changing node (a `do end` block or an `->` arrow block) is encountered the child is wrapped in a `:__block__` Other nodes (pipes, assignments) can only have a fixed number of children. This function will recursively traverse up the zipper until it's found the parents of those nodes. """ def ensure_block_parent(zipper) do case Zipper.up(zipper) do # Pipes and assignments have exactly two children - keep going up {{:|>, _, _}, _} = parent -> ensure_block_parent(parent) {{:=, _, _}, _} = parent -> ensure_block_parent(parent) # the current zipper is an only-child of an arrow ala `true -> :ok` # we need to change the body of the arrow to be a `:__block__` so our `:ok` can have siblings {{:->, _, _}, _} -> wrap_in_block(zipper) # parent is an only-child of a `do` block {{_, _}, _} -> wrap_in_block(zipper) # a snippet or script where the zipper is a single child with no parent above it nil -> wrap_in_block(zipper) # since its parent isn't one of the problem AST above, the current zipper's parent can have multiple children, so we're done # could be `:def`, `:__block__`, ... _ -> zipper end end # give it a block parent, then step back to the child - we can insert next to it now that it's in a block defp wrap_in_block(zipper), do: zipper |> Zipper.update(&{:__block__, [], [&1]}) |> Zipper.down() @doc """ Set the line of all comments with `line` in `range_start..range_end` to instead have line `range_start` """ def displace_comments(comments, range) do Enum.map(comments, fn comment -> if comment.line in range do %{comment | line: range.first} else comment end end) end @doc """ Change the `line` of all comments with `line` in `range` by adding `delta` to it. A positive delta will move the lines further down a file, while a negative delta will move them up. """ def shift_comments(comments, range, delta) do Enum.map(comments, fn comment -> if comment.line in range do %{comment | line: comment.line + delta} else comment end end) end @doc "Returns true if the ast represents an empty map" def empty_map?({:%{}, _, []}), do: true def empty_map?({{:., _, [{_, _, [:Map]}, :new]}, _, []}), do: true def empty_map?(_), do: false end