diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 00000000..f86f10d5 --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,7 @@ +[ + inputs: [ + "{mix,.formatter}.exs", + "{config,lib,test}/**/*.{ex,exs}" + ], + line_length: 122 +] diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..d0b2773e --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where third-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +formatter-*.tar + +# Temporary files, for example, from tests. +/tmp/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..929d0ba6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,16 @@ +# Changelog + +## v0.1.0 + +### Improvements + +* Initial release of Styler +* Added `Aliases` style, replacing the following Credo rules: + * `Credo.Check.Readability.AliasOrder` + * `Credo.Check.Readability.MultiAlias` + * `Credo.Check.Readability.UnnecessaryAliasExpansion` +* Added `Pipes` style, replacing the following Credo rules: + * `Credo.Check.Readability.BlockPipe` + * `Credo.Check.Readability.SinglePipe` + * `Credo.Check.Refactor.PipeChainStart` +* Added `Defs` style (currently disabled by default) diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..dd5b3a58 --- /dev/null +++ b/LICENSE @@ -0,0 +1,174 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. diff --git a/README.md b/README.md new file mode 100644 index 00000000..fe6f696a --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# Styler + +Styler is an AST-rewriting tool. Think of it as a combination of `mix format` and `mix credo`, except instead of telling +you what's wrong, it just rewrites the code for you to fit our style rules. Hence, `mix style`! + +Styler is configuration-free. Like `mix format`, it runs based on the `inputs` from `.formatter.exs` and has opinions rather than configuration. + +## `mix style` + +`mix style` is a 1-1 stand-in for `mix format` in all the normal use-cases. Run `mix help style` for help using it. + +### Styler and Comments... + +Styler is currently unaware of comments, so you may find that it puts them in really odd spots after a rewrite. + +If you find that a comment was put somewhere weird after using Styler, you'll just have to manually put it back where you want it after. +Feel free to grumble about it in an Issue so that we can properly prioritize making this work better in the future. + +## Current Styles + +You can find the currently-enabled styles in the `Mix.Tasks.Style` module, inside of its `@styles` module attribute. Each Style's moduledoc will tell you more about what it rewrites. + +## Credo Rules Styler Replaces + +| credo rule | style that rewrites to suit | +|---------------------------------------|--------------------------------------| +| `Credo.Check.Readability.AliasOrder` | `Styler.Style.Aliases` | +| `Credo.Check.Readability.MultiAlias` | `Styler.Style.Aliases` | +| `Credo.Check.Readability.UnnecessaryAliasExpansion` | `Styler.Style.Aliases` | +| `Credo.Check.Readability.SinglePipe` | `Styler.Style.Pipes` | +| `Credo.Check.Refactor.PipeChainStart` | `Styler.Style.Pipes` | + +## Writing Styles + +Write a new Style by implementing the `Styler.Style` behaviour. See its moduledoc for more. + +## Where is Sourceror? + +This work was inspired by earlier large-scale rewrites of our codebase that used the fantastic tool called [`Sourceror`](https://github.com/doorgan/sourceror/). + +The initial implementation of Styler used Sourceror, but Sourceror's AST-embedding comment algorithm causes Styler to be +too slow to use as a normal formatter. Still, we're grateful for the inspiration Sourceror provided and the changes to the +Elixir AST APIs that it drove. + +The AST-Zipper implementation in this project was derived mostly from Sourceror's implementation. diff --git a/lib/mix/tasks/style.ex b/lib/mix/tasks/style.ex new file mode 100644 index 00000000..2071d503 --- /dev/null +++ b/lib/mix/tasks/style.ex @@ -0,0 +1,121 @@ +# 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 Mix.Tasks.Style do + @shortdoc "Rewrites & formats your code so you don't get as mad at Credo" + @moduledoc """ + Formats and rewrites the given files and patterns. + + mix style mix.exs "lib/**/*.{ex,exs}" "test/**/*.{ex,exs}" + + `mix style` uses the same options as `mix format` specified in `.formatter.exs` to + format the code, and to determine which files to style if you don't pass any as arguments + + ## Task-specific options + + * `--check-formatted` - an alias for `--check-styled`, included for compatibility with `mix format` + + * `--check-styled` - checks that the file is already styled rather than styling it. + useful for CI. + """ + + use Mix.Task + + alias Styler.Style + alias Styler.StyleError + alias Styler.Zipper + + @styles [ + Styler.Style.Aliases, + Styler.Style.Pipes + ] + + @impl Mix.Task + def run(args) do + for style <- @styles, do: Code.ensure_loaded!(style) + + # we take `check_formatted` so we can easily replace `mix format` + {opts, files} = OptionParser.parse!(args, strict: [check_styled: :boolean, check_formatted: :boolean]) + check_styled? = opts[:check_styled] || opts[:check_formatted] || false + + {_, formatter_opts} = Mix.Tasks.Format.formatter_for_file("mix.exs") + + files = + if Enum.empty?(files) do + case Keyword.fetch(formatter_opts, :inputs) do + :error -> Mix.raise("you must pass file arguments or run `mix style` from the project's root directory") + {:ok, inputs} -> inputs + end + else + files + end + + files + |> Stream.flat_map(&(&1 |> Path.expand() |> Path.wildcard(match_dot: true))) + |> Task.async_stream(&style_file(&1, formatter_opts, check_styled?), + ordered: false, + timeout: :timer.seconds(30) + ) + |> Enum.reduce({[], []}, fn + {:ok, :ok}, acc -> acc + {:ok, {:exit, exit}}, {exits, not_styled} -> {[exit | exits], not_styled} + {:ok, {:not_styled, file}}, {exits, not_styled} -> {exits, [file | not_styled]} + end) + |> check!() + end + + defp check!({[], []}) do + :ok + end + + defp check!({[{file, exception, stacktrace} | _], _not_styled}) do + Mix.shell().error("mix style failed for file: #{Path.relative_to_cwd(file)}") + reraise exception, stacktrace + end + + defp check!({_exits, [_ | _] = not_styled}) do + Mix.raise(""" + mix style failed due to --check-styled. + The following files are not styled: + #{Enum.join(not_styled, "\n")} + """) + end + + defp style_file(file, formatter_opts, check_styled?) do + input = String.trim(File.read!(file)) + {ast, comments} = Styler.string_to_quoted_with_comments(input) + zipper = Zipper.zip(ast) + + output = + @styles + |> Enum.reduce(zipper, fn style, zipper -> + traverser = Style.wrap_run(style) + + try do + Zipper.traverse_while(zipper, traverser) + rescue + exception -> + reraise StyleError, [exception: exception, style: style, file: file], __STACKTRACE__ + end + end) + |> Zipper.root() + |> Styler.quoted_to_string(comments, formatter_opts) + + changed? = input != output + + cond do + check_styled? and changed? -> {:not_styled, file} + changed? -> File.write!(file, [output, "\n"]) + true -> :ok + end + rescue + exception -> {:exit, {file, exception, __STACKTRACE__}} + end +end diff --git a/lib/style.ex b/lib/style.ex new file mode 100644 index 00000000..3fc5580f --- /dev/null +++ b/lib/style.ex @@ -0,0 +1,42 @@ +# 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 command :: :cont | :skip | :halt + + @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()) :: Zipper.zipper() | {command(), Zipper.zipper()} + + @doc false + # this lets Styles optionally implement as though they're running inside of `Zipper.traverse` + # or `Zipper.traverse_while` for finer-grained control + def wrap_run(style) do + fn zipper -> + case style.run(zipper) do + {next, {_, _} = _zipper} = command when next in ~w(cont halt skip)a -> command + zipper -> {:cont, zipper} + end + end + end +end diff --git a/lib/style/aliases.ex b/lib/style/aliases.ex new file mode 100644 index 00000000..4db0b383 --- /dev/null +++ b/lib/style/aliases.ex @@ -0,0 +1,98 @@ +# 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.Aliases do + @moduledoc """ + Styles up aliases! + + This Style will expand multi-aliases and sort aliases within their groups. + It also adds a newline after all alias groups. + + Rewrites for the following Credo rules: + + * `Credo.Check.Readability.AliasOrder` + * `Credo.Check.Readability.MultiAlias` + * `Credo.Check.Readability.UnnecessaryAliasExpansion` + + This module is more particular than credo for sorting. Notably, it sorts `alias __MODULE__`, whereas Credo allowed + that alias intermixed anywhere in a group. + """ + + @behaviour Styler.Style + + alias Styler.Zipper + + def run({{:alias, _, _}, _} = zipper) do + {zipper, aliases} = + zipper + |> Zipper.insert_left(:placeholder) + |> consume_alias_group([]) + + [first | rest] = + aliases + # Credo does case-agnostic sorting, so we have to match that here + |> Enum.map(&{&1, &1 |> Macro.to_string() |> String.downcase()}) + # a splash of deduping for happiness + |> Enum.uniq_by(&elem(&1, 1)) + |> Enum.sort_by(&elem(&1, 1)) + |> Enum.map(&(&1 |> elem(0) |> set_newlines(1))) + + zipper = + zipper + |> Zipper.find(:prev, &(&1 == :placeholder)) + |> Zipper.replace(first) + + rest + |> Enum.reduce(zipper, &(&2 |> Zipper.insert_right(&1) |> Zipper.right())) + |> Zipper.update(&set_newlines(&1, 2)) + end + + def run(zipper), do: zipper + + defp set_newlines({node, meta, children}, newline) do + meta = Keyword.update(meta, :end_of_expression, [newlines: newline], &Keyword.put(&1, :newlines, newline)) + {node, meta, children} + end + + defp consume_alias_group({{:alias, meta, _} = alias, _} = zipper, aliases) do + aliases = expand_alias(alias, aliases) + zipper = Zipper.remove(zipper) + + # multiple newlines means this isn't a group. missing newline means EOF. + # thus only when there's one newline do we continue accumulating for our alias group + if meta[:end_of_expression][:newlines] == 1 do + consume_alias_group(zipper, aliases) + else + {zipper, aliases} + end + end + + defp consume_alias_group(zipper, aliases) do + # aliases in groups are always siblings, so we're using `Zipper.right` to save time from going down subtrees + case Zipper.right(zipper) do + nil -> {zipper, aliases} + zipper -> consume_alias_group(zipper, aliases) + end + end + + # This is where multi alias expansion happens + # + # alias Foo.{Bar, Baz} + # => + # alias Foo.Bar + # alias Foo.Baz + defp expand_alias({:alias, _, [{{:., _, [{_, _, module}, :{}]}, _, right}]}, aliases) do + right + |> Enum.map(fn {_, meta, segments} -> {:alias, meta, [{:__aliases__, [], module ++ segments}]} end) + |> Enum.concat(aliases) + end + + defp expand_alias({:alias, _, _} = alias, aliases), do: [alias | aliases] +end diff --git a/lib/style/defs.ex b/lib/style/defs.ex new file mode 100644 index 00000000..6a4b9d0f --- /dev/null +++ b/lib/style/defs.ex @@ -0,0 +1,123 @@ +# 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.Defs do + @moduledoc """ + NOT ENABLED + Currently has a bug where it puts comments into bad places no matter what, since it's + always rewriting every head. It's been run on our codebase once though... + + -------------------------------------- + + Styles function heads so that they're as small as possible. + + The goal is that a function head fits on a single line. + + This isn't a Credo issue, and the formatter is fine with either approach. But Styler has opinions! + + Ex: + + This long declaration + + def foo(%{ + bar: baz + }) do + ... + end + + Becomes + + def foo(%{bar: baz}) do + ... + end + """ + + @behaviour Styler.Style + + alias Styler.Zipper + + # a def with no body like + # + # def example(foo, bar \\ nil) + # + def run({{def, meta, [head]}, _} = zipper) when def in [:def, :defp] do + # There won't be any defs deeper in here, so lets skip ahead if we can + {:skip, Zipper.replace(zipper, {def, meta, [flatten_head(head, meta[:line])]})} + end + + # all the other kinds of defs! + def run({{def, def_meta, [head, body]}, _} = zipper) when def in [:def, :defp] do + def_start_line = def_meta[:line] + # order matters here! the end of the def is where the `do`s line is - but only if there's a do end block. + # otherwise it's just where the end of the (def) expression is. + def_end_line = (def_meta[:do] || def_meta[:end_of_expression])[:line] + + if def_start_line == def_end_line do + {:skip, zipper} + else + head = flatten_head(head, def_start_line) + + {def_meta, body_meta_rewriter} = + if def_meta[:do] do + # we're in a `def do ... end` + delta = def_end_line - def_start_line + up_by_delta = &(&1 - delta) + + # this is what does the shrinking of the `def ... do` stanza + def_meta = + def_meta + |> Keyword.replace_lazy(:do, &Keyword.put(&1, :line, def_start_line)) + |> Keyword.replace_lazy(:end, &Keyword.update!(&1, :line, up_by_delta)) + + # move all body line #s up by the amount we squished the head by + {def_meta, collapse_lines(up_by_delta)} + else + # we're in a `def, do:` + to_same_line = fn _ -> def_start_line end + {def_meta, collapse_lines(to_same_line)} + end + + body = update_all_meta(body, body_meta_rewriter) + + # There won't be any defs deeper in here, so lets skip ahead if we can + {:skip, Zipper.replace(zipper, {def, def_meta, [head, body]})} + end + end + + def run(zipper), do: zipper + + defp collapse_lines(line_mover) do + fn meta -> + meta + |> Keyword.replace_lazy(:line, line_mover) + |> Keyword.replace_lazy(:closing, &Keyword.replace_lazy(&1, :line, line_mover)) + end + end + + defp flatten_head(head, line) do + update_all_meta(head, fn meta -> + meta + |> Keyword.replace(:line, line) + |> Keyword.replace(:closing, line: line) + |> Keyword.replace(:last, line: line) + |> Keyword.delete(:newlines) + end) + end + + defp update_all_meta(node, meta_fun) do + node + |> Zipper.zip() + |> Zipper.traverse(fn + {{node, meta, children}, _} = zipper -> Zipper.replace(zipper, {node, meta_fun.(meta), children}) + zipper -> zipper + end) + |> Zipper.root() + end +end diff --git a/lib/style/pipes.ex b/lib/style/pipes.ex new file mode 100644 index 00000000..be48ae33 --- /dev/null +++ b/lib/style/pipes.ex @@ -0,0 +1,196 @@ +# 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.Pipes do + @moduledoc """ + Styles pipes! In particular, don't make pipe chains of only one pipe, and some persnickety pipe chain start stuff. + + Rewrites for the following Credo rules: + + * Credo.Check.Readability.BlockPipe + * Credo.Check.Readability.SinglePipe + * Credo.Check.Refactor.PipeChainStart, excluded_functions: ["from"] + """ + + @behaviour Styler.Style + + alias Styler.Zipper + + @blocks ~w(case if with cond for)a + + # we're in a multi-pipe, so only need to fix pipe_start + def run({{:|>, _, [{:|>, _, _} | _]}, _} = zipper), do: zipper |> check_start() |> Zipper.next() + # this is a single pipe, since valid pipelines are consumed by the previous head + def run({{:|>, meta, [lhs, {fun, _, args}]}, _} = zipper) do + if valid_pipe_start?(lhs) do + # `a |> f(b, c)` => `f(a, b, c)` + Zipper.replace(zipper, {fun, meta, [lhs | args]}) + else + zipper = fix_start(zipper) + {maybe_block, _, _} = lhs + + if maybe_block in @blocks do + # extracting a block means this is now `if_result |> single_pipe(a, b)` + # recursing will give us `single_pipe(if_result, a, b)` + run(zipper) + else + # fixing the start when it was a function call added another pipe to the chain, and so it's no longer + # a single pipe + zipper + end + end + end + + def run(zipper), do: zipper + + # walking down a pipeline. + # for reference, `a |> b() |> c()` is encoded `{:|>, [{:|>, _, [a, b]}, c]}` + # that is, the outermost ast is the last step of the chain, and the innermost pipe is the first step of the chain + defp check_start({{:|>, _, [{:|>, _, _} | _]}, _} = zipper), do: zipper |> Zipper.next() |> check_start() + # we found the pipe starting expression! + defp check_start({{:|>, _, [lhs, _]}, _} = zip), do: if(valid_pipe_start?(lhs), do: zip, else: fix_start(zip)) + defp check_start(zipper), do: zipper + + # this rewrites pipes that begin with blocks to save the result of the block expression into its own (non-hygienic!) + # variable, and then use that variable as the start of the pipe. the variable is named after the type of block: + # `case_result` or `if_result` + # + # before: + # + # case ... do + # ... + # end + # |> a() + # |> b() + # + # after: + # + # case_result = + # case ... do + # ... + # end + # + # case_result + # |> a() + # |> b() + defp fix_start({{:|>, pipe_meta, [{block, _, _} = expression, rhs]}, _} = zipper) when block in @blocks do + # credo:disable-for-next-line Credo.Check.Warning.UnsafeToAtom + variable = {:"#{block}_result", [], nil} + + zipper + |> Zipper.replace({:|>, pipe_meta, [variable, rhs]}) + |> find_valid_assignment_location() + |> Zipper.insert_left({:=, [], [variable, expression]}) + end + + # this rewrites other invalid pipe starts: `Module.foo(...) |> ...` and `foo(...) |> ....` + defp fix_start({{:|>, pipe_meta, [lhs, rhs]}, _} = zipper) do + lhs_rewrite = + case lhs do + # `Module.foo(a, ...)` => `a |> Module.foo(...)` + {{:., dot_meta, dot_args}, args_meta, [arg | args]} -> + {:|>, args_meta, [arg, {{:., [], dot_args}, dot_meta, args}]} + + # `foo(a, ...)` => `a |> foo(...)` + {atom, meta, [arg | args]} -> + {:|>, [], [arg, {atom, meta, args}]} + end + + zipper |> Zipper.replace({:|>, pipe_meta, [lhs_rewrite, rhs]}) |> Zipper.next() + end + + # this really needs a better name. + # essentially what we're doing is walking up the tree in search of a parent where it would be syntactically valid + # to insert a new "assignment" node (`x = y`) + # as we walk up the tree, our parent will be either + # 1. an invalid node for an assignment (still in the pipeline or in another assignment) + # 2. the start of the context (function def start) + # 3. something else! + # for 1, we keep going up + # for 2, we wrap ourselves in a new block parent (where we can insert a sibling node) + # for 3, we're done - wherever it is we are, our parent already supports us inserting a sibling node + defp find_valid_assignment_location(zipper) do + case Zipper.up(zipper) do + # still trying to find our way up the pipe, keep walking... + {{:|>, _, _}, _} = parent -> find_valid_assignment_location(parent) + # the parent of this pipe is an assignment like + # + # baz = + # block do ... end + # |> ... + # + # so we need to step up again and see what the assignment's parent is, with the goal of inserting our new + # assignment before the assignment built from the pipe chain, like: + # + # block_result = block do ... end + # baz = + # block_result + # |> ... + {{:=, _, _}, _} = parent -> find_valid_assignment_location(parent) + # we're in a function which is an immediate pipeline, like: + # + # def fun do + # block do end + # |> f() + # end + {{{:__block__, _, _}, {:|>, _, _}}, _} -> wrap_in_block(zipper) + # similar to the function definition, except it's an anonymous function this time + # + # fn -> + # case do end + # |> b() + # end + {{:->, _, [_, {:|>, _, _} | _]}, _} -> wrap_in_block(zipper) + # a snippet or script where the problem block has no parent + nil -> wrap_in_block(zipper) + # since its parent isn't one of the problem AST above, the current zipper must be a valid place to insert the node + _ -> zipper + end + end + + # give it a block parent, then step back to the pipe - we can insert next to it now that it's in a block + defp wrap_in_block({node, _} = zipper) do + zipper + |> Zipper.replace({:__block__, [], [node]}) + |> Zipper.next() + end + + # literal wrapper + defp valid_pipe_start?({:__block__, _, _}), do: true + defp valid_pipe_start?({:__aliases__, _, _}), do: true + defp valid_pipe_start?({:unquote, _, _}), do: true + # ecto + defp valid_pipe_start?({:from, _, _}), do: true + # most of these values were lifted directly from credo's pipe_chain_start.ex + @value_constructors ~w(% %{} .. <<>> @ {} & fn)a + @simple_operators ~w(++ -- && ||)a + @math_operators ~w(- * + / > < <= >=)a + @binary_operators ~w(<> <- ||| &&& <<< >>> <<~ ~>> <~ ~> <~> <|> ^^^ ~~~)a + defp valid_pipe_start?({op, _, _}) + when op in @value_constructors or op in @simple_operators or op in @math_operators or op in @binary_operators, + do: true + + # variable + defp valid_pipe_start?({atom, _, nil}) when is_atom(atom), do: true + # 0-arity function_call() + defp valid_pipe_start?({atom, _, []}) when is_atom(atom), do: true + # function_call(with, args) or sigils. sigils are allowed, function w/ args is not + defp valid_pipe_start?({atom, _, [_ | _]}) when is_atom(atom), do: String.match?("#{atom}", ~r/^sigil_[a-zA-Z]$/) + # map[:access] + defp valid_pipe_start?({{:., _, [Access, :get]}, _, _}), do: true + # Module.function_call() + defp valid_pipe_start?({{:., _, _}, _, []}), do: true + # '__#{val}__' are compiled to List.to_charlist("__#{val}__") + # we want to consider these charlists a valid pipe chain start + defp valid_pipe_start?({{:., _, [List, :to_charlist]}, _, [[_ | _]]}), do: true + # Module.function_call(with, parameters) + defp valid_pipe_start?({{:., _, _}, _, _}), do: false + defp valid_pipe_start?(_), do: true +end diff --git a/lib/style_error.ex b/lib/style_error.ex new file mode 100644 index 00000000..1c2bcd36 --- /dev/null +++ b/lib/style_error.ex @@ -0,0 +1,20 @@ +# 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.StyleError do + @moduledoc """ + Wraps errors raised by Styles during tree traversal. + """ + defexception [:exception, :style, :file] + + def message(%{exception: exception, style: style, file: file}) do + "Error running style #{style} on #{file}\n#{Exception.format_banner(:error, exception)}" + end +end diff --git a/lib/styler.ex b/lib/styler.ex new file mode 100644 index 00000000..d30f584a --- /dev/null +++ b/lib/styler.ex @@ -0,0 +1,40 @@ +# 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 do + @moduledoc false + + @doc """ + Wraps `Code.string_to_quoted_with_comments` with our desired options + """ + def string_to_quoted_with_comments(code) when is_binary(code) do + Code.string_to_quoted_with_comments!(code, + literal_encoder: &__MODULE__.literal_encoder/2, + token_metadata: true, + unescape: false + ) + end + + @doc false + def literal_encoder(a, b), do: {:ok, {:__block__, b, [a]}} + + @doc """ + Turns an ast and comments back into code, formatting it along the way. + """ + def quoted_to_string(ast, comments, formatter_opts \\ []) do + opts = [{:comments, comments}, {:escape, false} | formatter_opts] + {line_length, opts} = Keyword.pop(opts, :line_length, 122) + + ast + |> Code.quoted_to_algebra(opts) + |> Inspect.Algebra.format(line_length) + |> IO.iodata_to_binary() + end +end diff --git a/lib/zipper.ex b/lib/zipper.ex new file mode 100644 index 00000000..6819b756 --- /dev/null +++ b/lib/zipper.ex @@ -0,0 +1,409 @@ +# 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.Zipper do + @moduledoc """ + Branched from https://github.com/doorgan/sourceror/blob/main/lib/sourceror/zipper.ex, + see this issue for context on branching: https://github.com/doorgan/sourceror/issues/67 + + Implements a Zipper for the Elixir AST based on GĂ©rard Huet [Functional pearl: the + zipper](https://www.st.cs.uni-saarland.de/edu/seminare/2005/advanced-fp/docs/huet-zipper.pdf) paper and + Clojure's `clojure.zip` API. + + A zipper is a data structure that represents a location in a tree from the + perspective of the current node, also called *focus*. It is represented by a + 2-tuple where the first element is the focus and the second element is the + metadata/context. The metadata is `nil` when the focus is the topmost node + """ + + # Remove once we figure out why these functions cause a "pattern can never + # match" error: + # + # The pattern can never match the type. + # + # Pattern: _child = {_, _} + # + # Type: nil + @dialyzer {:nowarn_function, do_prev: 1} + + import Kernel, except: [node: 1] + + @type tree :: Macro.t() + + @opaque path :: %{ + l: [tree], + ptree: zipper, + r: [tree] + } + + @type zipper :: {tree, path | nil} + + @doc """ + Returns true if the node is a branch. + """ + @spec branch?(tree) :: boolean + def branch?({_, _, args}) when is_list(args), do: true + def branch?({_, _}), do: true + def branch?(list) when is_list(list), do: true + def branch?(_), do: false + + @doc """ + Returns a list of children of the node. + """ + @spec children(tree) :: [tree] + def children({form, _, args}) when is_atom(form) and is_list(args), do: args + def children({form, _, args}) when is_list(args), do: [form | args] + def children({left, right}), do: [left, right] + def children(list) when is_list(list), do: list + def children(_), do: [] + + @doc """ + Returns a new branch node, given an existing node and new children. + """ + @spec make_node(tree, [tree]) :: tree + def make_node({form, meta, _}, args) when is_atom(form), do: {form, meta, args} + def make_node({_form, meta, args}, [first | rest]) when is_list(args), do: {first, meta, rest} + def make_node({_, _}, [left, right]), do: {left, right} + def make_node({_, _}, args), do: {:{}, [], args} + def make_node(list, children) when is_list(list), do: children + + @doc """ + Creates a zipper from a tree node. + """ + @spec zip(tree) :: zipper + def zip(term), do: {term, nil} + + @doc """ + Walks the zipper all the way up and returns the top zipper. + """ + @spec top(zipper) :: zipper + def top({_, nil} = zipper), do: zipper + def top(zipper), do: zipper |> up() |> top() + + @doc """ + Walks the zipper all the way up and returns the root node. + """ + @spec root(zipper) :: tree + def root(zipper), do: zipper |> top() |> node() + + @doc """ + Returns the node at the zipper. + """ + @spec node(zipper) :: tree + def node({tree, _}), do: tree + + @doc """ + Returns the zipper of the leftmost child of the node at this zipper, or + nil if no there's no children. + """ + @spec down(zipper) :: zipper | nil + def down({tree, meta}) do + case children(tree) do + [] -> nil + [only_child] -> {only_child, %{ptree: {tree, meta}, l: nil, r: nil}} + [first | rest] -> {first, %{ptree: {tree, meta}, l: nil, r: rest}} + end + end + + @doc """ + Returns the zipper of the parent of the node at this zipper, or nil if at the + top. + """ + @spec up(zipper) :: zipper | nil + def up({_, nil}), do: nil + + def up({tree, meta}) do + children = Enum.reverse(meta.l || []) ++ [tree] ++ (meta.r || []) + {parent, parent_meta} = meta.ptree + {make_node(parent, children), parent_meta} + end + + @doc """ + Returns the zipper of the left sibling of the node at this zipper, or nil. + """ + @spec left(zipper) :: zipper | nil + def left({tree, %{l: [ltree | l], r: r} = meta}), do: {ltree, %{meta | l: l, r: [tree | r || []]}} + def left(_), do: nil + + @doc """ + Returns the leftmost sibling of the node at this zipper, or itself. + """ + @spec leftmost(zipper) :: zipper + def leftmost({tree, %{l: [_ | _] = l} = meta}) do + [left | rest] = Enum.reverse(l) + r = rest ++ [tree] ++ (meta.r || []) + {left, %{meta | l: nil, r: r}} + end + + def leftmost(zipper), do: zipper + + @doc """ + Returns the zipper of the right sibling of the node at this zipper, or nil. + """ + @spec right(zipper) :: zipper | nil + def right({tree, %{r: [rtree | r]} = meta}), do: {rtree, %{meta | r: r, l: [tree | meta.l || []]}} + def right(_), do: nil + + @doc """ + Returns the rightmost sibling of the node at this zipper, or itself. + """ + @spec rightmost(zipper) :: zipper + def rightmost({tree, %{r: [_ | _] = r} = meta}) do + [right | rest] = Enum.reverse(r) + l = rest ++ [tree] ++ (meta.l || []) + {right, %{meta | l: l, r: nil}} + end + + def rightmost(zipper), do: zipper + + @doc """ + Replaces the current node in the zipper with a new node. + """ + @spec replace(zipper, tree) :: zipper + def replace({_, meta}, tree), do: {tree, meta} + + @doc """ + Replaces the current node in the zipper with the result of applying `fun` to + the node. + """ + @spec update(zipper, (tree -> tree)) :: zipper + def update({tree, meta}, fun), do: {fun.(tree), meta} + + @doc """ + Removes the node at the zipper, returning the zipper that would have preceded + it in a depth-first walk. + """ + @spec remove(zipper) :: zipper + def remove({_, nil}), do: raise(ArgumentError, message: "Cannot remove the top level node.") + def remove({_, %{l: [left | rest]} = meta}), do: do_prev({left, %{meta | l: rest}}) + + def remove({_, meta}) do + children = meta.r || [] + {parent, parent_meta} = meta.ptree + {make_node(parent, children), parent_meta} + end + + @doc """ + Inserts the item as the left sibling of the node at this zipper, without + moving. Raises an `ArgumentError` when attempting to insert a sibling at the + top level. + """ + @spec insert_left(zipper, tree) :: zipper + def insert_left({_, nil}, _), do: raise(ArgumentError, message: "Can't insert siblings at the top level.") + def insert_left({tree, meta}, child), do: {tree, %{meta | l: [child | meta.l || []]}} + + @doc """ + Inserts the item as the right sibling of the node at this zipper, without + moving. Raises an `ArgumentError` when attempting to insert a sibling at the + top level. + """ + @spec insert_right(zipper, tree) :: zipper + def insert_right({_, nil}, _), do: raise(ArgumentError, message: "Can't insert siblings at the top level.") + def insert_right({tree, meta}, child), do: {tree, %{meta | r: [child | meta.r || []]}} + + @doc """ + Inserts the item as the leftmost child of the node at this zipper, + without moving. + """ + def insert_child({tree, meta}, child), do: {do_insert_child(tree, child), meta} + + defp do_insert_child({form, meta, args}, child) when is_list(args), do: {form, meta, [child | args]} + defp do_insert_child(list, child) when is_list(list), do: [child | list] + defp do_insert_child({left, right}, child), do: {:{}, [], [child, left, right]} + + @doc """ + Inserts the item as the rightmost child of the node at this zipper, + without moving. + """ + def append_child({tree, meta}, child), do: {do_append_child(tree, child), meta} + + defp do_append_child({form, meta, args}, child) when is_list(args), do: {form, meta, args ++ [child]} + defp do_append_child(list, child) when is_list(list), do: list ++ [child] + defp do_append_child({left, right}, child), do: {:{}, [], [left, right, child]} + + @doc """ + Returns the following zipper in depth-first pre-order. + """ + @spec next(zipper) :: zipper | nil + def next(zipper), do: down(zipper) || skip(zipper) + + @doc """ + Returns the zipper of the right sibling of the node at this zipper, or the + next zipper when no right sibling is available. + + This allows to skip subtrees while traversing the siblings of a node. + + The optional second parameters specifies the `direction`, defaults to + `:next`. + + If no right/left sibling is available, this function returns the same value as + `next/1`/`prev/1`. + + The function `skip/1` behaves like the `:skip` in `traverse_while/2` and + `traverse_while/3`. + """ + @spec skip(zipper, direction :: :next | :prev) :: zipper | nil + def skip(zipper, direction \\ :next) + def skip(zipper, :next), do: right(zipper) || next_up(zipper) + def skip(zipper, :prev), do: left(zipper) || prev_up(zipper) + + defp next_up(zipper) do + if parent = up(zipper) do + right(parent) || next_up(parent) + end + end + + defp prev_up(zipper) do + if parent = up(zipper) do + left(parent) || prev_up(parent) + end + end + + @doc """ + Returns the previous zipper in depth-first pre-order. If it's already at + the end, it returns nil. + """ + @spec prev(zipper) :: zipper | nil + def prev(zipper) do + if left = left(zipper), + do: do_prev(left), + else: up(zipper) + end + + defp do_prev(zipper) do + if down = down(zipper), + do: down |> rightmost() |> do_prev(), + else: zipper + end + + @doc """ + Traverses the tree in depth-first pre-order calling the given function for + each node. + + If the zipper is not at the top, just the subtree will be traversed. + + The function must return a zipper. + """ + @spec traverse(zipper, (zipper -> zipper)) :: zipper + def traverse({_tree, nil} = zipper, fun) do + do_traverse(zipper, fun) + end + + def traverse({tree, meta}, fun) do + {updated, _meta} = do_traverse({tree, nil}, fun) + {updated, meta} + end + + defp do_traverse(zipper, fun) do + zipper = fun.(zipper) + if next = next(zipper), do: do_traverse(next, fun), else: top(zipper) + end + + @doc """ + Traverses the tree in depth-first pre-order calling the given function for + each node with an accumulator. + + If the zipper is not at the top, just the subtree will be traversed. + """ + @spec traverse(zipper, term, (zipper, term -> {zipper, term})) :: {zipper, term} + def traverse({_tree, nil} = zipper, acc, fun) do + do_traverse(zipper, acc, fun) + end + + def traverse({tree, meta}, acc, fun) do + {{updated, _meta}, acc} = do_traverse({tree, nil}, acc, fun) + {{updated, meta}, acc} + end + + defp do_traverse(zipper, acc, fun) do + {zipper, acc} = fun.(zipper, acc) + if next = next(zipper), do: do_traverse(next, acc, fun), else: {top(zipper), acc} + end + + @doc """ + Traverses the tree in depth-first pre-order calling the given function for + each node. + + The traversing will continue if the function returns `{:cont, zipper}`, + skipped for `{:skip, zipper}` and halted for `{:halt, zipper}` + + If the zipper is not at the top, just the subtree will be traversed. + + The function must return a zipper. + """ + @spec traverse_while(zipper, (zipper -> {:cont, zipper} | {:halt, zipper} | {:skip, zipper})) :: zipper + def traverse_while({_tree, nil} = zipper, fun) do + do_traverse_while(zipper, fun) + end + + def traverse_while({tree, meta}, fun) do + {updated, _meta} = do_traverse({tree, nil}, fun) + {updated, meta} + end + + defp do_traverse_while(zipper, fun) do + case fun.(zipper) do + {:cont, zipper} -> if next = next(zipper), do: do_traverse_while(next, fun), else: top(zipper) + {:skip, zipper} -> if skipped = skip(zipper), do: do_traverse_while(skipped, fun), else: top(zipper) + {:halt, zipper} -> top(zipper) + end + end + + @doc """ + Traverses the tree in depth-first pre-order calling the given function for + each node with an accumulator. + + The traversing will continue if the function returns `{:cont, zipper, acc}`, + skipped for `{:skip, zipper, acc}` and halted for `{:halt, zipper, acc}` + + If the zipper is not at the top, just the subtree will be traversed. + """ + @spec traverse_while( + zipper, + term, + (zipper, term -> {:cont, zipper, term} | {:halt, zipper, term} | {:skip, zipper, term}) + ) :: {zipper, term} + def traverse_while({_tree, nil} = zipper, acc, fun) do + do_traverse_while(zipper, acc, fun) + end + + def traverse_while({tree, meta}, acc, fun) do + {{updated, _meta}, acc} = do_traverse({tree, nil}, acc, fun) + {{updated, meta}, acc} + end + + defp do_traverse_while(zipper, acc, fun) do + case fun.(zipper, acc) do + {:cont, zipper, acc} -> if next = next(zipper), do: do_traverse_while(next, acc, fun), else: {top(zipper), acc} + {:skip, zipper, acc} -> if skip = skip(zipper), do: do_traverse_while(skip, acc, fun), else: {top(zipper), acc} + {:halt, zipper, acc} -> {top(zipper), acc} + end + end + + @doc """ + Returns a zipper to the node that satisfies the predicate function, or `nil` + if none is found. + + The optional second parameters specifies the `direction`, defaults to + `:next`. + """ + @spec find(zipper, direction :: :prev | :next, predicate :: (tree -> any)) :: zipper | nil + def find(zipper, direction \\ :next, predicate) + def find(nil, _direction, _predicate), do: nil + + def find({tree, _} = zipper, direction, predicate) when direction in [:next, :prev] and is_function(predicate) do + if predicate.(tree) do + zipper + else + zipper = if direction == :next, do: next(zipper), else: prev(zipper) + zipper && find(zipper, direction, predicate) + end + end +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 00000000..d7939e83 --- /dev/null +++ b/mix.exs @@ -0,0 +1,59 @@ +# 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.MixProject do + use Mix.Project + + @version "0.1.0" + @url "https://github.com/adobe/elixir-styler" + + def project do + [ + app: :styler, + version: @version, + elixir: "~> 1.14", + start_permanent: Mix.env() == :prod, + elixirc_paths: elixirc_paths(Mix.env()), + deps: deps(), + + ## Hex + package: package(), + description: "A code-style enforcer that will just FIFY instead of complaining", + + # Docs + name: "Styler", + docs: docs() + ] + end + + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + + def application, do: [extra_applications: [:logger]] + + defp deps, do: [] + + defp package do + [ + maintainers: ["Matt Enlow", "Greg Mefford"], + licenses: ["Apache-2.0"], + links: %{"GitHub" => @url} + ] + end + + defp docs do + [ + main: "Styler", + source_ref: "v#{@version}", + source_url: @url, + extras: ["CHANGELOG.md": [title: "Changelog"]] + ] + end +end diff --git a/test/style/aliases_test.exs b/test/style/aliases_test.exs new file mode 100644 index 00000000..889f5930 --- /dev/null +++ b/test/style/aliases_test.exs @@ -0,0 +1,51 @@ +# 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.AliasesTest do + use Styler.StyleCase, style: Styler.Style.Aliases, async: true + + describe "run/1" do + test "sorts, dedupes & expands aliases while respecting groups" do + assert_style( + """ + alias D + alias A.{B} + alias A.{ + A, + B, + C + } + alias A.B + + alias B + alias A + """, + """ + alias A.A + alias A.B + alias A.C + alias D + + alias A + alias B + """ + ) + end + + test "respects as" do + assert_style(""" + alias Foo.Asset + alias Foo.Project.Loaders, as: ProjectLoaders + alias Foo.ProjectDevice.Loaders, as: ProjectDeviceLoaders + alias Foo.User.Loaders + """) + end + end +end diff --git a/test/style/defs_test.exs b/test/style/defs_test.exs new file mode 100644 index 00000000..54f7a667 --- /dev/null +++ b/test/style/defs_test.exs @@ -0,0 +1,149 @@ +# 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.DefsTest do + use Styler.StyleCase, style: Styler.Style.Defs, async: true + + describe "run" do + test "function with do keyword" do + assert_style( + """ + def save( + %Socket{assigns: %{user: user, live_action: :new}} = initial_socket, + params + ), + do: :ok + """, + """ + def save(%Socket{assigns: %{user: user, live_action: :new}} = initial_socket, params), do: :ok + """ + ) + end + + test "bodyless function with spec" do + assert_style(""" + @spec original_object(atom()) :: atom() + def original_object(object) + """) + end + + test "block function body doesn't get newlined" do + assert_style(""" + def some_function(%{id: id, type: type, processed_at: processed_at} = file, params, _) + when type == :file and is_nil(processed_at) do + with {:ok, results} <- FileProcessor.process(file) do + {:ok, post_process_the_results_somehow(results)} + end + end + """) + end + + test "kwl function body doesn't get newlined" do + assert_style(""" + def is_expired_timestamp?(timestamp) when is_integer(timestamp), + do: Timex.from_unix(timestamp, :second) <= Timex.shift(DateTime.utc_now(), minutes: 1) + """) + end + + test "function with do block" do + assert_style( + """ + def save( + %Socket{assigns: %{user: user, live_action: :new}} = initial_socket, + params + ) do + :ok + end + """, + """ + def save(%Socket{assigns: %{user: user, live_action: :new}} = initial_socket, params) do + :ok + end + """ + ) + end + + test "no body" do + assert_style( + """ + def no_body( + foo, + bar + ) + + def no_body(nil, _), do: nil + """, + """ + def no_body(foo, bar) + + def no_body(nil, _), do: nil + """ + ) + end + + test "when clause w kwl do" do + assert_style( + """ + def foo(%{ + bar: baz + }) + when baz in [ + :a, + :b + ], + do: :never_write_code_like_this + """, + """ + def foo(%{bar: baz}) when baz in [:a, :b], do: :never_write_code_like_this + """ + ) + end + + test "rewrites subsequent definitions" do + assert_style( + """ + def foo(), do: :ok + + def foo( + too, + long + ), do: :ok + """, + """ + def foo(), do: :ok + + def foo(too, long), do: :ok + """ + ) + end + + test "when clause with block do" do + assert_style( + """ + def foo(%{ + bar: baz + }) + when baz in [ + :a, + :b + ] + do + :never_write_code_like_this + end + """, + """ + def foo(%{bar: baz}) when baz in [:a, :b] do + :never_write_code_like_this + end + """ + ) + end + end +end diff --git a/test/style/pipes_test.exs b/test/style/pipes_test.exs new file mode 100644 index 00000000..36b3bf7d --- /dev/null +++ b/test/style/pipes_test.exs @@ -0,0 +1,219 @@ +# 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.PipesTest do + use Styler.StyleCase, style: Styler.Style.Pipes, async: true + + describe "block starts" do + test "rewrites fors" do + assert_style( + """ + for(a <- as, do: a) + |> bar() + |> baz() + """, + """ + for_result = for(a <- as, do: a) + + for_result + |> bar() + |> baz() + """ + ) + end + + test "rewrites blocks" do + assert_style( + """ + with({:ok, value} <- foo(), do: value) + |> bar() + |> baz() + """, + """ + with_result = with({:ok, value} <- foo(), do: value) + + with_result + |> bar() + |> baz() + """ + ) + end + + test "rewrites conds" do + assert_style( + """ + cond do + x -> :ok + true -> :error + end + |> bar() + |> baz() + """, + """ + cond_result = + cond do + x -> :ok + true -> :error + end + + cond_result + |> bar() + |> baz() + """ + ) + end + + test "rewrites case at root" do + assert_style( + """ + case x do + x -> x + end + |> foo() + """, + """ + case_result = + case x do + x -> x + end + + foo(case_result) + """ + ) + end + + test "single pipe of case w/ parent" do + assert_style( + """ + def foo do + case x do + x -> x + end + |> foo() + end + """, + """ + def foo do + case_result = + case x do + x -> x + end + + foo(case_result) + end + """ + ) + end + end + + describe "run on single pipe + start issues" do + test "anon functio is finen" do + assert_style(""" + fn + :ok -> :ok + :error -> :error + end + |> b() + |> c() + """) + end + + test "handles that weird single pipe but with function call" do + assert_style("b(a) |> c()", "a |> b() |> c()") + end + + test "doesn't modify valid pipe" do + assert_style(""" + a() + |> b() + |> c() + + a |> b() |> c() + """) + end + end + + describe "run on single pipe issues" do + test "fixes single pipe" do + assert_style("a |> f()", "f(a)") + end + + test "fixes single pipe in function head" do + assert_style( + """ + def a, do: b |> c() + """, + """ + def a, do: c(b) + """ + ) + end + + test "extracts blocks successfully" do + assert_style( + """ + def foo do + if true do + nil + end + |> a() + |> b() + end + """, + """ + def foo do + if_result = + if true do + nil + end + + if_result + |> a() + |> b() + end + """ + ) + end + end + + describe "run on pipe chain start issues" do + test "allows 0-arity function calls" do + assert_style(""" + foo() + |> bar() + |> baz() + """) + end + + test "allows ecto's from" do + assert_style(""" + from(foo in Bar, where: foo.bool) + |> some_query_helper() + |> Repo.all() + """) + end + + test "extracts >0 arity functions" do + assert_style( + """ + M.f(a, b) + |> g() + |> h() + """, + """ + a + |> M.f(b) + |> g() + |> h() + """ + ) + end + end +end diff --git a/test/support/style_case.ex b/test/support/style_case.ex new file mode 100644 index 00000000..0b8380f4 --- /dev/null +++ b/test/support/style_case.ex @@ -0,0 +1,70 @@ +# 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.StyleCase do + @moduledoc """ + Helpers around testing Style rules. + """ + use ExUnit.CaseTemplate + + alias Styler.Style + alias Styler.Zipper + + using options do + style = options[:style] + unless style, do: raise(ArgumentError, "missing required `:style` option") + + quote do + @style unquote(style) + import unquote(__MODULE__), only: [assert_style: 1, assert_style: 2] + + def style(code), do: unquote(__MODULE__).style(code, @style) + end + end + + defmacro assert_style(before, expected \\ nil) do + expected = String.trim(expected || before) + + quote bind_quoted: [before: before, expected: expected] do + {styled_ast, styled} = style(before) + + if styled != expected and ExUnit.configuration()[:trace] do + IO.puts("\n======Given=============\n") + IO.puts(before) + {before_ast, _} = Styler.string_to_quoted_with_comments(before) + dbg(before_ast) + IO.puts("======Expected==========\n") + IO.puts(expected) + {expected_ast, _} = Styler.string_to_quoted_with_comments(expected) + dbg(expected_ast) + IO.puts("======Got===============\n") + IO.puts(styled) + dbg(styled_ast) + IO.puts("========================\n") + end + + assert styled == expected + end + end + + def style(code, style) do + {ast, comments} = Styler.string_to_quoted_with_comments(code) + + styled_ast = + ast + |> Zipper.zip() + |> Zipper.traverse_while(Style.wrap_run(style)) + |> Zipper.root() + + styled_code = Styler.quoted_to_string(styled_ast, comments) + + {styled_ast, styled_code} + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 00000000..bda10d41 --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1,11 @@ +# 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. + +ExUnit.start(capture_log: true, formatters: [JUnitFormatter, ExUnit.CLIFormatter]) diff --git a/test/zipper_test.exs b/test/zipper_test.exs new file mode 100644 index 00000000..d3629290 --- /dev/null +++ b/test/zipper_test.exs @@ -0,0 +1,559 @@ +# 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. + +# Branched from https://github.com/doorgan/sourceror/blob/main/test/zipper_test.exs +# See this issue for context on branching: https://github.com/doorgan/sourceror/issues/67 +defmodule StylerTest.ZipperTest do + use ExUnit.Case, async: true + + alias Styler.Zipper + + describe "zip/1" do + test "creates a zipper from a term" do + assert Zipper.zip(42) == {42, nil} + end + end + + describe "branch?/1" do + test "correctly identifies branch nodes" do + assert Zipper.branch?(42) == false + assert Zipper.branch?(:foo) == false + assert Zipper.branch?([1, 2, 3]) == true + assert Zipper.branch?({:left, :right}) == true + assert Zipper.branch?({:foo, [], []}) == true + end + end + + describe "children/1" do + test "returns the children for a node" do + assert Zipper.children([1, 2, 3]) == [1, 2, 3] + assert Zipper.children({:foo, [], [1, 2]}) == [1, 2] + + assert Zipper.children({{:., [], [:left, :right]}, [], [:arg]}) == [ + {:., [], [:left, :right]}, + :arg + ] + + assert Zipper.children({:left, :right}) == [:left, :right] + end + end + + describe "make_node/2" do + test "2-tuples" do + assert Zipper.make_node({1, 2}, [3, 4]) == {3, 4} + end + + test "changing to 2-tuples arity" do + assert Zipper.make_node({1, 2}, [3, 4, 5]) == {:{}, [], [3, 4, 5]} + assert Zipper.make_node({1, 2}, [3]) == {:{}, [], [3]} + end + + test "lists" do + assert Zipper.make_node([1, 2, 3], [:a, :b, :c]) == [:a, :b, :c] + end + + test "unqualified calls" do + assert Zipper.make_node({:foo, [], [1, 2]}, [:a, :b]) == {:foo, [], [:a, :b]} + end + + test "qualified calls" do + assert Zipper.make_node({{:., [], [1, 2]}, [], [3, 4]}, [:a, :b, :c]) == {:a, [], [:b, :c]} + end + end + + describe "node/1" do + test "returns the node for a zipper" do + assert Zipper.node(Zipper.zip(42)) == 42 + end + end + + describe "down/1" do + test "rips and tears the parent node" do + assert [1, 2] |> Zipper.zip() |> Zipper.down() == {1, %{l: nil, r: [2], ptree: {[1, 2], nil}}} + assert {1, 2} |> Zipper.zip() |> Zipper.down() == {1, %{l: nil, r: [2], ptree: {{1, 2}, nil}}} + + assert {:foo, [], [1, 2]} |> Zipper.zip() |> Zipper.down() == + {1, %{l: nil, r: [2], ptree: {{:foo, [], [1, 2]}, nil}}} + + assert {{:., [], [:a, :b]}, [], [1, 2]} |> Zipper.zip() |> Zipper.down() == + {{:., [], [:a, :b]}, %{l: nil, r: [1, 2], ptree: {{{:., [], [:a, :b]}, [], [1, 2]}, nil}}} + end + end + + describe "up/1" do + test "reconstructs the previous parent" do + assert [1, 2] |> Zipper.zip() |> Zipper.down() |> Zipper.up() == {[1, 2], nil} + assert {1, 2} |> Zipper.zip() |> Zipper.down() |> Zipper.up() == {{1, 2}, nil} + assert {:foo, [], [1, 2]} |> Zipper.zip() |> Zipper.down() |> Zipper.up() == {{:foo, [], [1, 2]}, nil} + + assert {{:., [], [:a, :b]}, [], [1, 2]} |> Zipper.zip() |> Zipper.down() |> Zipper.up() == + {{{:., [], [:a, :b]}, [], [1, 2]}, nil} + end + + test "returns nil at the top level" do + assert 42 |> Zipper.zip() |> Zipper.up() == nil + end + end + + describe "left/1 and right/1" do + test "correctly navigate horizontally" do + zipper = Zipper.zip([1, [2, 3], [[4, 5], 6]]) + + assert zipper |> Zipper.down() |> Zipper.right() |> Zipper.right() |> Zipper.node() == [[4, 5], 6] + assert zipper |> Zipper.down() |> Zipper.right() |> Zipper.right() |> Zipper.left() |> Zipper.node() == [2, 3] + end + + test "return nil at the boundaries" do + zipper = Zipper.zip([1, 2]) + + assert zipper |> Zipper.down() |> Zipper.left() == nil + assert zipper |> Zipper.down() |> Zipper.right() |> Zipper.right() == nil + end + end + + describe "rightmost/1" do + test "returns the rightmost child" do + assert [1, 2, 3, 4, 5] |> Zipper.zip() |> Zipper.down() |> Zipper.rightmost() |> Zipper.node() == 5 + end + + test "returns itself it already at the rightmost node" do + assert [1, 2, 3, 4, 5] + |> Zipper.zip() + |> Zipper.down() + |> Zipper.rightmost() + |> Zipper.rightmost() + |> Zipper.rightmost() + |> Zipper.node() == 5 + + assert [1, 2, 3] + |> Zipper.zip() + |> Zipper.rightmost() + |> Zipper.rightmost() + |> Zipper.node() == [1, 2, 3] + end + end + + describe "leftmost/1" do + test "returns the leftmost child" do + assert [1, 2, 3, 4, 5] + |> Zipper.zip() + |> Zipper.down() + |> Zipper.right() + |> Zipper.right() + |> Zipper.leftmost() + |> Zipper.node() == 1 + end + + test "returns itself it already at the leftmost node" do + assert [1, 2, 3, 4, 5] + |> Zipper.zip() + |> Zipper.down() + |> Zipper.leftmost() + |> Zipper.leftmost() + |> Zipper.leftmost() + |> Zipper.node() == 1 + + assert [1, 2, 3] + |> Zipper.zip() + |> Zipper.leftmost() + |> Zipper.leftmost() + |> Zipper.node() == [1, 2, 3] + end + end + + describe "next/1" do + test "walks forward in depth-first pre-order" do + zipper = Zipper.zip([1, [2, [3, 4]], 5]) + + assert zipper |> Zipper.next() |> Zipper.next() |> Zipper.next() |> Zipper.next() |> Zipper.node() == [3, 4] + + assert zipper + |> Zipper.next() + |> Zipper.next() + |> Zipper.next() + |> Zipper.next() + |> Zipper.next() + |> Zipper.next() + |> Zipper.next() + |> Zipper.node() == 5 + end + + test "returns nil after exhausting the tree" do + zipper = Zipper.zip([1, [2, [3, 4]], 5]) + + refute zipper + |> Zipper.next() + |> Zipper.next() + |> Zipper.next() + |> Zipper.next() + |> Zipper.next() + |> Zipper.next() + |> Zipper.next() + |> Zipper.next() + + refute Zipper.next({42, nil}) + end + end + + describe "prev/1" do + test "walks backwards in depth-first pre-order" do + zipper = Zipper.zip([1, [2, [3, 4]], 5]) + + assert zipper + |> Zipper.next() + |> Zipper.next() + |> Zipper.next() + |> Zipper.next() + |> Zipper.next() + |> Zipper.next() + |> Zipper.next() + |> Zipper.prev() + |> Zipper.prev() + |> Zipper.prev() + |> Zipper.node() == [3, 4] + end + + test "returns nil when it reaches past the top" do + zipper = Zipper.zip([1, [2, [3, 4]], 5]) + + assert zipper + |> Zipper.next() + |> Zipper.next() + |> Zipper.next() + |> Zipper.prev() + |> Zipper.prev() + |> Zipper.prev() + |> Zipper.prev() == nil + end + end + + describe "skip/2" do + test "returns a zipper to the next sibling while skipping subtrees" do + zipper = + Zipper.zip([ + {:foo, [], [1, 2, 3]}, + {:bar, [], [1, 2, 3]}, + {:baz, [], [1, 2, 3]} + ]) + + zipper = Zipper.down(zipper) + + assert Zipper.node(zipper) == {:foo, [], [1, 2, 3]} + assert zipper |> Zipper.skip() |> Zipper.node() == {:bar, [], [1, 2, 3]} + assert zipper |> Zipper.skip(:next) |> Zipper.node() == {:bar, [], [1, 2, 3]} + assert zipper |> Zipper.skip() |> Zipper.skip(:prev) |> Zipper.node() == {:foo, [], [1, 2, 3]} + end + + test "returns nil if no previous sibling is available" do + zipper = + Zipper.zip([ + {:foo, [], [1, 2, 3]} + ]) + + zipper = Zipper.down(zipper) + + assert Zipper.skip(zipper, :prev) == nil + assert [7] |> Zipper.zip() |> Zipper.skip(:prev) == nil + end + + test "returns nil if no next sibling is available" do + zipper = + Zipper.zip([ + {:foo, [], [1, 2, 3]} + ]) + + zipper = Zipper.down(zipper) + + refute Zipper.skip(zipper) + end + end + + describe "traverse/2" do + test "traverses in depth-first pre-order" do + zipper = Zipper.zip([1, [2, [3, 4], 5], [6, 7]]) + + assert zipper + |> Zipper.traverse(fn + {x, m} when is_integer(x) -> {x * 2, m} + z -> z + end) + |> Zipper.node() == [2, [4, [6, 8], 10], [12, 14]] + end + + test "traverses a subtree in depth-first pre-order" do + zipper = Zipper.zip([1, [2, [3, 4], 5], [6, 7]]) + + assert zipper + |> Zipper.down() + |> Zipper.right() + |> Zipper.traverse(fn + {x, m} when is_integer(x) -> {x + 10, m} + z -> z + end) + |> Zipper.root() == [1, [12, [13, 14], 15], [6, 7]] + end + end + + describe "traverse/3" do + test "traverses in depth-first pre-order" do + zipper = Zipper.zip([1, [2, [3, 4], 5], [6, 7]]) + + {_, acc} = Zipper.traverse(zipper, [], &{&1, [Zipper.node(&1) | &2]}) + + assert [ + [1, [2, [3, 4], 5], [6, 7]], + 1, + [2, [3, 4], 5], + 2, + [3, 4], + 3, + 4, + 5, + [6, 7], + 6, + 7 + ] == Enum.reverse(acc) + end + + test "traverses a subtree in depth-first pre-order" do + zipper = Zipper.zip([1, [2, [3, 4], 5], [6, 7]]) + + {_, acc} = + zipper + |> Zipper.down() + |> Zipper.right() + |> Zipper.traverse([], &{&1, [Zipper.node(&1) | &2]}) + + assert [[2, [3, 4], 5], 2, [3, 4], 3, 4, 5] == Enum.reverse(acc) + end + end + + describe "traverse_while/2" do + test "traverses in depth-first pre-order and skips branch" do + zipper = Zipper.zip([10, [20, [30, 31], [21, [32, 33]], [22, 23]]]) + + assert zipper + |> Zipper.traverse_while(fn + {[x | _], _} = z when rem(x, 2) != 0 -> {:skip, z} + {[_ | _], _} = z -> {:cont, z} + {x, m} -> {:cont, {x + 100, m}} + end) + |> Zipper.node() == [110, [120, [130, 131], [21, [32, 33]], [122, 123]]] + end + + test "traverses in depth-first pre-order and halts on halt" do + zipper = Zipper.zip([10, [20, [30, 31], [21, [32, 33]], [22, 23]]]) + + assert zipper + |> Zipper.traverse_while(fn + {[x | _], _} = z when rem(x, 2) != 0 -> {:halt, z} + {[_ | _], _} = z -> {:cont, z} + {x, m} -> {:cont, {x + 100, m}} + end) + |> Zipper.node() == [110, [120, [130, 131], [21, [32, 33]], [22, 23]]] + end + + test "traverses until end while always skip" do + assert {_, nil} = [1] |> Zipper.zip() |> Zipper.traverse_while(fn z -> {:skip, z} end) + end + end + + describe "traverse_while/3" do + test "traverses in depth-first pre-order and skips branch" do + zipper = Zipper.zip([10, [20, [30, 31], [21, [32, 33]], [22, 23]]]) + + {_zipper, acc} = + Zipper.traverse_while( + zipper, + [], + fn + {[x | _], _} = z, acc when rem(x, 2) != 0 -> {:skip, z, acc} + {[_ | _], _} = z, acc -> {:cont, z, acc} + {x, _} = z, acc -> {:cont, z, [x + 100 | acc]} + end + ) + + assert acc == [123, 122, 131, 130, 120, 110] + end + + test "traverses in depth-first pre-order and halts on halt" do + zipper = Zipper.zip([10, [20, [30, 31], [21, [32, 33]], [22, 23]]]) + + {_zipper, acc} = + Zipper.traverse_while( + zipper, + [], + fn + {[x | _], _} = z, acc when rem(x, 2) != 0 -> {:halt, z, acc} + {[_ | _], _} = z, acc -> {:cont, z, acc} + {x, _} = z, acc -> {:cont, z, [x + 100 | acc]} + end + ) + + assert acc == [131, 130, 120, 110] + end + + test "traverses until end while always skip" do + assert {_, nil} = + [1] + |> Zipper.zip() + |> Zipper.traverse_while(nil, fn z, acc -> {:skip, z, acc} end) + |> elem(0) + end + end + + describe "top/1" do + test "returns the top zipper" do + assert [1, [2, [3, 4]]] |> Zipper.zip() |> Zipper.next() |> Zipper.next() |> Zipper.next() |> Zipper.top() == + {[1, [2, [3, 4]]], nil} + + assert 42 |> Zipper.zip() |> Zipper.top() |> Zipper.top() |> Zipper.top() == {42, nil} + end + end + + describe "root/1" do + test "returns the root node" do + assert [1, [2, [3, 4]]] |> Zipper.zip() |> Zipper.next() |> Zipper.next() |> Zipper.next() |> Zipper.root() == + [1, [2, [3, 4]]] + end + end + + describe "replace/2" do + test "replaces the current node" do + assert [1, 2] |> Zipper.zip() |> Zipper.down() |> Zipper.replace(:a) |> Zipper.root() == [:a, 2] + end + end + + describe "update/2" do + test "updates the current node" do + assert [1, 2] |> Zipper.zip() |> Zipper.down() |> Zipper.update(fn x -> x + 50 end) |> Zipper.root() == + [51, 2] + end + end + + describe "remove/1" do + test "removes the node and goes back to the previous zipper" do + zipper = [1, [2, 3], 4] |> Zipper.zip() |> Zipper.down() |> Zipper.rightmost() |> Zipper.remove() + + assert Zipper.node(zipper) == 3 + assert Zipper.root(zipper) == [1, [2, 3]] + + assert [1, 2, 3] + |> Zipper.zip() + |> Zipper.next() + |> Zipper.rightmost() + |> Zipper.remove() + |> Zipper.remove() + |> Zipper.remove() + |> Zipper.node() == [] + end + + test "raises when attempting to remove the root" do + assert_raise ArgumentError, fn -> + 42 |> Zipper.zip() |> Zipper.remove() + end + end + end + + describe "insert_left/2 and insert_right/2" do + test "insert a sibling to the left or right" do + assert [1, 2, 3] + |> Zipper.zip() + |> Zipper.down() + |> Zipper.right() + |> Zipper.insert_left(:left) + |> Zipper.insert_right(:right) + |> Zipper.root() == [1, :left, 2, :right, 3] + end + + test "raise when attempting to insert a sibling at the root" do + assert_raise ArgumentError, fn -> 42 |> Zipper.zip() |> Zipper.insert_left(:nope) end + assert_raise ArgumentError, fn -> 42 |> Zipper.zip() |> Zipper.insert_right(:nope) end + end + end + + describe "insert_child/2 and append_child/2" do + test "add child nodes to the leftmost or rightmost side" do + assert [1, 2, 3] |> Zipper.zip() |> Zipper.insert_child(:first) |> Zipper.append_child(:last) |> Zipper.root() == [ + :first, + 1, + 2, + 3, + :last + ] + + assert {:left, :right} |> Zipper.zip() |> Zipper.insert_child(:first) |> Zipper.root() == + {:{}, [], + [ + :first, + :left, + :right + ]} + + assert {:left, :right} |> Zipper.zip() |> Zipper.append_child(:last) |> Zipper.root() == + {:{}, [], + [ + :left, + :right, + :last + ]} + + assert {:foo, [], []} |> Zipper.zip() |> Zipper.insert_child(:first) |> Zipper.append_child(:last) |> Zipper.root() == + {:foo, [], [:first, :last]} + + assert {{:., [], [:a, :b]}, [], []} + |> Zipper.zip() + |> Zipper.insert_child(:first) + |> Zipper.append_child(:last) + |> Zipper.root() == + {{:., [], [:a, :b]}, [], [:first, :last]} + end + end + + describe "find/3" do + test "finds a zipper with a predicate" do + zipper = Zipper.zip([1, [2, [3, 4], 5]]) + + assert zipper |> Zipper.find(fn x -> x == 4 end) |> Zipper.node() == 4 + assert zipper |> Zipper.find(:next, fn x -> x == 4 end) |> Zipper.node() == 4 + end + + test "returns nil if nothing was found" do + zipper = Zipper.zip([1, [2, [3, 4], 5]]) + + assert Zipper.find(zipper, fn x -> x == 9 end) == nil + assert Zipper.find(zipper, :prev, fn x -> x == 9 end) == nil + end + + test "finds a zipper with a predicate in direction :prev" do + zipper = + [1, [2, [3, 4], 5]] + |> Zipper.zip() + |> Zipper.next() + |> Zipper.next() + |> Zipper.next() + |> Zipper.next() + + assert zipper |> Zipper.find(:prev, fn x -> x == 2 end) |> Zipper.node() == 2 + end + + test "retruns nil if nothing was found in direction :prev" do + zipper = + [1, [2, [3, 4], 5]] + |> Zipper.zip() + |> Zipper.next() + |> Zipper.next() + |> Zipper.next() + |> Zipper.next() + + assert Zipper.find(zipper, :prev, fn x -> x == 9 end) == nil + end + end +end