lib/style.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 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
end