From 3769ffb81aaaad80ad4cf17e20dc154a63fdc531 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Wed, 7 Aug 2024 08:31:38 -0600 Subject: [PATCH 001/145] ignore no-paren, no-body function heads. closes #185 --- CHANGELOG.md | 1 + lib/style/defs.ex | 19 +++++++++++++------ test/style/defs_test.exs | 2 ++ 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d31e73cf..fbb76561 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ they can and will change without that change being reflected in Styler's semanti ### Fixes +* don't blow up on `def function_head_with_no_body_nor_parens` (#185, h/t @ypconstante) * fix `with` arrow replacement + redundant body removal creating invalid statements (#184, h/t @JesseHerrick) * allow Kernel unary `!` and `not` as valid pipe starts (#183, h/t @nherzing) diff --git a/lib/style/defs.ex b/lib/style/defs.ex index 1e76e54c..fb344e78 100644 --- a/lib/style/defs.ex +++ b/lib/style/defs.ex @@ -52,13 +52,20 @@ defmodule Styler.Style.Defs do first_line = meta[:line] last_line = head_meta[:closing][:line] - if first_line == last_line do + cond do + # weird `def fun`, nothing else + is_nil(last_line) -> + {:skip, zipper, ctx} + # Already collapsed - {:skip, zipper, ctx} - else - comments = Style.displace_comments(ctx.comments, first_line..last_line) - node = {def, meta, [Style.set_line(head, meta[:line])]} - {:skip, Zipper.replace(zipper, node), %{ctx | comments: comments}} + first_line == last_line -> + {:skip, zipper, ctx} + + # I just felt like this clause deserved a comment too. It's my favorite one + true -> + comments = Style.displace_comments(ctx.comments, first_line..last_line) + node = {def, meta, [Style.set_line(head, first_line)]} + {:skip, Zipper.replace(zipper, node), %{ctx | comments: comments}} end end diff --git a/test/style/defs_test.exs b/test/style/defs_test.exs index b549ac1f..d80f5871 100644 --- a/test/style/defs_test.exs +++ b/test/style/defs_test.exs @@ -98,6 +98,8 @@ defmodule Styler.Style.DefsTest do end test "no body" do + assert_style "def no_body_nor_parens_yikes!" + assert_style( """ # Top comment From 1661f4181d3289f6915b520a8d7f85d24538551f Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Wed, 7 Aug 2024 08:35:24 -0600 Subject: [PATCH 002/145] minor optimization --- lib/style/defs.ex | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/lib/style/defs.ex b/lib/style/defs.ex index fb344e78..f1670fd5 100644 --- a/lib/style/defs.ex +++ b/lib/style/defs.ex @@ -52,20 +52,13 @@ defmodule Styler.Style.Defs do first_line = meta[:line] last_line = head_meta[:closing][:line] - cond do - # weird `def fun`, nothing else - is_nil(last_line) -> - {:skip, zipper, ctx} - - # Already collapsed - first_line == last_line -> - {:skip, zipper, ctx} - - # I just felt like this clause deserved a comment too. It's my favorite one - true -> - comments = Style.displace_comments(ctx.comments, first_line..last_line) - node = {def, meta, [Style.set_line(head, first_line)]} - {:skip, Zipper.replace(zipper, node), %{ctx | comments: comments}} + # Already collapsed or it's a bodyless/paramless `def fun` + if first_line == last_line || is_nil(last_line) do + {:skip, zipper, ctx} + else + comments = Style.displace_comments(ctx.comments, first_line..last_line) + node = {def, meta, [Style.set_line(head, first_line)]} + {:skip, Zipper.replace(zipper, node), %{ctx | comments: comments}} end end From b3ccc02f0fbaa25cadc970dda5af1434fd3cfcb3 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Thu, 8 Aug 2024 10:42:39 -0600 Subject: [PATCH 003/145] Wrap up 1.0.0 docs overhaul --- CHANGELOG.md | 564 ++---------------------------------- README.md | 12 +- docs/control_flow_macros.md | 17 +- docs/mix_configs.md | 77 ++++- docs/pipes.md | 122 ++++++-- mix.lock | 2 +- 6 files changed, 216 insertions(+), 578 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fbb76561..6e82a4c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,44 +3,7 @@ **Note** Styler's only public API is its usage as a formatter plugin. While you're welcome to play with its internals, they can and will change without that change being reflected in Styler's semantic version. -## main - -### Improvements - -#### `with` - -* remove `with` structure with no left arrows in its head to be normal code (#174) -* `with true <- x(), do: y` => `if x(), do: y` (#173) - -### Fixes - -* don't blow up on `def function_head_with_no_body_nor_parens` (#185, h/t @ypconstante) -* fix `with` arrow replacement + redundant body removal creating invalid statements (#184, h/t @JesseHerrick) -* allow Kernel unary `!` and `not` as valid pipe starts (#183, h/t @nherzing) - -## 1.0.0-rc.2 - -### Fixes - -* fix `Map.drop(x, [a | b])` registering as a chance to refactor to `Map.delete` - -## 1.0.0-rc.1 - -### Improvements - -* Lots of documentation added. Nearly done and ready for 1.0.0. -* `Enum.into(x, [])` => `Enum.to_list(x)` -* `Enum.into(x, [], mapper)` => `Enum.map(x, mapper)` -* `a |> Enum.map(m) |> Enum.join()` to `map_join(a, m)`. we already did this for `join/2`, but missed the case for `join/1` - -## 1.0.0-rc.0 - -At this point, 1.0.0 feels feature complete. Two things remains for a full release: - -1. feedback! -2. documentation overhaul! [monitor progress here](https://github.com/adobe/elixir-styler/pull/166) - -### Improvements +## 1.0.0 Styler's two biggest outstanding bugs have been fixed, both related to compilation breaking during module directive organization. One was references to aliases being moved above where the aliases were declared, and the other was similarly module directives being moved after their uses in module directives. @@ -48,6 +11,10 @@ In both cases, Styler is now smart enough to auto-apply the fixes we recommended Other than that, a slew of powerful new features have been added, the neatest one (in the author's opinion anyways) being Alias Lifting. +Thanks to everyone who reported bugs that contributed to all the fixes released in 1.0.0 as well. + +### Improvements + #### Alias Lifting Along the lines of `Credo.Check.Design.AliasUsage`, Styler now "lifts" deeply nested aliases (depth >= 3, ala `A.B.C....`) that are used more than once. @@ -116,6 +83,18 @@ Styler now organizes `Mix.Config.config/2,3` stanzas according to erlang term so See the moduledoc for `Styler.Style.Configs` for more. +#### Pipe Optimizations + +* `Enum.into(x, [])` => `Enum.to_list(x)` +* `Enum.into(x, [], mapper)` => `Enum.map(x, mapper)` +* `a |> Enum.map(m) |> Enum.join()` to `map_join(a, m)`. we already did this for `join/2`, but missed the case for `join/1` +* `lhs |> Enum.reverse() |> Kernel.++(enum)` => `lhs |> Enum.reverse(enum)` + +#### `with` styles + +* remove `with` structure with no left arrows in its head to be normal code (#174) +* `with true <- x(), do: y` => `if x(), do: y` (#173) + #### Everything Else * `if`/`unless`: invert if and unless with `!=` or `!==`, like we do for `!` and `not` #132 @@ -124,10 +103,13 @@ See the moduledoc for `Styler.Style.Configs` for more. (`"\"\"\"\""` -> `~s("""")`) (`Credo.Check.Readability.StringSigils`) #146 * `Map.drop(foo, [single_key])` => `Map.delete(foo, single_key)` #161 (also in pipes) * `Keyword.drop(foo, [single_key])` => `Keyword.delete(foo, single_key)` #161 (also in pipes) -* `lhs |> Enum.reverse() |> Kernel.++(enum)` => `lhs |> Enum.reverse(enum)` ### Fixes +* don't blow up on `def function_head_with_no_body_nor_parens` (#185, h/t @ypconstante) +* fix `with` arrow replacement + redundant body removal creating invalid statements (#184, h/t @JesseHerrick) +* allow Kernel unary `!` and `not` as valid pipe starts (#183, h/t @nherzing) +* fix `Map.drop(x, [a | b])` registering as a chance to refactor to `Map.delete` * `alias`: expands aliases when moving an alias after another directive that relied on it (#137) * module directives: various fixes for unreported obscure crashes * pipes: fix a comment-shifting scenario when unpiping @@ -139,506 +121,4 @@ See the moduledoc for `Styler.Style.Configs` for more. * drop support for elixir `1.14` * ModuleDirectives: group callback attributes (`before_compile after_compile after_verify`) with nondirectives (previously, were grouped with `use`, their relative order maintained). to keep the desired behaviour, you can make new `use` macros that wrap these callbacks. Apologies if this makes using Styler untenable for your codebase, but it's probably not a good tool for macro-heavy libraries. -* sorting configs for the first time can change your configuration; see `Styler.Style.Configs` moduledoc for more - -## v0.11.9 - -### Improvements - -* pipes: check for `Stream.foo` equivalents to `Enum.foo` in a few more cases - -### Fixes - -* pipes: `|> then(&(&1 op y))` rewrites with `|> Kernel.op(y)` as long as the operator is defined in `Kernel`; skips the rewrite otherwise (h/t @kerryb for the report & @saveman71 for the fix) - -## v0.11.8 - -Two releases in one day!? @koudelka made too good a point about `Map.new` not being special... - -### Improvements - -* pipes: treat `MapSet.new` and `Keyword.new` the same way we do `Map.new` (h/t @koudelka) -* pipes: treat `Stream.map` the same as `Enum.map` when piped `|> Enum.into` - -## v0.11.7 - -### Improvements - -* deprecations: `~R` -> `~r`, `Date.range/2` -> `Date.range/3` with decreasing dates (h/t @milmazz) -* if: rewrite `if not x, do: y` => `unless x, do: y` -* pipes: `|> Enum.map(foo) |> Map.new()` => `|> Map.new(foo)` -* pipes: remove unnecessary `then/2` on named function captures: `|> then(&foo/1)` => `|> foo()`, `|> then(&foo(&1, ...))` => `|> foo(...)` (thanks to @tfiedlerdejanze for the idea + impl!) - -## v0.11.6 - -### Fixes - -* directives: maintain order of module compilation callbacks (`@before_compile` etc) relative to `use` statements (Closes #120, h/t @frankdugan3) - -## v0.11.5 - -### Fixes - -* fix parsing ranges with non-trivial integer bounds like `x..y` (Closes #119, h/t @maennchen) - -## v0.11.4 - -### Improvements - -Shoutout @milmazz for all the deprecation work below =) - -* Deprecations: Rewrite 1.16 Deprecations (h/t @milmazz for all the work here) - * add `//1` step to `Enum.slice/2|String.slice/2` with decreasing ranges - * `File.stream!(file, options, line_or_bytes)` => `File.stream!(file, line_or_bytes, options)` -* Deprecations `Path.safe_relative_to/2` => `Path.safe_relative/2` - -## v0.11.3 - -### Fixes - -* directives: fix infinite loop when encountering `@spec import(...) :: ...` (Closes #115, h/t @kerryb) -* `with`: fix deletion of arrow-less `with` statements within function invocations - -## v0.11.2 - -### Fixes - -* `pipes`: fix unpiping do-blocks into variables when the parent expression is a function invocation - like `a(if x do y end |> z(), b)` (Closes #114, h/t @wkirschbaum) - -## v0.11.1 - -### Fixes - -* `with`: fix `with` replacement when it's the only child of a `do` or `->` block (Closes #107, h/t @kerryb -- turns out those edge cases _did_ exist in the wild!) - -## v0.11.0 - -### Improvements - -#### Comments - -Styler will no longer make comments jump around in any situation, and will move comments with the appropriate node in all cases but module directive rearrangement (where they'll just be left behind - sorry! we're still working on it). - -* Keep comments in logical places when rewriting if/unless/cond/with (#79, #97, #101, #103) - -#### With Statements - -This release has a slew of improvements for `with` statements. It's not surprising that there's lots of style rules for `with` given that just about any `case`, `if`, or even `cond do` could also be expressed as a `with`. They're very powerful! And with great power... - -* style trivial pattern matches ala `lhs <- rhs` to `lhs = rhs` (#86) -* style `_ <- rhs` to `rhs` -* style keyword `, do: ` to `do end` rather than wrapping multiple statements in parens -* style statements all the way to `if` statements when appropriate (#100) - -#### Other - -* Rewrite `{Map|Keyword}.merge(single_key: value)` to use `put/3` instead (#96) - -### Fixes - -* `with`: various edge cases we can only hope no one's encountered and thus never reported - -## v0.10.5 - -After being bitten by two of them in a row, Styler's test suite now makes sure that there are no -idempotency bugs as part of its tests. - -In short, we now have `assert style(x) == style(style(x))` as part of every test. Sorry for not thinking to include this before :) - -### Fixes - -* alias: fix single-module alias deletion newlines bug -* comments: ensure all generated nodes always include line meta (#101) - -## v0.10.4 - -### Improvements - -* alias: delete noop single-module aliases (`alias Foo`, #87, h/t @mgieger) - -### Fixes - -* pipes: unnest all pipe starts in one pass (`f(g(h(x))) |> j()` => `x |> h() |> g() |> f() |> j()`, #94, h/t @tomjschuster) - -## v0.10.3 - -### Improvements - -* charlists: leave charlist rewriting to elixir's formatter on elixir >= 1.15 - -### Fixes - -* charlists: rewrite empty charlist to use sigil (`''` => `~c""`) -* pipes: don't blow up extracting fully-qualified macros (`Foo.bar do end |> foo()`, #91, h/t @NikitaNaumenko) - -## v0.10.2 - -### Improvements - -* `with`: remove identity singleton else clause (eg `else {:error, e} -> {:error, e} end`, `else error -> error end`) - -## v0.10.1 - -### Fixes - -* Fix function head shrink-failures causing comments to jump into blocks (Closes #67, h/t @APB9785) - -## v0.10.0 - -### Improvements - -* hoist all block-starts to pipes to their own variables (makes styler play better with piped macros) - -### Fixes - -* fix pipes starting with a macro do-block creating invalid ast (#83, h/t @mhanberg) - -## v0.9.7 - -### Fixes - -* rewrite pipes starting with `quote` blocks like we do with `case|if|cond|with` blocks (#82, h/t @SteffenDE) - -## v0.9.6 - -### Breaking Change - -* removed `mix style` task - -## v0.9.5 - -### Fixes - -* fix mistaking `Timex.now/1` in a pipe for `Timex.now/0` (#66, h/t @sabiwara) - -### Removed style - -* stop rewriting `Timex.today/0` given that we allow `Timex.today/1` -- too inconsistent. - -## v0.9.4 - -### Improvements - -* `if` statements: drop `else` clauses whose body is simply `nil` - -## v0.9.3 - -### Fixes - -* fix `unless a do b else c end` rewrites to `if` not flopping do/else bodies! (#77, h/t @jcowgar) -* fix pipes styling ranges with steps (`a..b//c`) incorrectly (#76, h/t @cschmatzler) - -## v0.9.2 - -### Fixes - -* fix exception styling module attributes named `@def` (we confused them with real `def`s, whoops!) (#75, h/t @randycoulman) - -## v0.9.1 - -the boolean blocks edition! - -### Improvements - -* auto-fix `Credo.Check.Refactor.CondStatements` (detects any truthy atom, not just `true`) -* if/unless rewrites: - - `Credo.Check.Refactor.NegatedConditionsWithElse` - - `Credo.Check.Refactor.NegatedConditionsInUnless` - - `Credo.Check.Refactor.UnlessWithElse` - -## v0.9.0 - -the with statement edition! - -### Improvements - -* Added right-hand-pattern-matching rewrites to `for` and `with` left arrow expressions `<-` - (ex: `with map = %{} <- foo()` => `with %{} = map <- foo`) -* `with` statement rewrites, solving the following credo rules - * `Credo.Check.Readability.WithSingleClause` - * `Credo.Check.Refactor.RedundantWithClauseResult` - * `Credo.Check.Refactor.WithClauses` - -## v0.8.5 - -### Fixes - -* Fixed exception when encountering non-arrowed case statements ala `case foo, do: unquote(quoted)` (#69, h/t @brettinternet, nice) - -## v0.8.4 - -### Fixes - -* Timex related fixes (#66): - * Rewrite `Timex.now/1` to `DateTime.now!/1` instead of `DateTime.utc_now/1` - * Only rewrite `Timex.today/0`, don't change `Timex.today/1` - -## v0.8.3 - -### Improvements - -* DateTime rewrites (#62, ht @milmazz) - * `DateTime.compare` => `DateTime.{before/after}` (elixir >= 1.15) - * `Timex.now` => `DateTime.utc_now` - * `Timex.today` => `Date.utc_today` - -### Fixes - -* Pipes: add `!=`, `!==`, `===`, `and`, and `or` to list of valid infix operators (#64) - -## v0.8.2 - -### Fixes - -* Pipes always de-sugars keyword lists when unpiping them (#60) - -## v0.8.1 - -### Fixes - -* ModuleDirectives doesn't mistake variables for directives (#57, h/t @leandrocp) - -## v0.8.0 - -### Improvements (Bug Fix!?) - -* ModuleDirectives no longer throws comments around a file when hoisting directives up (#53) - -## v0.7.14 - -### Improvements - -* rewrite `Logger.warn/1,2` to `Logger.warning/1,2` due to Elixir 1.15 deprecation - -## v0.7.13 - -### Fixes - -* don't unpipe single-piped `unquote` expressions (h/t @elliottneilclark) - -## v0.7.12 - -### Fixes - -* fix 0-arity paren removal on metaprogramming creating uncompilable code (h/t @simonprev) - -## v0.7.11 - -### Fixes - -* fix crash from `mix style` running plugins as part of formatting (no longer runs formatter plugins) - -### Improvements - -* single-quote charlists are rewritten to use the `~c` sigil (`'foo'` -> `~c'foo'`) (h/t @fhunleth) -* `mix style` warns the user that Styler is primarily meant to be used as a plugin - -## v0.7.10 - -### Fixes - -* fix crash when encountering single-quote charlists (h/t @fhunleth) - -### Improvements - -* single-quote charlists are rewritten to use the `~c` sigil (`'foo'` -> `~c'foo'`) -* when encountering `_ = bar ->`, replace it with `bar ->` - -## v0.7.9 - -### Fixes - -* Fix a toggle state resulting from (ahem, nonsense) code like `_ = bar ->` encountering ParameterPatternMatching style - -## v0.7.8 - -### Fixes - -* Fix crash trying to remove 0-arity parens from metaprogramming ala `def unquote(foo)()` - -## v0.7.7 - -### Improvements - -* Rewrite `Enum.into/2,3` into `Map.new/1,2` when the collectable is `%{}` or `Map.new/0` - -## v0.7.6 - -### Fixes - -* Fix crash when single pipe had inner defs (h/t [@michallepicki](https://github.com/adobe/elixir-styler/issues/39)) - -## v0.7.5 - -### Fixes - -* Fix bug from `ParameterPatternMatching` implementation that re-ordered pattern matching in `cond do` `->` clauses - -## v0.7.4 - -### Features - -* Implement `Credo.Check.Readability.PreferImplicitTry` -* Implement `Credo.Check.Consistency.ParameterPatternMatching` for `def|defp|fn|case` - -## v0.7.3 - -### Features - -* Remove parens from 0-arity function definitions (`Credo.Check.Readability.ParenthesesOnZeroArityDefs`) - -## v0.7.2 - -### Features - -* Rewrite `case ... true -> ...; _ -> ...` to `if` statements as well - -## v0.7.1 - -### Features - -* Rewrite `case ... true / else ->` to be `if` statements - -## v0.7.0 - -### Features - -* `Styler.Style.Simple`: - * Optimize `Enum.reverse(foo) ++ bar` to `Enum.reverse(foo, bar)` -* `Styler.Style.Pipes`: - * Rewrite `|> (& ...).()` to `|> then(& ...)` (`Credo.Check.Readability.PipeIntoAnonymousFunctions`) - * Add parens to 1-arity pipe functions (`Credo.Check.Readability.OneArityFunctionInPipe`) - * Optimize `a |> Enum.reverse() |> Enum.concat(enum)` to `Enum.reverse(a, enum)` - -## v0.6.1 - -### Improvements - -* Better error handling: `mix format` will still format files if a style fails - -### Fixes - -* `mix style`: only run on `.ex` and `.exs` files -* `ModuleDirectives`: now expands `alias __MODULE__.{A, B}` (h/t [@adriankumpf](https://github.com/adriankumpf)) - -## v0.6.0 - -### Features - -* `mix style`: brought back to life for folks who want to incrementally introduce Styler - -### Fixes - -* `Styler.Style.Pipes`: - * include `x in y` and `^foo` (for ecto) as a valid pipe starts - * work even harder to keep rewrites on one line - -## v0.5.2 - -### Fixes - -* `ModuleDirectives`: handle dynamic module names -* `Pipes`: include `Ecto.Query.from` and `Query.from` as valid pipe starts - -## v0.5.1 - -### Improvements - -* Sped up styling just a little bit - -## v0.5.0 - -### Improvements - -* `Styler` now implements `Mix.Task.Format`, meaning it is now an Elixir formatter plugin. -See the README for new installation & usage instructions - -### Breaking Change! Wooo! - -* the `mix style` task has been removed - -## v0.4.1 - -### Improvements - -* `Pipes` rewrites `|> Enum.into(%{}[, mapper])` and `Enum.into(Map.new()[, mapper])` to `Map.new/1,2` calls - -## v0.4.0 - -### Improvements - -* `Pipes` rewrites some two-step processes into one, fixing these credo issues in pipe chains: - * `Credo.Check.Refactor.FilterCount` - * `Credo.Check.Refactor.MapJoin` - * `Credo.Check.Refactor.MapInto` - -### Fixes - -* `ModuleDirectives` handles even weirder places to hide your aliases (anonymous functions, in this case) -* `Pipes` tries even harder to keep single-pipe rewrites of invocations on one line - -## v0.3.1 - -### Fixes - -* `Pipes` - * fixed omission of `==` as a valid pipe start operator (h/t @peake100 for the issue) - * fixed rewrite of `a |> b`, where `b` was invoked without parenthesis - -## v0.3.0 - -### Improvements - -* Enabled `Defs` style and overhauled it to properly handles comments -* Optimized and tweaked `ModuleDirectives` style - * Now culls newlines between "groups" of the same directive - * sorts `@behaviour` directives - * orders directives within non defmodule contexts (eg, a `def do`) if there's at least one `alias|require|use|import` - -### Fixes - -* `Pipes` will try to keep single-pipe rewrites on one line - -## v0.2.0 - -### Improvements - -* Added `ModuleDirectives` style - * Note that this is potentially destructive in some rare cases. See moduledoc for more. - * This supersedes the `Aliases` style, which has been removed. -* `mix style -` reads and writes to stdin/stdout - -### Fixes - -* `Pipes` style is now aware of `unless` blocks - -## v0.1.1 - -### Improvements - -* Lots of README tweaking =) -* Optimized some Zipper operations -* Added `Simple` style, replacing the following Credo rule: - * `Credo.Check.Readability.LargeNumbers` - -### Fixes - -* Exceptions while parsing code now appropriately render filename rather than `nofile:xx` -* Fixed opaque `Zipper.path()` typespec implementation mismatches (thanks @sega-yarkin) -* Made `ex_doc` dev only, removing it as a dependency for users of Styler - -## 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) +* sorting configs for the first time can change your configuration; see [Mix Configs docs](docs/mix_configs.md) for more diff --git a/README.md b/README.md index de88728c..a4a1b085 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,11 @@ You can learn more about the history, purpose and implementation of Styler from - auto-fixes [many credo rules](docs/credo.md), meaning you can turn them off to speed credo up - [keeps a strict module layout](docs/module_directives.md#directive-organization) -- alphabetizes module directives + - alphabetizes module directives - [extracts repeated aliases](docs/module_directives.md#alias-lifting) -- pipes and unpipes function calls based on the number of calls -- optimizes standard library calls (`a |> Enum.map(m) |> Enum.into(Map.new)` => `Map.new(a, m)`) +- [makes your pipe chains pretty as can be](docs/pipes.md) + - pipes and unpipes function calls based on the number of calls + - optimizes standard library calls (`a |> Enum.map(m) |> Enum.into(Map.new)` => `Map.new(a, m)`) - replaces strings with sigils when the string has many escaped quotes - ... and so much more @@ -24,15 +25,14 @@ You can learn more about the history, purpose and implementation of Styler from ## Who is Styler for? -Styler was designed for a large team (40+ engineers) working in a single codebase. It helps remove fiddly code review comments and removes failed linter CI slowdowns, helping teams get things done faster. Teams in similar situations might appreciate Styler. +Styler was designed for a **large team (40+ engineers) working in a single codebase. It helps remove fiddly code review comments and removes failed linter CI slowdowns, helping teams get things done faster. Teams in similar situations might appreciate Styler. Its automations are also extremely valuable for taming legacy elixir codebases or just refactoring in general. Some of its rewrites have inspired code actions in elixir language servers. Conversely, Styler probably _isn't_ a good match for: -- libraries - experimental, macro-heavy codebases -- small teams that don't want to think about code standards +- teams that don't care about code standards ## Installation diff --git a/docs/control_flow_macros.md b/docs/control_flow_macros.md index d4123a9b..39243034 100644 --- a/docs/control_flow_macros.md +++ b/docs/control_flow_macros.md @@ -97,26 +97,29 @@ if x, do: y ### "Erlang heritage" `case` true/false -> `if` -Trivial true/false `case` statements are rewritten to `if` statements. While this results in a [semantically different program](https://github.com/rrrene/credo/issues/564#issue-338349517), we argue that it results in a better program for maintainability. If the developer wants a their case statement to raise when receiving a non-boolean value as a feature of the program, they would better serve their callers by raising something more descriptive. +Trivial true/false `case` statements are rewritten to `if` statements. While this results in a [semantically different program](https://github.com/rrrene/credo/issues/564#issue-338349517), we argue that it results in a better program for maintainability. If the developer wants their case statement to raise when receiving a non-boolean value as a feature of the program, they would better serve their callers by raising something more descriptive. In other words, Styler leaves the code with better style, trumping obscure exception design :) ```elixir -# instead of this +# Styler will rewrite this even if the clause order is flipped, +# and if the `false` is replaced with a wildcard (`_`) case foo do true -> :ok false -> :error end -# do this +# styled: if foo do :ok else :error end +``` + +Per the argument above, if the `if` statement is an incorrect rewrite for your program, we recommend this manual fix rewrite: -# OR this. readers now know that the exception is an intentional design, -# rather than an accidental "feature" +```elixir case foo do true -> :ok false -> :error @@ -265,8 +268,6 @@ If the pattern of the final clause of the head is also the `with` statements `do with {:ok, a} <- foo(), {:ok, b} <- bar(a) do {:ok, b} -else - error -> error end # Styled: with {:ok, a} <- foo() do @@ -276,7 +277,7 @@ end ### Replace with `case` -A `with` statement with a single clause in the head and `else` is a really just a `case` clause putting on airs. +A `with` statement with a single clause in the head and an `else` body is really just a `case` statement putting on airs. ```elixir # Given: diff --git a/docs/mix_configs.md b/docs/mix_configs.md index c3b45e8e..70bf88fd 100644 --- a/docs/mix_configs.md +++ b/docs/mix_configs.md @@ -4,7 +4,7 @@ Mix Config files have their config stanzas sorted. Similar to the sorting of ali A file is considered a config file if -1. its path matches `config/.*\.exs` or `rel/overlays/.*\.exs` +1. its path matches `~r|config/.*\.exs|` `~r|rel/overlays/.*\.exs|` 2. the file has `import Config` Once a file is detected as a mix config, its `config/2,3` stanzas are grouped and ordered like so: @@ -13,6 +13,79 @@ Once a file is detected as a mix config, its `config/2,3` stanzas are grouped an - sort each group according to erlang term sorting - move all existing assignments between the config stanzas to above the stanzas (without changing their ordering) +## THIS CAN BREAK YOUR PROGRAM + +It's important to double check your configuration after running Styler on it for the first time. + +**First Use Advice**: To limit the size of changes Styler submits to a codebase, we recommend formatting only a few (or a single) files at a time and making pull requests for each. Only commit Styler as a new formatter plugin once each of these more dangerous changes has been safely committed to the codebase. + +Imagine your application configures the same value twice, once with an invalid or application breaking value, and then again with a correct value, like so: + +```elixir +string = "i am a string" +atom = :i_am_an_atom + +config :my_app, value_must_be_an_atom: string +... +... +config :my_app, value_must_be_an_atom: atom +``` + +When styler sorts the configuration file, this dormant mistake can become a bug if the sorting changes the order such that the invalid value takes precedence (aka comes last) + +```elixir +string = "i am a string" +atom = :i_am_an_atom + +# The value that must be an atom is now a string! +config :my_app, value_must_be_an_atom: atom +config :my_app, value_must_be_an_atom: string +``` + ## Examples -TODOs +Sorts configs by erlang term ordering: + +```elixir +# Given +import Config + +config :z, :x, :c +config :a, :b, :c +config :y, :x, :z +config :a, :c, :d + +# Styled: +import Config + +config :a, :b, :c +config :a, :c, :d + +config :y, :x, :z + +config :z, :x, :c +``` + +Non-config statements break the file up into chunks, where each chunk is sorted separately relative to itself. + +```elixir +# Given +import Config + +config :z, :x, :c +config :a, :b, :c +var = "value" +config :y, :x, var +config :a, :c, var + +# Styled: +import Config + +config :a, :b, :c +config :z, :x, :c + +var = "value" + +config :a, :c, var +config :y, :x, var +``` diff --git a/docs/pipes.md b/docs/pipes.md index 9958bcc6..5b8f065c 100644 --- a/docs/pipes.md +++ b/docs/pipes.md @@ -2,30 +2,114 @@ ## Pipe Start -- raw value -- blocks are extracted to variables -- ecto's `from` is allowed +Styler will ensure that the start of a pipechain is a 0-arity function, a raw value, or a variable. -## Piped function rewrites +```elixir +Enum.at(enum, 5) +|> IO.inspect() + +# Styled: +enum +|> Enum.at(5) +|> IO.inspect() +``` + +If the start of a pipe is a block expression, styler will create a new variable to store the result of that expression and make that variable the start of the pipe. + +```elixir +if a do + b +else + c +end +|> Enum.at(4) +|> IO.inspect() + +# Styled: +if_result = + if a do + b + else + c + end + +if_result +|> Enum.at(4) +|> IO.inspect() +``` + +### Add parenthesis to function calls in pipes + +```elixir +a |> b |> c |> d +# Styled: +a |> b() |> c() |> d() +``` + +### Remove Unnecessary `then/2` + +When the piped argument is being passed as the first argument to the inner function, there's no need for `then/2`. + +```elixir +a |> then(&f(&1, ...)) |> b() +# Styled: +a |> f(...)) |> b() +``` - add parens to function calls `|> fun |>` => `|> fun() |>` -- remove unnecessary `then/2`: `|> then(&f(&1, ...))` -> `|> f(...)` -- add `then/2` when defining anon funs in pipe `|> (& &1).() |>` => `|> then(& &1) |>` -## Piped function optimizations +### Add `then/2` when defining and calling anonymous functions in pipes + +```elixir +a |> (fn x -> x end).() |> c() +# Styled: +a |> then(fn x -> x end) |> c() +``` + +### Piped function optimizations + +Two function calls into one! Fewer steps is always nice. + +```elixir +# reverse |> concat => reverse/2 +a |> Enum.reverse() |> Enum.concat(enum) |> ... +# Styled: +a |> Enum.reverse(enum) |> ... + +# filter |> count => count(filter) +a |> Enum.filter(filterer) |> Enum.count() |> ... +# Styled: +a |> Enum.count(filterer) |> ... + +# map |> join => map_join +a |> Enum.map(mapper) |> Enum.join(joiner) |> ... +# Styled: +a |> Enum.map_join(joiner, mapper) |> ... + +# Enum.map |> X.new() => X.new(mapper) +# where X is one of: Map, MapSet, Keyword +a |> Enum.map(mapper) |> Map.new() |> ... +# Styled: +a |> Map.new(mapper) |> ... + +# Enum.map |> Enum.into(empty_collectable) => X.new(mapper) +# Where empty_collectable is one of `%{}`, `Map.new()`, `Keyword.new()`, `MapSet.new()` +# Given: +a |> Enum.map(mapper) |> Enum.into(%{}) |> ... +# Styled: +a |> Map.new(mapper) |> ... +``` -Two function calls into one! Tries to fit everything on one line when shrinking. +### Unpiping Single Pipes -| Before | After | -|--------|-------| -| `lhs \|> Enum.reverse() \|> Enum.concat(enum)` | `lhs \|> Enum.reverse(enum)` (also Kernel.++) | -| `lhs \|> Enum.filter(filterer) \|> Enum.count()` | `lhs \|> Enum.count(filterer)` | -| `lhs \|> Enum.map(mapper) \|> Enum.join(joiner)` | `lhs \|> Enum.map_join(joiner, mapper)` | -| `lhs \|> Enum.map(mapper) \|> Enum.into(empty_map)` | `lhs \|> Map.new(mapper)` | -| `lhs \|> Enum.map(mapper) \|> Map.new()` | `lhs \|> Map.new(mapper)` mapset & keyword also | +Styler rewrites pipechains with a single pipe to be function calls. Notably, this rule combined with the optimizations rewrites above means some chains with more than one pipe will also become function calls. -## Unpiping Single Pipes +```elixir +foo = bar |> baz() +# Styled: +foo = baz(bar) -- notably, optimizations might turn a 2 pipe into a single pipe -- doesn't unpipe when we're starting w/ quote -- pretty straight forward i daresay +map = a |> Enum.map(mapper) |> Map.new() +# Styled: +map = Map.new(a, mapper) +``` diff --git a/mix.lock b/mix.lock index ad250b75..3339de94 100644 --- a/mix.lock +++ b/mix.lock @@ -1,6 +1,6 @@ %{ "earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"}, - "ex_doc": {:hex, :ex_doc, "0.31.1", "8a2355ac42b1cc7b2379da9e40243f2670143721dd50748bf6c3b1184dae2089", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.1", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "3178c3a407c557d8343479e1ff117a96fd31bafe52a039079593fb0524ef61b0"}, + "ex_doc": {:hex, :ex_doc, "0.34.2", "13eedf3844ccdce25cfd837b99bea9ad92c4e511233199440488d217c92571e8", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "5ce5f16b41208a50106afed3de6a2ed34f4acfd65715b82a0b84b49d995f95c1"}, "makeup": {:hex, :makeup, "1.1.1", "fa0bc768698053b2b3869fa8a62616501ff9d11a562f3ce39580d60860c3a55e", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "5dc62fbdd0de44de194898b6710692490be74baa02d9d108bc29f007783b0b48"}, "makeup_elixir": {:hex, :makeup_elixir, "0.16.1", "cc9e3ca312f1cfeccc572b37a09980287e243648108384b97ff2b76e505c3555", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "e127a341ad1b209bd80f7bd1620a15693a9908ed780c3b763bccf7d200c767c6"}, "makeup_erlang": {:hex, :makeup_erlang, "0.1.3", "d684f4bac8690e70b06eb52dad65d26de2eefa44cd19d64a8095e1417df7c8fd", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "b78dc853d2e670ff6390b605d807263bf606da3c82be37f9d7f68635bd886fc9"}, From ea8c72308b317a3201170c976b4cc2f8a09a1c09 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Thu, 8 Aug 2024 10:43:23 -0600 Subject: [PATCH 004/145] 1.0.0 --- mix.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mix.exs b/mix.exs index f128ae75..02eba042 100644 --- a/mix.exs +++ b/mix.exs @@ -12,7 +12,7 @@ defmodule Styler.MixProject do use Mix.Project # Don't forget to bump the README when doing non-patch version changes - @version "1.0.0-rc.2" + @version "1.0.0" @url "https://github.com/adobe/elixir-styler" def project do From a9bc6890f54819ce234bdb0e1bdacd8fc9548ecc Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Tue, 27 Aug 2024 09:55:13 -0600 Subject: [PATCH 005/145] Add missing documentation. Closes #188 --- docs/pipes.md | 2 +- docs/styles.md | 194 ++++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 161 insertions(+), 35 deletions(-) diff --git a/docs/pipes.md b/docs/pipes.md index 5b8f065c..bcf98ef7 100644 --- a/docs/pipes.md +++ b/docs/pipes.md @@ -53,7 +53,7 @@ When the piped argument is being passed as the first argument to the inner funct ```elixir a |> then(&f(&1, ...)) |> b() # Styled: -a |> f(...)) |> b() +a |> f(...) |> b() ``` - add parens to function calls `|> fun |>` => `|> fun() |>` diff --git a/docs/styles.md b/docs/styles.md index 3c333737..d3d26497 100644 --- a/docs/styles.md +++ b/docs/styles.md @@ -10,7 +10,12 @@ These apply to the piped versions as well Rewrites strings with 4 or more escaped quotes to string sigils with an alternative delimiter. The delimiter will be one of `" ( { | [ ' < /`, chosen by which would require the fewest escapes, and otherwise preferred in the order listed. -* `"{\"errors\":[\"Not Authorized\"]}"` => `~s({"errors":["Not Authorized"]})` +```elixir +# Before +"{\"errors\":[\"Not Authorized\"]}" +# Styled +~s({"errors":["Not Authorized"]}) +``` ## Large Base 10 Numbers @@ -51,29 +56,58 @@ Note that all of the examples below also apply to pipes (`enum |> Enum.into(...) `Keyword.merge` and `Map.merge` called with a literal map or keyword argument with a single key are rewritten to the equivalent `put`, a cognitively simpler function. -| Before | After | -|--------|-------| -| `Keyword.merge(kw, [key: :value])` | `Keyword.put(kw, :key, :value)` | -| `Map.merge(map, %{key: :value})` | `Map.put(map, :key, :value)` | -| `Map.merge(map, %{key => value})` | `Map.put(map, key, value)` | -| `map \|> Map.merge(%{key: value}) \|> foo()` | `map \|> Map.put(:key, value) \|> foo()` | +```elixir +# Before +Keyword.merge(kw, [key: :value]) +# Styled +Keyword.put(kw, :key, :value) + +# Before +Map.merge(map, %{key: :value}) +# Styled +Map.put(map, :key, :value) + +# Before +Map.merge(map, %{key => value}) +# Styled +Map.put(map, key, value) + +# Before +map |> Map.merge(%{key: value}) |> foo() +# Styled +map |> Map.put(:key, value) |> foo() +``` ## Map/Keyword.drop w/ single key -> X.delete In the same vein as the `merge` style above, `[Map|Keyword].drop/2` with a single key to drop are rewritten to use `delete/2` -| Before | After | -|--------|-------| -| `Map.drop(map, [key])` | `Map.delete(map, key)`| -| `Keyword.drop(kw, [key])` | `Keyword.delete(kw, key)`| +```elixir +# Before +Map.drop(map, [key]) +# Styled +Map.delete(map, key) + +# Before +Keyword.drop(kw, [key]) +# Styled +Keyword.delete(kw, key) +``` ## `Enum.reverse/1` and concatenation -> `Enum.reverse/2` `Enum.reverse/2` optimizes a two-step reverse and concatenation into a single step. -| Before | After | -|--------|-------| -| `Enum.reverse(foo) ++ bar` | `Enum.reverse(foo, bar)`| -| `baz \|> Enum.reverse() \|> Enum.concat(bop)` | `Enum.reverse(baz, bop)`| +```elixir +# Before +Enum.reverse(foo) ++ bar +# Styled +Enum.reverse(foo, bar) + +# Before +baz |> Enum.reverse() |> Enum.concat(bop) +# Styled +Enum.reverse(baz, bop) +``` ## `Timex.now/0` ->` DateTime.utc_now/0` @@ -91,10 +125,17 @@ That's where Styler comes in! The examples below use `DateTime.compare/2`, but the same is also done for `NaiveDateTime|Time|Date.compare/2` -| Before | After | -|--------|-------| -| `DateTime.compare(start, end_date) == :gt` | `DateTime.after?(start, end_date)` | -| `DateTime.compare(start, end_date) == :lt` | `DateTime.before?(start, end_date)` | +```elixir +# Before +DateTime.compare(start, end_date) == :gt +# Styled +DateTime.after?(start, end_date) + +# Before +DateTime.compare(start, end_date) == :lt +# Styled +DateTime.before?(start, end_date) +``` ## Implicit Try @@ -140,12 +181,19 @@ end The author of the library disagrees with this style convention :) BUT, the wonderful thing about Styler is it lets you write code how _you_ want to, while normalizing it for reading for your entire team. The most important thing is not having to think about the style, and instead focus on what you're trying to achieve. -| Before | After | -|--------|-------| -| `def foo()` | `def foo`| -| `defp foo()` | `defp foo`| -| `defmacro foo()` | `defmacro foo`| -| `defmacrop foo()` | `defmacrop foo`| +```elixir +# Before +def foo() +defp foo() +defmacro foo() +defmacrop foo() + +# Styled +def foo +defp foo +defmacro foo +defmacrop foo +``` ## Elixir Deprecation Rewrites @@ -163,16 +211,94 @@ The author of the library disagrees with this style convention :) BUT, the wonde \* For both of the "decreasing range" changes, the rewrite can only be applied if the range is being passed as an argument to the function. ### 1.16+ -| Before | After | -|--------|-------| -|`File.stream!(file, options, line_or_bytes)` | `File.stream!(file, line_or_bytes, options)`| +File.stream! `:line` and `:bytes` deprecation -## Code Readability +```elixir +# Before +File.stream!(path, [encoding: :utf8, trim_bom: true], :line) +# Styled +File.stream!(path, :line, encoding: :utf8, trim_bom: true) +``` -- put matches on right -- `Credo.Check.Readability.PreferImplicitTry` +## Putting variable matching on the right -## Function Definitions +```elixir +# Before +case foo do + bar = %{baz: baz? = true} -> :baz? + opts = [[a = %{}] | _] -> a +end +# Styled: +case foo do + %{baz: true = baz?} = bar -> :baz? + [[%{} = a] | _] = opts -> a +end + +# Before +with {:ok, result = %{}} <- foo, do: result +# Styled +with {:ok, %{} = result} <- foo, do: result + +# Before +def foo(bar = %{baz: baz? = true}, opts = [[a = %{}] | _]), do: :ok +# Styled +def foo(%{baz: true = baz?} = bar, [[%{} = a] | _] = opts), do: :ok +``` + +## Drops superfluous `= _` in pattern matching + +```elixir +# Before +def foo(_ = bar), do: bar +# Styled +def foo(bar), do: bar + +# Before +case foo do + _ = bar -> :ok +end +# Styled +case foo do + _ = bar -> :ok +end +``` + +## Use Implicit Try -- Shrink multi-line function defs -- Put assignments on the right +```elixir +# before +def foo d + try do + throw_ball() + catch + :ball -> :caught + end +end + +# Styled: +def foo d + throw_ball() +catch + :ball -> :caught +end +``` + +## Shrink Function Definitions to One Line When Possible + +```elixir +# Before + +def save( + # Socket comment + %Socket{assigns: %{user: user, live_action: :new}} = initial_socket, + # Params comment + params + ), + do: :ok + +# Styled + +# Socket comment +# Params comment +def save(%Socket{assigns: %{user: user, live_action: :new}} = initial_socket, params), do: :ok +``` From ee0a6fa0bcc13e0b8ac360f7c39ea026b548ea81 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Tue, 27 Aug 2024 09:56:25 -0600 Subject: [PATCH 006/145] update readme link --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a4a1b085..28d45589 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ You can learn more about the history, purpose and implementation of Styler from - replaces strings with sigils when the string has many escaped quotes - ... and so much more -[See our Rewrites documentation on hexdocs for all the nitty-gritty on what all Styler does](https://hexdocs.pm/styler/) +[See our Rewrites documentation on hexdocs](https://hexdocs.pm/styler/styles.html) ## Who is Styler for? From c29d10dc3952af9ba11fb5d6499cc32dc8c0ead5 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Tue, 27 Aug 2024 10:00:08 -0600 Subject: [PATCH 007/145] add more verbiage re styler can add bugs. Closes #186 --- README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 28d45589..39c25929 100644 --- a/README.md +++ b/README.md @@ -79,9 +79,13 @@ Styler [will not add configuration](https://github.com/adobe/elixir-styler/pull/ Ultimately Styler is @adobe's internal tool that we're happy to share with the world. We're delighted if you like it as is, and just as excited if it's a starting point for you to make something even better for yourself. -## !Styler can change the behaviour of your program! +## WARNING: Styler can change the behaviour of your program! + +In some cases, this can introduce bugs. It goes without saying, but look over your changes before committing to main :) +(Here's an [example issue](https://github.com/adobe/elixir-styler/issues/186) where Styler unexpectedly changed the behaviour of a user's program.) + +A simple example of a way Styler changes the behaviour of code is the following rewrite: -The best example of the way in which Styler changes the meaning of your code is the following rewrite: ```elixir # Before: this case statement... case foo do From 3a66e42cd529a3a085f84b91cf97a8797988bf60 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Tue, 27 Aug 2024 10:03:01 -0600 Subject: [PATCH 008/145] reword warning to list examples of breakages --- README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 39c25929..c2350650 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,6 @@ Ultimately Styler is @adobe's internal tool that we're happy to share with the w ## WARNING: Styler can change the behaviour of your program! In some cases, this can introduce bugs. It goes without saying, but look over your changes before committing to main :) -(Here's an [example issue](https://github.com/adobe/elixir-styler/issues/186) where Styler unexpectedly changed the behaviour of a user's program.) A simple example of a way Styler changes the behaviour of code is the following rewrite: @@ -115,6 +114,12 @@ end Also good style! But Styler assumes that most of the time people just meant the `if` equivalent of the code, and so makes that change. If issues like this bother you, Styler probably isn't the tool you're looking for. +Other ways Styler can change your program: + +- [`with` statement rewrites](https://github.com/adobe/elixir-styler/issues/186) +- [config file sorting](https://hexdocs.pm/styler/mix_configs.html#this-can-break-your-program) +- and likely other ways. stay safe out there! + ## Thanks & Inspiration ### [Sourceror](https://github.com/doorgan/sourceror/) From f53cf7c952b9bda912b85fa9de20d011607e7795 Mon Sep 17 00:00:00 2001 From: Kem Tekinay Date: Fri, 30 Aug 2024 13:49:21 -0400 Subject: [PATCH 009/145] Update styles.md Fixed example for "drop superfluous _..." --- docs/styles.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/styles.md b/docs/styles.md index d3d26497..69e04cea 100644 --- a/docs/styles.md +++ b/docs/styles.md @@ -259,7 +259,7 @@ case foo do end # Styled case foo do - _ = bar -> :ok + bar -> :ok end ``` From f2b269ece58ab4f550cb7f81bf6142d2efe11487 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Wed, 11 Sep 2024 09:05:34 -0600 Subject: [PATCH 010/145] Add Stream.run optimizations, fix optimizations shrinking pipes to one line (Closes #180) --- CHANGELOG.md | 10 ++++++++++ docs/pipes.md | 7 +++++++ lib/style/pipes.ex | 42 ++++++++++++++++++++++++++++++--------- test/style/pipes_test.exs | 39 +++++++++++++++++++++++++++++++++++- 4 files changed, 88 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e82a4c3..ce56518d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,16 @@ **Note** Styler's only public API is its usage as a formatter plugin. While you're welcome to play with its internals, they can and will change without that change being reflected in Styler's semantic version. +## main + +### Improvements + +* `pipes`: optimize `|> Stream.{each|map}(fun) |> Stream.run()` to `|> Enum.each(fun)` + +### Fixes + +* `pipes`: optimizations reducing 2 pipes to 1 no longer squeeze all pipes onto one line (#180) + ## 1.0.0 Styler's two biggest outstanding bugs have been fixed, both related to compilation breaking during module directive organization. One was references to aliases being moved above where the aliases were declared, and the other was similarly module directives being moved after their uses in module directives. diff --git a/docs/pipes.md b/docs/pipes.md index bcf98ef7..08673c94 100644 --- a/docs/pipes.md +++ b/docs/pipes.md @@ -98,6 +98,13 @@ a |> Map.new(mapper) |> ... a |> Enum.map(mapper) |> Enum.into(%{}) |> ... # Styled: a |> Map.new(mapper) |> ... + +# Given: +a |> b() |> Stream.each(fun) |> Stream.run() +a |> b() |> Stream.map(fun) |> Stream.run() +# Styled: +a |> b() |> Enum.each(fun) +a |> b() |> Enum.each(fun) ``` ### Unpiping Single Pipes diff --git a/lib/style/pipes.ex b/lib/style/pipes.ex index 034ffb2f..d2974650 100644 --- a/lib/style/pipes.ex +++ b/lib/style/pipes.ex @@ -159,8 +159,8 @@ defmodule Styler.Style.Pipes do # `pipe_chain(a, b, c)` generates the ast for `a |> b |> c` # the intention is to make it a little easier to see what the fix_pipe functions are matching on =) - defmacrop pipe_chain(a, b, c) do - quote do: {:|>, _, [{:|>, _, [unquote(a), unquote(b)]}, unquote(c)]} + defmacrop pipe_chain(pm, a, b, c) do + quote do: {:|>, unquote(pm), [{:|>, _, [unquote(a), unquote(b)]}, unquote(c)]} end # a |> fun => a |> fun() @@ -201,40 +201,58 @@ defmodule Styler.Style.Pipes do # `lhs |> Enum.reverse() |> Enum.concat(enum)` => `lhs |> Enum.reverse(enum)` defp fix_pipe( pipe_chain( + pm, lhs, {{:., _, [{_, _, [:Enum]}, :reverse]} = reverse, meta, []}, {{:., _, [{_, _, [:Enum]}, :concat]}, _, [enum]} ) ) do - {:|>, [line: meta[:line]], [lhs, {reverse, [line: meta[:line]], [enum]}]} + {:|>, pm, [lhs, {reverse, [line: meta[:line]], [enum]}]} end # `lhs |> Enum.reverse() |> Kernel.++(enum)` => `lhs |> Enum.reverse(enum)` defp fix_pipe( pipe_chain( + pm, lhs, {{:., _, [{_, _, [:Enum]}, :reverse]} = reverse, meta, []}, {{:., _, [{_, _, [:Kernel]}, :++]}, _, [enum]} ) ) do - {:|>, [line: meta[:line]], [lhs, {reverse, [line: meta[:line]], [enum]}]} + {:|>, pm, [lhs, {reverse, [line: meta[:line]], [enum]}]} end # `lhs |> Enum.filter(filterer) |> Enum.count()` => `lhs |> Enum.count(count)` defp fix_pipe( pipe_chain( + pm, lhs, {{:., _, [{_, _, [mod]}, :filter]}, meta, [filterer]}, {{:., _, [{_, _, [:Enum]}, :count]} = count, _, []} ) ) when mod in @enum do - {:|>, [line: meta[:line]], [lhs, {count, [line: meta[:line]], [filterer]}]} + {:|>, pm, [lhs, {count, [line: meta[:line]], [filterer]}]} + end + + # `lhs |> Stream.map(fun) |> Stream.run()` => `lhs |> Enum.each(fun)` + # `lhs |> Stream.each(fun) |> Stream.run()` => `lhs |> Enum.each(fun)` + defp fix_pipe( + pipe_chain( + pm, + lhs, + {{:., dm, [{a, am, [:Stream]}, map_or_each]}, fm, fa}, + {{:., _, [{_, _, [:Stream]}, :run]}, _, []} + ) + ) + when map_or_each in [:map, :each] do + {:|>, pm, [lhs, {{:., dm, [{a, am, [:Enum]}, :each]}, fm, fa}]} end # `lhs |> Enum.map(mapper) |> Enum.join(joiner)` => `lhs |> Enum.map_join(joiner, mapper)` defp fix_pipe( pipe_chain( + pm, lhs, {{:., dm, [{_, _, [mod]}, :map]}, em, map_args}, {{:., _, [{_, _, [:Enum]} = enum, :join]}, _, join_args} @@ -242,7 +260,7 @@ defmodule Styler.Style.Pipes do ) when mod in @enum do rhs = Style.set_line({{:., dm, [enum, :map_join]}, em, join_args ++ map_args}, dm[:line]) - {:|>, [line: dm[:line]], [lhs, rhs]} + {:|>, pm, [lhs, rhs]} end # `lhs |> Enum.map(mapper) |> Enum.into(empty_map)` => `lhs |> Map.new(mapper)` @@ -250,6 +268,7 @@ defmodule Styler.Style.Pipes do # `lhs |> Enum.map(mapper) |> Enum.into(collectable)` => `lhs |> Enum.into(collectable, mapper) defp fix_pipe( pipe_chain( + pm, lhs, {{:., dm, [{_, _, [mod]}, :map]}, _, [mapper]}, {{:., _, [{_, _, [:Enum]}, :into]} = into, _, [collectable]} @@ -268,15 +287,20 @@ defmodule Styler.Style.Pipes do {into, dm, [collectable, mapper]} end - Style.set_line({:|>, [], [lhs, rhs]}, dm[:line]) + Style.set_line({:|>, pm, [lhs, rhs]}, dm[:line]) end # `lhs |> Enum.map(mapper) |> Map.new()` => `lhs |> Map.new(mapper)` defp fix_pipe( - pipe_chain(lhs, {{:., _, [{_, _, [enum]}, :map]}, _, [mapper]}, {{:., _, [{_, _, [mod]}, :new]} = new, nm, []}) + pipe_chain( + pm, + lhs, + {{:., _, [{_, _, [enum]}, :map]}, _, [mapper]}, + {{:., _, [{_, _, [mod]}, :new]} = new, nm, []} + ) ) when mod in @collectable and enum in @enum do - Style.set_line({:|>, [], [lhs, {new, nm, [mapper]}]}, nm[:line]) + Style.set_line({:|>, pm, [lhs, {new, nm, [mapper]}]}, nm[:line]) end defp fix_pipe(node), do: node diff --git a/test/style/pipes_test.exs b/test/style/pipes_test.exs index 7b5ddd20..5bd24c93 100644 --- a/test/style/pipes_test.exs +++ b/test/style/pipes_test.exs @@ -589,11 +589,14 @@ defmodule Styler.Style.PipesTest do assert_style( """ a + |> b() |> #{enum}.filter(fun) |> Enum.count() """, """ - Enum.count(a, fun) + a + |> b() + |> Enum.count(fun) """ ) @@ -621,9 +624,43 @@ defmodule Styler.Style.PipesTest do end end + test "Stream.{each/map}/Stream.run" do + assert_style("a |> Stream.each(fun) |> Stream.run()", "Enum.each(a, fun)") + assert_style("a |> Stream.map(fun) |> Stream.run()", "Enum.each(a, fun)") + + assert_style( + """ + a + |> foo + |> Stream.map(fun) + |> Stream.run() + """, + """ + a + |> foo() + |> Enum.each(fun) + """ + ) + end + test "map/join" do for enum <- ~w(Enum Stream) do assert_style("a |> #{enum}.map(mapper) |> Enum.join()", "Enum.map_join(a, mapper)") + + assert_style( + """ + a + |> b() + |> #{enum}.map(mapper) + |> Enum.join() + """, + """ + a + |> b() + |> Enum.map_join(mapper) + """ + ) + assert_style("a |> #{enum}.map(mapper) |> Enum.join(joiner)", "Enum.map_join(a, joiner, mapper)") end end From d6c90cdd12118b1831622a01986a4cfa20a9f4b5 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Mon, 23 Sep 2024 10:35:17 -0600 Subject: [PATCH 011/145] fix infinite loop rewriting negated if with empty do body. closes #196 --- CHANGELOG.md | 1 + lib/style/blocks.ex | 4 ++++ test/style/blocks_test.exs | 18 ++++++++++++++++++ 3 files changed, 23 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce56518d..ce20b8d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ they can and will change without that change being reflected in Styler's semanti ### Fixes * `pipes`: optimizations reducing 2 pipes to 1 no longer squeeze all pipes onto one line (#180) +* `if`: fix infinite loop rewriting negated if with empty do body `if x != y, do: (), else: :ok` (#196, h/t @itamm15) ## 1.0.0 diff --git a/lib/style/blocks.ex b/lib/style/blocks.ex index dbfe5ab8..ffb0ba0e 100644 --- a/lib/style/blocks.ex +++ b/lib/style/blocks.ex @@ -201,6 +201,10 @@ defmodule Styler.Style.Blocks do [negator, [do_block]] when is_negator(negator) -> zipper |> Zipper.replace({:unless, m, [invert(negator), [do_block]]}) |> run(ctx) + # drop `else end` + [head, [do_block, {_, {:__block__, _, []}}]] -> + {:cont, Zipper.replace(zipper, {:if, m, [head, [do_block]]}), ctx} + # drop `else: nil` [head, [do_block, {_, {:__block__, _, [nil]}}]] -> {:cont, Zipper.replace(zipper, {:if, m, [head, [do_block]]}), ctx} diff --git a/test/style/blocks_test.exs b/test/style/blocks_test.exs index 8444666f..400fd991 100644 --- a/test/style/blocks_test.exs +++ b/test/style/blocks_test.exs @@ -839,6 +839,24 @@ defmodule Styler.Style.BlocksTest do assert_style("if !!x, do: y", "if x, do: y") end + test "regression: negation with empty do body" do + assert_style( + """ + if a != b do + # comment + else + :ok + end + """, + """ + if a == b do + # comment + :ok + end + """ + ) + end + test "Credo.Check.Refactor.UnlessWithElse" do for negator <- ["!", "not "] do assert_style( From cde90d3b1b8624a065347b60e6048f3b9e22c8f9 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Mon, 23 Sep 2024 10:58:57 -0600 Subject: [PATCH 012/145] Remove unless from codebases (#194) --- CHANGELOG.md | 3 + docs/control_flow_macros.md | 7 +- lib/style/blocks.ex | 42 +++++----- lib/style/module_directives.ex | 2 +- lib/style/pipes.ex | 2 + test/style/blocks_test.exs | 139 +++++++++++++++++---------------- test/style/pipes_test.exs | 6 +- test/support/style_case.ex | 4 +- 8 files changed, 110 insertions(+), 95 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce20b8d5..616ca034 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ they can and will change without that change being reflected in Styler's semanti ### Improvements +The big change here is the rewrite/removal of `unless` due to [unless "eventually" being deprecated](https://github.com/elixir-lang/elixir/pull/13769#issuecomment-2334878315). Thanks to @janpieper and @ypconstante for bringing this up in #190. + +* `unless`: rewrite all `unless` to `if` (#190) * `pipes`: optimize `|> Stream.{each|map}(fun) |> Stream.run()` to `|> Enum.each(fun)` ### Fixes diff --git a/docs/control_flow_macros.md b/docs/control_flow_macros.md index 39243034..70a85460 100644 --- a/docs/control_flow_macros.md +++ b/docs/control_flow_macros.md @@ -11,13 +11,15 @@ The number of "blocks" in Elixir means there are many ways to write semantically We believe readability is enhanced by using the simplest api possible, whether we're talking about internal module function calls or standard-library macros. -### use `case`, `if`, or `unless` when... +### use `case`, `if`, or `cond` when... We advocate for `case` and `if` as the first tools to be considered for any control flow as they are the two simplest blocks. If a branch _can_ be expressed with an `if` statement, it _should_ be. Otherwise, `case` is the next best choice. In situations where developers might reach for an `if/elseif/else` block in other languages, `cond do` should be used. (`cond do` seems to see a paucity of use in the language, but many complex nested expressions or with statements can be improved by replacing them with a `cond do`). -`unless` is a special case of `if` meant to make code read as natural-language (citation needed). While it sometimes succeeds in this goal, its absence in most programming languages often makes it feel cumbersome to programmers with non-Ruby backgrounds. Thankfully, with Styler's help developers don't need to ever reach for `unless` - expressions that are "simpler" with its use are automatically rewritten to use it. +### use `unless` when... + +Never! `unless` [is being deprecated](https://github.com/elixir-lang/elixir/pull/13769#issuecomment-2334878315) and so should not be used. ### use `with` when... @@ -25,7 +27,6 @@ We advocate for `case` and `if` as the first tools to be considered for any cont > > - Uncle Ben - As the most powerful of the Kernel control-flow expressions, `with` requires the most cognitive overhead to understand. Its power means that we can use it as a replacement for anything we might express using a `case`, `if`, or `cond` (especially with the liberal application of small private helper functions). Unfortunately, this has lead to a proliferation of `with` in codebases where simpler expressions would have sufficed, meaning a lot of Elixir code ends up being harder for readers to understand than it needs to be. diff --git a/lib/style/blocks.ex b/lib/style/blocks.ex index ffb0ba0e..af2d6a89 100644 --- a/lib/style/blocks.ex +++ b/lib/style/blocks.ex @@ -176,31 +176,24 @@ defmodule Styler.Style.Blocks do end end - def run({{:unless, m, children}, _} = zipper, ctx) do - case children do - # Credo.Check.Refactor.UnlessWithElse - [{_, hm, _} = head, [_, _] = do_else] -> - zipper |> Zipper.replace({:if, m, [{:!, hm, [head]}, do_else]}) |> run(ctx) - - # Credo.Check.Refactor.NegatedConditionsInUnless - [negator, [{do_, do_body}]] when is_negator(negator) -> - zipper |> Zipper.replace({:if, m, [invert(negator), [{do_, do_body}]]}) |> run(ctx) - - _ -> - {:cont, zipper, ctx} - 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) - # if not x, do: y => unless x, do: y - [negator, [do_block]] when is_negator(negator) -> - zipper |> Zipper.replace({:unless, m, [invert(negator), [do_block]]}) |> run(ctx) - # drop `else end` [head, [do_block, {_, {:__block__, _, []}}]] -> {:cont, Zipper.replace(zipper, {:if, m, [head, [do_block]]}), ctx} @@ -268,7 +261,7 @@ defmodule Styler.Style.Blocks do # c # d # ) - # @TODO would be nice to changeto + # @TODO would be nice to change to # a # b # c @@ -331,5 +324,16 @@ defmodule Styler.Style.Blocks do defp invert({:!=, m, [a, b]}), do: {:==, m, [a, b]} defp invert({:!==, m, [a, b]}), do: {:===, m, [a, b]} - defp invert({_, _, [expr]}), do: expr + 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({fun, m, _} = ast) do + meta = [line: m[:line]] + + if fun == :|>, + do: {:|>, meta, [ast, {{:., meta, [Kernel, :!]}, meta, []}]}, + else: {:!, meta, [ast]} + end end diff --git a/lib/style/module_directives.ex b/lib/style/module_directives.ex index e385eb9d..623ce5f0 100644 --- a/lib/style/module_directives.ex +++ b/lib/style/module_directives.ex @@ -135,7 +135,7 @@ defmodule Styler.Style.ModuleDirectives do defp moduledoc({:__aliases__, m, aliases}) do name = aliases |> List.last() |> to_string() # module names ending with these suffixes will not have a default moduledoc appended - unless String.ends_with?(name, ~w(Test Mixfile MixProject Controller Endpoint Repo Router Socket View HTML JSON)) do + if !String.ends_with?(name, ~w(Test Mixfile MixProject Controller Endpoint Repo Router Socket View HTML JSON)) do Style.set_line(@moduledoc_false, m[:line] + 1) end end diff --git a/lib/style/pipes.ex b/lib/style/pipes.ex index d2974650..20761b3a 100644 --- a/lib/style/pipes.ex +++ b/lib/style/pipes.ex @@ -128,6 +128,8 @@ defmodule Styler.Style.Pipes do # |> ... var_name = case fun do + # unless will be rewritten to `if` statements in the Blocks Style + :unless -> :if fun when is_atom(fun) -> fun {:., _, [{:__aliases__, _, _}, fun]} when is_atom(fun) -> fun _ -> "block" diff --git a/test/style/blocks_test.exs b/test/style/blocks_test.exs index 400fd991..14fd9e85 100644 --- a/test/style/blocks_test.exs +++ b/test/style/blocks_test.exs @@ -822,42 +822,25 @@ defmodule Styler.Style.BlocksTest do end end - describe "if/unless" do - test "drops if else nil" do - assert_style("if a, do: b, else: nil", "if a, do: b") - - assert_style("if a do b else nil end", """ - if a do - b - end - """) - end - - test "if not => unless" do - assert_style("if not x, do: y", "unless x, do: y") - assert_style("if !x, do: y", "unless x, do: y") - assert_style("if !!x, do: y", "if x, do: y") - end - - test "regression: negation with empty do body" do + describe "unless to if" do + test "inverts all the things" do assert_style( """ - if a != b do - # comment + unless !! not true do + a else - :ok + b end """, """ - if a == b do - # comment - :ok + if true do + a + else + b end """ ) - end - test "Credo.Check.Refactor.UnlessWithElse" do for negator <- ["!", "not "] do assert_style( """ @@ -912,9 +895,7 @@ defmodule Styler.Style.BlocksTest do end """ ) - end - test "Credo.Check.Refactor.NegatedConditionsInUnless" do for negator <- ["!", "not "] do assert_style("unless #{negator} foo, do: :bar", "if foo, do: :bar") @@ -950,7 +931,69 @@ defmodule Styler.Style.BlocksTest do end end - test "Credo.Check.Refactor.NegatedConditionsWithElse" do + test "unless with pipes" do + assert_style "unless a |> b() |> c(), do: x", "if a |> b() |> c() |> Kernel.!(), do: x" + end + end + + describe "if" do + test "drops else nil" do + assert_style("if a, do: b, else: nil", "if a, do: b") + + assert_style("if a do b else nil end", """ + if a do + b + end + """) + + assert_style( + """ + if a != b do + # comment + else + :ok + end + """, + """ + if a == b do + # comment + :ok + end + """ + ) + end + + test "double negator rewrites" do + for a <- ~w(not !), block <- ["do: z", "do: z, else: zz"] do + assert_style "if #{a} (x != y), #{block}", "if x == y, #{block}" + assert_style "if #{a} (x !== y), #{block}", "if x === y, #{block}" + assert_style "if #{a} ! x, #{block}", "if x, #{block}" + assert_style "if #{a} not x, #{block}", "if x, #{block}" + end + + assert_style("if not x, do: y", "if not x, do: y") + assert_style("if !x, do: y", "if !x, do: y") + + assert_style( + """ + if !!val do + a + else + b + end + """, + """ + if val do + a + else + b + end + """ + ) + end + + test "single negator do/else swaps" do + # covers Credo.Check.Refactor.NegatedConditionsWithElse for negator <- ["!", "not "] do assert_style("if #{negator}foo, do: :bar, else: :baz", "if foo, do: :baz, else: :bar") @@ -994,44 +1037,6 @@ defmodule Styler.Style.BlocksTest do end end - test "recurses" do - assert_style( - """ - if !!val do - a - else - b - end - """, - """ - if val do - a - else - b - end - """ - ) - - assert_style( - """ - unless !! not true do - a - else - b - end - """, - """ - if true do - a - else - b - end - """ - ) - - assert_style("if not (a != b), do: c", "if a == b, do: c") - end - test "comments and flips" do assert_style( """ diff --git a/test/style/pipes_test.exs b/test/style/pipes_test.exs index 5bd24c93..49763ea5 100644 --- a/test/style/pipes_test.exs +++ b/test/style/pipes_test.exs @@ -228,12 +228,12 @@ defmodule Styler.Style.PipesTest do |> wee() """, """ - unless_result = - unless foo do + if_result = + if !foo do bar end - wee(unless_result) + wee(if_result) """ ) end diff --git a/test/support/style_case.ex b/test/support/style_case.ex index 61a11f2a..f87c7bd3 100644 --- a/test/support/style_case.ex +++ b/test/support/style_case.ex @@ -70,7 +70,7 @@ defmodule Styler.StyleCase do # body blocks - for example, the block node for an anonymous function - don't have line meta # yes, i just did `&& case`. sometimes it's funny to write ugly things in my project that's all about style. # i believe they calls that one "irony" - is_body_block? = + body_block? = node == :__block__ && case up && Zipper.node(up) do # top of a snippet @@ -99,7 +99,7 @@ defmodule Styler.StyleCase do end end - unless line || is_body_block? do + if is_nil(line) and not body_block? do IO.puts("missing `:line` meta in node:") dbg(ast) From 926ad515207e4ceb58305bd580856a768c0daa21 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Mon, 23 Sep 2024 11:16:32 -0600 Subject: [PATCH 013/145] use `not` over `!` when rewriting `unless a (> >= < <= in) b` --- lib/style/blocks.ex | 11 +++-------- test/style/blocks_test.exs | 8 ++++++++ 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/lib/style/blocks.ex b/lib/style/blocks.ex index af2d6a89..659e2419 100644 --- a/lib/style/blocks.ex +++ b/lib/style/blocks.ex @@ -328,12 +328,7 @@ defmodule Styler.Style.Blocks do defp invert({:===, m, [a, b]}), do: {:!==, m, [a, b]} defp invert({:!, _, [condition]}), do: condition defp invert({:not, _, [condition]}), do: condition - - defp invert({fun, m, _} = ast) do - meta = [line: m[:line]] - - if fun == :|>, - do: {:|>, meta, [ast, {{:., meta, [Kernel, :!]}, meta, []}]}, - else: {:!, meta, [ast]} - end + defp invert({:|>, m, _} = ast), do: {:|>, m, [ast, {{:., m, [Kernel, :!]}, m, []}]} + defp invert({bool, m, [_, _]} = ast) when bool in ~w(> >= < <= in)a, do: {:not, m, [ast]} + defp invert({_, m, _} = ast), do: {:!, [line: m[:line]], [ast]} end diff --git a/test/style/blocks_test.exs b/test/style/blocks_test.exs index 14fd9e85..f93a1800 100644 --- a/test/style/blocks_test.exs +++ b/test/style/blocks_test.exs @@ -934,6 +934,14 @@ defmodule Styler.Style.BlocksTest do test "unless with pipes" do assert_style "unless a |> b() |> c(), do: x", "if a |> b() |> c() |> Kernel.!(), do: x" end + + test "kernel boolean operators" do + assert_style "unless a in b, do: x", "if a not in b, do: x" + + for bool <- ~w(> >= < <=)a do + assert_style "unless a #{bool} b, do: x", "if not (a #{bool} b), do: x" + end + end end describe "if" do From 912515793ca41716206a2b2ac41e96b79c288d8e Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Mon, 23 Sep 2024 11:19:05 -0600 Subject: [PATCH 014/145] actually, lets only do `not` for `in` --- lib/style/blocks.ex | 2 +- test/style/blocks_test.exs | 6 +----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/lib/style/blocks.ex b/lib/style/blocks.ex index 659e2419..9763d4d3 100644 --- a/lib/style/blocks.ex +++ b/lib/style/blocks.ex @@ -329,6 +329,6 @@ defmodule Styler.Style.Blocks do defp invert({:!, _, [condition]}), do: condition defp invert({:not, _, [condition]}), do: condition defp invert({:|>, m, _} = ast), do: {:|>, m, [ast, {{:., m, [Kernel, :!]}, m, []}]} - defp invert({bool, m, [_, _]} = ast) when bool in ~w(> >= < <= in)a, do: {:not, m, [ast]} + defp invert({:in, m, [_, _]} = ast), do: {:not, m, [ast]} defp invert({_, m, _} = ast), do: {:!, [line: m[:line]], [ast]} end diff --git a/test/style/blocks_test.exs b/test/style/blocks_test.exs index f93a1800..7c140f05 100644 --- a/test/style/blocks_test.exs +++ b/test/style/blocks_test.exs @@ -935,12 +935,8 @@ defmodule Styler.Style.BlocksTest do assert_style "unless a |> b() |> c(), do: x", "if a |> b() |> c() |> Kernel.!(), do: x" end - test "kernel boolean operators" do + test "in" do assert_style "unless a in b, do: x", "if a not in b, do: x" - - for bool <- ~w(> >= < <=)a do - assert_style "unless a #{bool} b, do: x", "if not (a #{bool} b), do: x" - end end end From 42b5d4031650391e6dcb3d963ecb082787a289ff Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Mon, 23 Sep 2024 11:20:32 -0600 Subject: [PATCH 015/145] v1.1.0 --- CHANGELOG.md | 3 ++- mix.exs | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 616ca034..35791058 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,10 @@ **Note** Styler's only public API is its usage as a formatter plugin. While you're welcome to play with its internals, they can and will change without that change being reflected in Styler's semantic version. - ## main +## 1.1.0 + ### Improvements The big change here is the rewrite/removal of `unless` due to [unless "eventually" being deprecated](https://github.com/elixir-lang/elixir/pull/13769#issuecomment-2334878315). Thanks to @janpieper and @ypconstante for bringing this up in #190. diff --git a/mix.exs b/mix.exs index 02eba042..a74b30cf 100644 --- a/mix.exs +++ b/mix.exs @@ -12,7 +12,7 @@ defmodule Styler.MixProject do use Mix.Project # Don't forget to bump the README when doing non-patch version changes - @version "1.0.0" + @version "1.1.0" @url "https://github.com/adobe/elixir-styler" def project do From 79dce095e831411a9b7a0fd60a7181914ee99333 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Mon, 23 Sep 2024 12:38:34 -0600 Subject: [PATCH 016/145] update version in readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c2350650..f6978a52 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Add `:styler` as a dependency to your project's `mix.exs`: ```elixir def deps do [ - {:styler, "~> 1.0.0-rc.1", only: [:dev, :test], runtime: false}, + {:styler, "~> 1.1", only: [:dev, :test], runtime: false}, ] end ``` From 4aa18ba4fb7a2232bbe337fe43afff4e1387d67b Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Mon, 23 Sep 2024 13:42:41 -0600 Subject: [PATCH 017/145] Don't pipe into `Kernel.!` when rewriting unless with pipes --- CHANGELOG.md | 6 ++++++ lib/style/blocks.ex | 1 - mix.exs | 2 +- test/style/blocks_test.exs | 2 +- 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 35791058..528daf90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ they can and will change without that change being reflected in Styler's semantic version. ## main +## 1.1.1 + +### Improvements + +* `unless`: rewrite `unless a |> b |> c` as `unless !(a |> b() |> c())` rather than `unless a |> b() |> c() |> Kernel.!()` (h/t @gregmefford) + ## 1.1.0 ### Improvements diff --git a/lib/style/blocks.ex b/lib/style/blocks.ex index 9763d4d3..ce3a849c 100644 --- a/lib/style/blocks.ex +++ b/lib/style/blocks.ex @@ -328,7 +328,6 @@ defmodule Styler.Style.Blocks do defp invert({:===, m, [a, b]}), do: {:!==, m, [a, b]} defp invert({:!, _, [condition]}), do: condition defp invert({:not, _, [condition]}), do: condition - defp invert({:|>, m, _} = ast), do: {:|>, m, [ast, {{:., m, [Kernel, :!]}, m, []}]} defp invert({:in, m, [_, _]} = ast), do: {:not, m, [ast]} defp invert({_, m, _} = ast), do: {:!, [line: m[:line]], [ast]} end diff --git a/mix.exs b/mix.exs index a74b30cf..edf369cb 100644 --- a/mix.exs +++ b/mix.exs @@ -12,7 +12,7 @@ defmodule Styler.MixProject do use Mix.Project # Don't forget to bump the README when doing non-patch version changes - @version "1.1.0" + @version "1.1.1" @url "https://github.com/adobe/elixir-styler" def project do diff --git a/test/style/blocks_test.exs b/test/style/blocks_test.exs index 7c140f05..fae1144b 100644 --- a/test/style/blocks_test.exs +++ b/test/style/blocks_test.exs @@ -932,7 +932,7 @@ defmodule Styler.Style.BlocksTest do end test "unless with pipes" do - assert_style "unless a |> b() |> c(), do: x", "if a |> b() |> c() |> Kernel.!(), do: x" + assert_style "unless a |> b() |> c(), do: x", "if !(a |> b() |> c()), do: x" end test "in" do From 7fb05fa64e3e752027736186755c28a7cc911d76 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Fri, 18 Oct 2024 12:26:18 -0600 Subject: [PATCH 018/145] configs: improve comment handling when moving a small number of nodes. Closes #187 --- CHANGELOG.md | 4 ++++ lib/style/configs.ex | 34 +++++++++++++--------------------- 2 files changed, 17 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 528daf90..ce09af45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ they can and will change without that change being reflected in Styler's semantic version. ## main +### Improvements + +* Config Sorting: improve comment handling when only sorting a few nodes (Closes #187) + ## 1.1.1 ### Improvements diff --git a/lib/style/configs.ex b/lib/style/configs.ex index 9725bcf4..ab110a0b 100644 --- a/lib/style/configs.ex +++ b/lib/style/configs.ex @@ -85,21 +85,16 @@ defmodule Styler.Style.Configs do # so i'm trying to guess which change will be less damaging. # moving >=3 nodes hints that this is an initial run, where `set_lines` definitely outperforms. {nodes, comments} = - case change_count(nodes) do - 0 -> - {nodes, comments} - - n when n < 3 -> - {Style.fix_line_numbers(nodes, List.last(rest)), comments} - - _ -> - # after running, this block should take up the same # of lines that it did before - # the first node of `rest` is greater than the highest line in configs, assignments - # config line is the first line to be used as part of this block - # that will change when we consider preceding comments - {node_comments, _} = comments_for_node(config, comments) - first_line = min(List.last(node_comments)[:line] || cfm[:line], cfm[:line]) - set_lines(nodes, comments, first_line) + if changed?(nodes) do + # after running, this block should take up the same # of lines that it did before + # the first node of `rest` is greater than the highest line in configs, assignments + # config line is the first line to be used as part of this block + # that will change when we consider preceding comments + {node_comments, _} = comments_for_node(config, comments) + first_line = min(List.last(node_comments)[:line] || cfm[:line], cfm[:line]) + set_lines(nodes, comments, first_line) + else + {nodes, comments} end [config | left_siblings] = Enum.reverse(nodes, zm.l) @@ -120,14 +115,11 @@ defmodule Styler.Style.Configs do end end - defp change_count(nodes, n \\ 0) - - defp change_count([{_, am, _}, {_, bm, _} = b | tail], n) do - n = if am[:line] > bm[:line], do: n + 1, else: n - change_count([b | tail], n) + defp changed?([{_, am, _}, {_, bm, _} = b | tail]) do + if am[:line] > bm[:line], do: true, else: changed?([b | tail]) end - defp change_count(_, n), do: n + defp changed?(_), do: false defp set_lines(nodes, comments, first_line) do {nodes, comments, node_comments} = set_lines(nodes, comments, first_line, [], []) From b38b9906abb19587eb4b263df24b248fa2f4ba27 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Fri, 18 Oct 2024 12:26:39 -0600 Subject: [PATCH 019/145] v1.1.2 --- CHANGELOG.md | 2 ++ mix.exs | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce09af45..ae6678cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ they can and will change without that change being reflected in Styler's semantic version. ## main +## 1.1.2 + ### Improvements * Config Sorting: improve comment handling when only sorting a few nodes (Closes #187) diff --git a/mix.exs b/mix.exs index edf369cb..2cabf780 100644 --- a/mix.exs +++ b/mix.exs @@ -12,7 +12,7 @@ defmodule Styler.MixProject do use Mix.Project # Don't forget to bump the README when doing non-patch version changes - @version "1.1.1" + @version "1.1.2" @url "https://github.com/adobe/elixir-styler" def project do From 548fbf6cd8401b9ad29254c851d35c176535455e Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Fri, 18 Oct 2024 13:06:57 -0600 Subject: [PATCH 020/145] pipes: improve comment behaviour in optimizations (Closes #176) --- CHANGELOG.md | 4 ++ lib/style/pipes.ex | 18 +++--- test/style/pipes_test.exs | 116 ++++++++++++++++++++++++++++++-------- 3 files changed, 106 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae6678cd..15db7758 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ they can and will change without that change being reflected in Styler's semantic version. ## main +### Fixes + +* `pipes`: optimizations are less likely to move comments (Closes #176) + ## 1.1.2 ### Improvements diff --git a/lib/style/pipes.ex b/lib/style/pipes.ex index 20761b3a..9aecaed3 100644 --- a/lib/style/pipes.ex +++ b/lib/style/pipes.ex @@ -261,7 +261,7 @@ defmodule Styler.Style.Pipes do ) ) when mod in @enum do - rhs = Style.set_line({{:., dm, [enum, :map_join]}, em, join_args ++ map_args}, dm[:line]) + rhs = {{:., dm, [enum, :map_join]}, em, Style.set_line(join_args, dm[:line]) ++ map_args} {:|>, pm, [lhs, rhs]} end @@ -272,7 +272,7 @@ defmodule Styler.Style.Pipes do pipe_chain( pm, lhs, - {{:., dm, [{_, _, [mod]}, :map]}, _, [mapper]}, + {{:., dm, [{_, _, [mod]}, :map]}, em, [mapper]}, {{:., _, [{_, _, [:Enum]}, :into]} = into, _, [collectable]} ) ) @@ -280,16 +280,16 @@ defmodule Styler.Style.Pipes do rhs = case collectable do {{:., _, [{_, _, [mod]}, :new]}, _, []} when mod in @collectable -> - {{:., dm, [{:__aliases__, dm, [mod]}, :new]}, dm, [mapper]} + {{:., dm, [{:__aliases__, dm, [mod]}, :new]}, em, [mapper]} {:%{}, _, []} -> - {{:., dm, [{:__aliases__, dm, [:Map]}, :new]}, dm, [mapper]} + {{:., dm, [{:__aliases__, dm, [:Map]}, :new]}, em, [mapper]} _ -> - {into, dm, [collectable, mapper]} + {into, em, [Style.set_line(collectable, dm[:line]), mapper]} end - Style.set_line({:|>, pm, [lhs, rhs]}, dm[:line]) + {:|>, pm, [lhs, rhs]} end # `lhs |> Enum.map(mapper) |> Map.new()` => `lhs |> Map.new(mapper)` @@ -297,12 +297,12 @@ defmodule Styler.Style.Pipes do pipe_chain( pm, lhs, - {{:., _, [{_, _, [enum]}, :map]}, _, [mapper]}, - {{:., _, [{_, _, [mod]}, :new]} = new, nm, []} + {{:., _, [{_, _, [enum]}, :map]}, em, [mapper]}, + {{:., _, [{_, _, [mod]}, :new]} = new, _, []} ) ) when mod in @collectable and enum in @enum do - Style.set_line({:|>, pm, [lhs, {new, nm, [mapper]}]}, nm[:line]) + {:|>, pm, [lhs, {Style.set_line(new, em[:line]), em, [mapper]}]} end defp fix_pipe(node), do: node diff --git a/test/style/pipes_test.exs b/test/style/pipes_test.exs index 49763ea5..5c363551 100644 --- a/test/style/pipes_test.exs +++ b/test/style/pipes_test.exs @@ -749,29 +749,99 @@ defmodule Styler.Style.PipesTest do describe "comments" do test "unpiping doesn't move comment in anonymous function" do - assert_style """ - aliased = - aliases - |> MapSet.new(fn - {:alias, _, [{:__aliases__, _, aliases}]} -> List.last(aliases) - {:alias, _, [{:__aliases__, _, _}, [{_as, {:__aliases__, _, [as]}}]]} -> as - # alias __MODULE__ or other oddities - {:alias, _, _} -> nil - end) - - excluded_first = MapSet.union(aliased, @excluded_namespaces) - """, - """ - aliased = - MapSet.new(aliases, fn - {:alias, _, [{:__aliases__, _, aliases}]} -> List.last(aliases) - {:alias, _, [{:__aliases__, _, _}, [{_as, {:__aliases__, _, [as]}}]]} -> as - # alias __MODULE__ or other oddities - {:alias, _, _} -> nil - end) - - excluded_first = MapSet.union(aliased, @excluded_namespaces) - """ + assert_style( + """ + aliased = + aliases + |> MapSet.new(fn + {:alias, _, [{:__aliases__, _, aliases}]} -> List.last(aliases) + {:alias, _, [{:__aliases__, _, _}, [{_as, {:__aliases__, _, [as]}}]]} -> as + # alias __MODULE__ or other oddities + {:alias, _, _} -> nil + end) + + excluded_first = MapSet.union(aliased, @excluded_namespaces) + """, + """ + aliased = + MapSet.new(aliases, fn + {:alias, _, [{:__aliases__, _, aliases}]} -> List.last(aliases) + {:alias, _, [{:__aliases__, _, _}, [{_as, {:__aliases__, _, [as]}}]]} -> as + # alias __MODULE__ or other oddities + {:alias, _, _} -> nil + end) + + excluded_first = MapSet.union(aliased, @excluded_namespaces) + """ + ) end end + + test "optimizing with comments" do + assert_style( + """ + a + |> Enum.map(fn b -> + c + # a comment + d + end) + |> Enum.join(x) + |> Enum.each(...) + """, + """ + a + |> Enum.map_join(x, fn b -> + c + # a comment + d + end) + |> Enum.each(...) + """ + ) + + assert_style( + """ + a + |> Enum.map(fn b -> + c + # a comment + d + end) + |> Enum.into(x) + |> Enum.each(...) + """, + """ + a + |> Enum.into(x, fn b -> + c + # a comment + d + end) + |> Enum.each(...) + """ + ) + + assert_style( + """ + a + |> Enum.map(fn b -> + c + # a comment + d + end) + |> Keyword.new() + |> Enum.each(...) + """, + """ + a + |> Keyword.new(fn b -> + c + # a comment + d + end) + |> Enum.each(...) + """ + ) + end end From fe32a848e8b60ee2b663694158e59cc484e8bc93 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Thu, 7 Nov 2024 11:20:55 -0700 Subject: [PATCH 021/145] One-line unpiped assignments (#197) Closes #181 --- CHANGELOG.md | 4 ++ lib/style.ex | 30 +++++++++---- lib/style/pipes.ex | 91 ++++++++++++++++++++++++++++++-------- test/style/pipes_test.exs | 93 +++++++++++++++++++++++++++++++++++---- 4 files changed, 183 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15db7758..11460ae8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ they can and will change without that change being reflected in Styler's semantic version. ## main +### Improvements + +* `pipes`: unpiping assignments will make the assignment one-line when possible (Closes #181) + ### Fixes * `pipes`: optimizations are less likely to move comments (Closes #176) diff --git a/lib/style.ex b/lib/style.ex index 0f20ced8..6368293c 100644 --- a/lib/style.ex +++ b/lib/style.ex @@ -144,7 +144,7 @@ defmodule Styler.Style do comments |> Enum.map(fn comment -> if delta = Enum.find_value(shifts, fn {range, delta} -> comment.line in range && delta end) do - %{comment | line: comment.line + delta} + %{comment | line: max(comment.line + delta, 1)} else comment end @@ -230,14 +230,28 @@ defmodule Styler.Style do else: do_fix_lines(nodes, line, [node | acc]) end - # @TODO can i shortcut and just return end_of_expression[:line] when it's available? + def max_line([_ | _] = list), do: list |> List.last() |> max_line() + def max_line(ast) do - {_, max_line} = - Macro.prewalk(ast, 0, fn - {_, meta, _} = ast, max -> {ast, max(meta[:line] || max, max)} - ast, max -> {ast, max} - end) + meta = + case ast do + {_, meta, _} -> + meta + + _ -> + [] + end - max_line + if max_line = meta[:closing][:line] do + max_line + else + {_, max_line} = + Macro.prewalk(ast, 0, fn + {_, meta, _} = ast, max -> {ast, max(meta[:line] || max, max)} + ast, max -> {ast, max} + end) + + max_line + end end end diff --git a/lib/style/pipes.ex b/lib/style/pipes.ex index 9aecaed3..b6204ca7 100644 --- a/lib/style/pipes.ex +++ b/lib/style/pipes.ex @@ -50,25 +50,79 @@ defmodule Styler.Style.Pipes do {{:|>, _, [_, {:unquote, _, [_]}]}, _} = single_pipe_unquote_zipper -> {:cont, single_pipe_unquote_zipper, ctx} + # unpipe a single pipe zipper {{:|>, _, [lhs, rhs]}, _} = single_pipe_zipper -> - {_, meta, _} = lhs - # try to get everything on one line if we can - line = meta[:line] - {fun, meta, args} = rhs + {fun, rhs_meta, args} = rhs + {_, lhs_meta, _} = lhs + lhs_line = lhs_meta[:line] args = args || [] - - # no way multi-headed fn fits on one line; everything else (?) is just a matter of line length - args = - if Enum.any?(args, &match?({:fn, _, [{:->, _, _}, {:->, _, _} | _]}, &1)) do - Style.shift_line(args, -1) - else - Style.set_line(args, line) + # Every branch ends with the zipper being replaced with a function call + # `lhs |> rhs(...args)` => `rhs(lhs, ...args)` + # The differences are just figuring out what line number updates to make + # in order to get the following properties: + # + # 1. write the function call on one line if reasonable + # 2. keep comments well behaved (by doing meta line-number gymnastics) + + # if we see multiple `->`, there's no way we can online this + # future heuristics would include finding multiple lines + definitively_multiline? = + Enum.any?(args, fn + {:fn, _, [{:->, _, _}, {:->, _, _} | _]} -> true + {:fn, _, [{:->, _, [_, _]}]} -> true + _ -> false + end) + + if definitively_multiline? do + # shift rhs up to hang out with lhs + # 1 lhs + # 2 |> fun( + # 3 ...args... + # n ) + # => + # 1 fun(lhs + # 2 ... args... + # n-1 ) + + # because there could be comments between lhs and rhs, or the dev may have a bunch of empty lines, + # we need to calculate the distance between the two ("shift") + rhs_line = rhs_meta[:line] + shift = lhs_line - rhs_line + {fun, meta, args} = Style.shift_line(rhs, shift) + + # Not going to lie, no idea why the `shift + 1` is correct but it makes tests pass ¯\_(ツ)_/¯ + rhs_max_line = Style.max_line(rhs) + + comments = + ctx.comments + |> Style.displace_comments(lhs_line..(rhs_line - 1)) + |> Style.shift_comments(rhs_line..rhs_max_line, shift + 1) + + {:cont, Zipper.replace(single_pipe_zipper, {fun, meta, [lhs | args]}), %{ctx | comments: comments}} + else + # try to get everything on one line. + # formatter will kick it back to multiple if line-length doesn't accommodate + case Zipper.up(single_pipe_zipper) do + # if the parent is an assignment, put it on the same line as the `=` + {{:=, am, [{_, vm, _} = var, _single_pipe]}, _} = assignment_parent -> + # 1 var = + # 2 lhs + # 3 |> rhs(...args) + # => + # 1 var = rhs(lhs, ...args) + oneline_assignment = Style.set_line({:=, am, [var, {fun, rhs_meta, [lhs | args]}]}, vm[:line]) + # skip so we don't re-traverse + {:cont, Zipper.replace(assignment_parent, oneline_assignment), ctx} + + _ -> + # lhs + # |> rhs(...args) + # => + # rhs(lhs, ...) + oneline_function_call = Style.set_line({fun, rhs_meta, [lhs | args]}, lhs_line) + {:cont, Zipper.replace(single_pipe_zipper, oneline_function_call), ctx} end - - lhs = Style.set_line(lhs, line) - {_, meta, _} = Style.set_line({:ignore, meta, []}, line) - function_call_zipper = Zipper.replace(single_pipe_zipper, {fun, meta, [lhs | args]}) - {:cont, function_call_zipper, ctx} + end end non_pipe -> @@ -162,7 +216,7 @@ defmodule Styler.Style.Pipes do # `pipe_chain(a, b, c)` generates the ast for `a |> b |> c` # the intention is to make it a little easier to see what the fix_pipe functions are matching on =) defmacrop pipe_chain(pm, a, b, c) do - quote do: {:|>, unquote(pm), [{:|>, _, [unquote(a), unquote(b)]}, unquote(c)]} + quote do: {:|>, _, [{:|>, unquote(pm), [unquote(a), unquote(b)]}, unquote(c)]} end # a |> fun => a |> fun() @@ -286,7 +340,8 @@ defmodule Styler.Style.Pipes do {{:., dm, [{:__aliases__, dm, [:Map]}, :new]}, em, [mapper]} _ -> - {into, em, [Style.set_line(collectable, dm[:line]), mapper]} + {into, m, [collectable]} = Style.set_line({into, em, [collectable]}, dm[:line]) + {into, m, [collectable, mapper]} end {:|>, pm, [lhs, rhs]} diff --git a/test/style/pipes_test.exs b/test/style/pipes_test.exs index 5c363551..93ba461f 100644 --- a/test/style/pipes_test.exs +++ b/test/style/pipes_test.exs @@ -432,6 +432,18 @@ defmodule Styler.Style.PipesTest do """ ) end + + test "onelines assignments" do + assert_style( + """ + x = + y + |> Enum.map(&f/1) + |> Enum.join() + """, + "x = Enum.map_join(y, &f/1)" + ) + end end describe "valid pipe starts & unpiping" do @@ -677,16 +689,24 @@ defmodule Styler.Style.PipesTest do assert_style( """ + # a + # b a_multiline_mapper |> #{enum}.map(fn %{gets: shrunk, down: to_a_more_reasonable} -> + # c IO.puts "woo!" + # d {shrunk, to_a_more_reasonable} end) |> Enum.into(size) """, """ + # a + # b Enum.into(a_multiline_mapper, size, fn %{gets: shrunk, down: to_a_more_reasonable} -> + # c IO.puts("woo!") + # d {shrunk, to_a_more_reasonable} end) """ @@ -751,16 +771,16 @@ defmodule Styler.Style.PipesTest do test "unpiping doesn't move comment in anonymous function" do assert_style( """ - aliased = - aliases - |> MapSet.new(fn - {:alias, _, [{:__aliases__, _, aliases}]} -> List.last(aliases) - {:alias, _, [{:__aliases__, _, _}, [{_as, {:__aliases__, _, [as]}}]]} -> as - # alias __MODULE__ or other oddities - {:alias, _, _} -> nil - end) + aliased = + aliases + |> MapSet.new(fn + {:alias, _, [{:__aliases__, _, aliases}]} -> List.last(aliases) + {:alias, _, [{:__aliases__, _, _}, [{_as, {:__aliases__, _, [as]}}]]} -> as + # alias __MODULE__ or other oddities + {:alias, _, _} -> nil + end) - excluded_first = MapSet.union(aliased, @excluded_namespaces) + excluded_first = MapSet.union(aliased, @excluded_namespaces) """, """ aliased = @@ -774,6 +794,61 @@ defmodule Styler.Style.PipesTest do excluded_first = MapSet.union(aliased, @excluded_namespaces) """ ) + + assert_style( + """ + foo = + # bar + bar + # baz + |> baz(fn -> + # a + a + # b + b + end) + """, + """ + # bar + # baz + foo = + baz(bar, fn -> + # a + a + # b + b + end) + """ + ) + + assert_style( + """ + foo = + # bar + bar + # baz + + + + |> baz(fn -> + # a + a + # b + b + end) + """, + """ + # bar + # baz + foo = + baz(bar, fn -> + # a + a + # b + b + end) + """ + ) end end From 60f79c7649c447c4d581e330d160f0a2c75b23bc Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Thu, 7 Nov 2024 12:39:11 -0700 Subject: [PATCH 022/145] Pipify: `d(a |> b |> c)` => `a |> b() |> c() |> d()`(#198) Closes #133 --- CHANGELOG.md | 1 + lib/style.ex | 3 + lib/style/blocks.ex | 3 +- lib/style/pipes.ex | 42 +++++++++ test/style/pipes_test.exs | 181 +++++++++++++++++++++++--------------- 5 files changed, 159 insertions(+), 71 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11460ae8..1a1e535a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ they can and will change without that change being reflected in Styler's semanti ### Improvements +* `pipes`: pipe-ifies when first arg to a function is a pipe. reach out if this happens in unstylish places in your code (Closes #133) * `pipes`: unpiping assignments will make the assignment one-line when possible (Closes #181) ### Fixes diff --git a/lib/style.ex b/lib/style.ex index 6368293c..da93416b 100644 --- a/lib/style.ex +++ b/lib/style.ex @@ -83,6 +83,9 @@ defmodule Styler.Style do end end + def do_block?([{{:__block__, _, [:do]}, _body} | _]), do: true + def do_block?(_), do: false + @doc """ Returns a zipper focused on the nearest node where additional nodes can be inserted (a "block"). diff --git a/lib/style/blocks.ex b/lib/style/blocks.ex index ce3a849c..96e10ce9 100644 --- a/lib/style/blocks.ex +++ b/lib/style/blocks.ex @@ -79,9 +79,8 @@ defmodule Styler.Style.Blocks do def run({{:with, with_meta, children}, _} = zipper, ctx) when is_list(children) do # a std lib `with` block will have at least one left arrow and a `do` body. anything else we skip ¯\_(ツ)_/¯ arrow_or_match? = &(left_arrow?(&1) || match?({:=, _, _}, &1)) - do_block? = &match?([{{:__block__, _, [:do]}, _body} | _], &1) - if Enum.any?(children, arrow_or_match?) and Enum.any?(children, do_block?) do + if Enum.any?(children, arrow_or_match?) and Enum.any?(children, &Style.do_block?/1) do {preroll, children} = children |> Enum.map(fn diff --git a/lib/style/pipes.ex b/lib/style/pipes.ex index b6204ca7..76a97b44 100644 --- a/lib/style/pipes.ex +++ b/lib/style/pipes.ex @@ -130,6 +130,48 @@ defmodule Styler.Style.Pipes do end end + # a(b |> c[, ...args]) + # The first argument to a function-looking node is a pipe. + # Maybe pipe the whole thing? + def run({{f, m, [{:|>, _, _} = pipe | args]}, _} = zipper, ctx) do + parent = + case Zipper.up(zipper) do + {{parent, _, _}, _} -> parent + _ -> nil + end + + stringified = is_atom(f) && to_string(f) + + cond do + # this is likely a macro + # assert a |> b() |> c() + !m[:closing] -> + {:cont, zipper, ctx} + + # leave bools alone as they often read better coming first, like when prepended with `not` + # [not ]is_nil(a |> b() |> c()) + stringified && (String.starts_with?(stringified, "is_") or String.ends_with?(stringified, "?")) -> + {:cont, zipper, ctx} + + # string interpolation, module attribute assignment, or prettier bools with not + parent in [:"::", :@, :not] -> + {:cont, zipper, ctx} + + # double down on being good to exunit macros, and any other special ops + # ..., do: assert(a |> b |> c) + # not (a |> b() |> c()) + f in [:assert, :refute | @special_ops] -> + {:cont, zipper, ctx} + + # if a |> b() |> c(), do: ... + Enum.any?(args, &Style.do_block?/1) -> + {:cont, zipper, ctx} + + true -> + {:cont, Zipper.replace(zipper, {:|>, m, [pipe, {f, m, args}]}), ctx} + end + end + def run(zipper, ctx), do: {:cont, zipper, ctx} defp fix_pipe_start({pipe, zmeta} = zipper) do diff --git a/test/style/pipes_test.exs b/test/style/pipes_test.exs index 93ba461f..0da3eb5d 100644 --- a/test/style/pipes_test.exs +++ b/test/style/pipes_test.exs @@ -90,7 +90,7 @@ defmodule Styler.Style.PipesTest do y end - a(foo(if_result), b) + if_result |> foo() |> a(b) """ ) end @@ -767,8 +767,8 @@ defmodule Styler.Style.PipesTest do end end - describe "comments" do - test "unpiping doesn't move comment in anonymous function" do + describe "comments and..." do + test "unpiping" do assert_style( """ aliased = @@ -850,73 +850,116 @@ defmodule Styler.Style.PipesTest do """ ) end + + test "optimizing" do + assert_style( + """ + a + |> Enum.map(fn b -> + c + # a comment + d + end) + |> Enum.join(x) + |> Enum.each(...) + """, + """ + a + |> Enum.map_join(x, fn b -> + c + # a comment + d + end) + |> Enum.each(...) + """ + ) + + assert_style( + """ + a + |> Enum.map(fn b -> + c + # a comment + d + end) + |> Enum.into(x) + |> Enum.each(...) + """, + """ + a + |> Enum.into(x, fn b -> + c + # a comment + d + end) + |> Enum.each(...) + """ + ) + + assert_style( + """ + a + |> Enum.map(fn b -> + c + # a comment + d + end) + |> Keyword.new() + |> Enum.each(...) + """, + """ + a + |> Keyword.new(fn b -> + c + # a comment + d + end) + |> Enum.each(...) + """ + ) + end end - test "optimizing with comments" do - assert_style( - """ - a - |> Enum.map(fn b -> - c - # a comment - d - end) - |> Enum.join(x) - |> Enum.each(...) - """, - """ - a - |> Enum.map_join(x, fn b -> - c - # a comment - d - end) - |> Enum.each(...) - """ - ) - - assert_style( - """ - a - |> Enum.map(fn b -> - c - # a comment - d - end) - |> Enum.into(x) - |> Enum.each(...) - """, - """ - a - |> Enum.into(x, fn b -> - c - # a comment - d - end) - |> Enum.each(...) - """ - ) - - assert_style( - """ - a - |> Enum.map(fn b -> - c - # a comment - d - end) - |> Keyword.new() - |> Enum.each(...) - """, - """ - a - |> Keyword.new(fn b -> - c - # a comment - d - end) - |> Enum.each(...) - """ - ) + describe "pipifying" do + test "no false positives" do + pipe = "a() |> b() |> c()" + assert_style pipe + assert_style String.replace(pipe, " |>", "\n|>") + assert_style "fn -> #{pipe} end" + assert_style "if #{pipe}, do: ..." + assert_style "x\n\n#{pipe}" + assert_style "@moduledoc #{pipe}" + assert_style "!(#{pipe})" + assert_style "not foo(#{pipe})" + assert_style ~s<"\#{#{pipe}}"> + end + + test "pipifying" do + assert_style "d(a |> b |> c)", "a |> b() |> c() |> d()" + + assert_style( + """ + # d + d( + # a + a + # b + |> b + # c + |> c + ) + """, + """ + # d + # a + a + # b + |> b() + # c + |> c() + |> d() + """ + ) + end end end From 5016027975dbac6bdbf517c1c363074a3b844765 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Mon, 18 Nov 2024 23:42:58 -0700 Subject: [PATCH 023/145] 1.18 hard deprecations (#203) * `List.zip` => `Enum.zip` * `first..last = range` => `first..last//_ = range` --- lib/style/deprecations.ex | 17 ++++++++++++++++ test/style/deprecations_test.exs | 34 ++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/lib/style/deprecations.ex b/lib/style/deprecations.ex index 68050083..f810e5a3 100644 --- a/lib/style/deprecations.ex +++ b/lib/style/deprecations.ex @@ -17,6 +17,20 @@ defmodule Styler.Style.Deprecations do def run({node, meta}, ctx), do: {:cont, {style(node), meta}, ctx} + # Deprecated in 1.18 + # rewrite patterns of `first..last = ...` to `first..last//_ = ...` + defp style({:=, m, [{:.., _, [_first, _last]} = range, rhs]}), do: {:=, m, [rewrite_range_match(range), rhs]} + defp style({:->, m, [[{:.., _, [_first, _last]} = range], rhs]}), do: {:->, m, [[rewrite_range_match(range)], rhs]} + defp style({:<-, m, [{:.., _, [_first, _last]} = range, rhs]}), do: {:<-, m, [rewrite_range_match(range), rhs]} + + defp style({def, dm, [{x, xm, params} | rest]}) when def in ~w(def defp)a and is_list(params), + do: {def, dm, [{x, xm, Enum.map(params, &rewrite_range_match/1)} | rest]} + + # Deprecated in 1.18 + # List.zip => Enum.zip + defp style({{:., dm_, [{:__aliases__, am, [:List]}, :zip]}, fm, arg}), + do: {{:., dm_, [{:__aliases__, am, [:Enum]}, :zip]}, fm, arg} + # Logger.warn => Logger.warning # Started to emit warning after Elixir 1.15.0 defp style({{:., dm, [{:__aliases__, am, [:Logger]}, :warn]}, funm, args}), @@ -84,6 +98,9 @@ defmodule Styler.Style.Deprecations do defp style(node), do: node + defp rewrite_range_match({:.., dm, [first, {_, m, _} = last]}), do: {:"..//", dm, [first, last, {:_, m, nil}]} + defp rewrite_range_match(x), do: x + defp add_step_to_date_range?(first, last) do with {:ok, f} <- extract_date_value(first), {:ok, l} <- extract_date_value(last), diff --git a/test/style/deprecations_test.exs b/test/style/deprecations_test.exs index 3d5e8ed9..cb9396fd 100644 --- a/test/style/deprecations_test.exs +++ b/test/style/deprecations_test.exs @@ -33,6 +33,40 @@ defmodule Styler.Style.DeprecationsTest do ) end + test "matching ranges" do + assert_style "first..last = range", "first..last//_ = range" + assert_style "^first..^last = range", "^first..^last//_ = range" + assert_style "first..last = x = y", "first..last//_ = x = y" + assert_style "y = first..last = x", "y = first..last//_ = x" + + assert_style "def foo(x..y), do: :ok", "def foo(x..y//_), do: :ok" + assert_style "def foo(a, x..y = z), do: :ok", "def foo(a, x..y//_ = z), do: :ok" + assert_style "def foo(%{a: x..y = z}), do: :ok", "def foo(%{a: x..y//_ = z}), do: :ok" + + assert_style "with a..b = c <- :ok, d..e <- :better, do: :ok", "with a..b//_ = c <- :ok, d..e//_ <- :better, do: :ok" + + assert_style( + """ + case x do + a..b = c -> :ok + d..e -> :better + end + """, + """ + case x do + a..b//_ = c -> :ok + d..e//_ -> :better + end + """ + ) + end + + test "List.zip/1" do + assert_style "List.zip(foo)", "Enum.zip(foo)" + assert_style "foo |> List.zip |> bar", "foo |> Enum.zip() |> bar()" + assert_style "foo |> List.zip", "Enum.zip(foo)" + end + describe "1.16 deprecations" do @describetag skip: Version.match?(System.version(), "< 1.16.0-dev") From 929d217753e11757d2624c6c64a129dc2d798e26 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Tue, 19 Nov 2024 11:04:07 -0800 Subject: [PATCH 024/145] docs and changelog --- CHANGELOG.md | 3 ++ docs/deprecations.md | 48 ++++++++++++++++++++ docs/styles.md | 101 ++++++++----------------------------------- 3 files changed, 70 insertions(+), 82 deletions(-) create mode 100644 docs/deprecations.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a1e535a..cd7001bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ they can and will change without that change being reflected in Styler's semanti * `pipes`: pipe-ifies when first arg to a function is a pipe. reach out if this happens in unstylish places in your code (Closes #133) * `pipes`: unpiping assignments will make the assignment one-line when possible (Closes #181) +* `deprecations`: 1.18 deprecations + * `List.zip` => `Enum.zip` + * `first..last = range` => `first..last//_ = range` ### Fixes diff --git a/docs/deprecations.md b/docs/deprecations.md new file mode 100644 index 00000000..1ed13297 --- /dev/null +++ b/docs/deprecations.md @@ -0,0 +1,48 @@ +## Elixir Deprecation Rewrites + +| Before | After | +|--------|-------| +| `Logger.warn` | `Logger.warning`| +| `Path.safe_relative_to/2` | `Path.safe_relative/2`| +| `~R/my_regex/` | `~r/my_regex/`| +| `Enum/String.slice/2` with decreasing ranges | add explicit steps to the range * | +| `Date.range/2` with decreasing range | `Date.range/3` *| +| `IO.read/bin_read` with `:all` option | replace `:all` with `:eof`| + +\* For both of the "decreasing range" changes, the rewrite can only be applied if the range is being passed as an argument to the function. + +### 1.18 Deprecations + +#### `List.zip/1` + +``` +# Before +List.zip(list) +# Styled +Enum.zip(list) +``` + +#### Range Matching Without Step + +```elixir +# Before +first..last = range +# Styled +first..last//_ = range + +# Before +def foo(x..y), do: :ok +# Styled +def foo(x..y//_), do: :ok +``` + +### 1.16+ + +`File.stream!/3` `:line` and `:bytes` deprecation + +```elixir +# Before +File.stream!(path, [encoding: :utf8, trim_bom: true], :line) +# Styled +File.stream!(path, :line, encoding: :utf8, trim_bom: true) +``` diff --git a/docs/styles.md b/docs/styles.md index 69e04cea..bf404aa3 100644 --- a/docs/styles.md +++ b/docs/styles.md @@ -109,7 +109,7 @@ baz |> Enum.reverse() |> Enum.concat(bop) Enum.reverse(baz, bop) ``` -## `Timex.now/0` ->` DateTime.utc_now/0` +## `Timex.now/0` -> `DateTime.utc_now/0` Timex certainly has its uses, but knowing what stdlib date/time struct is returned by `now/0` is a bit difficult! @@ -137,43 +137,25 @@ DateTime.compare(start, end_date) == :lt DateTime.before?(start, end_date) ``` -## Implicit Try +## Implicit `try` Styler will rewrite functions whose entire body is a try/do to instead use the implicit try syntax, per Credo's `Credo.Check.Readability.PreferImplicitTry` -The following example illustrates the most complex case, but Styler happily handles just basic try do/rescue bodies just as easily. - -### Before - ```elixir -def foo() do +# before +def foo do try do - uh_oh() - rescue - exception -> {:error, exception} + throw_ball() catch - :a_throw -> {:error, :threw!} - else - try_has_an_else_clause? -> {:did_you_know, try_has_an_else_clause?} - after - :done + :ball -> :caught end end -``` - -### After -```elixir -def foo() do - uh_oh() -rescue - exception -> {:error, exception} +# Styled: +def foo do + throw_ball() catch - :a_throw -> {:error, :threw!} -else - try_has_an_else_clause? -> {:did_you_know, try_has_an_else_clause?} -after - :done + :ball -> :caught end ``` @@ -183,44 +165,19 @@ The author of the library disagrees with this style convention :) BUT, the wonde ```elixir # Before -def foo() -defp foo() -defmacro foo() -defmacrop foo() - -# Styled -def foo -defp foo -defmacro foo -defmacrop foo -``` - -## Elixir Deprecation Rewrites - -### 1.15+ - -| Before | After | -|--------|-------| -| `Logger.warn` | `Logger.warning`| -| `Path.safe_relative_to/2` | `Path.safe_relative/2`| -| `~R/my_regex/` | `~r/my_regex/`| -| `Enum/String.slice/2` with decreasing ranges | add explicit steps to the range * | -| `Date.range/2` with decreasing range | `Date.range/3` *| -| `IO.read/bin_read` with `:all` option | replace `:all` with `:eof`| - -\* For both of the "decreasing range" changes, the rewrite can only be applied if the range is being passed as an argument to the function. - -### 1.16+ -File.stream! `:line` and `:bytes` deprecation +def foo() do +defp foo() do +defmacro foo() do +defmacrop foo() do -```elixir -# Before -File.stream!(path, [encoding: :utf8, trim_bom: true], :line) # Styled -File.stream!(path, :line, encoding: :utf8, trim_bom: true) +def foo do +defp foo do +defmacro foo do +defmacrop foo do ``` -## Putting variable matching on the right +## Variable matching on the right ```elixir # Before @@ -263,26 +220,6 @@ case foo do end ``` -## Use Implicit Try - -```elixir -# before -def foo d - try do - throw_ball() - catch - :ball -> :caught - end -end - -# Styled: -def foo d - throw_ball() -catch - :ball -> :caught -end -``` - ## Shrink Function Definitions to One Line When Possible ```elixir From da3f3c955735d0c941de87ff90595628f6d2843f Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Wed, 20 Nov 2024 11:17:32 -0800 Subject: [PATCH 025/145] docs prep for 1.2 --- CHANGELOG.md | 2 ++ docs/deprecations.md | 63 +++++++++++++++++++++++++++++++++++--------- docs/pipes.md | 14 ++++++++-- mix.exs | 3 ++- 4 files changed, 66 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd7001bd..ffeaa3dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ they can and will change without that change being reflected in Styler's semantic version. ## main +## 1.2.0 + ### Improvements * `pipes`: pipe-ifies when first arg to a function is a pipe. reach out if this happens in unstylish places in your code (Closes #133) diff --git a/docs/deprecations.md b/docs/deprecations.md index 1ed13297..bbc7c190 100644 --- a/docs/deprecations.md +++ b/docs/deprecations.md @@ -1,27 +1,34 @@ ## Elixir Deprecation Rewrites -| Before | After | -|--------|-------| -| `Logger.warn` | `Logger.warning`| -| `Path.safe_relative_to/2` | `Path.safe_relative/2`| -| `~R/my_regex/` | `~r/my_regex/`| -| `Enum/String.slice/2` with decreasing ranges | add explicit steps to the range * | -| `Date.range/2` with decreasing range | `Date.range/3` *| -| `IO.read/bin_read` with `:all` option | replace `:all` with `:eof`| +Elixir's built-in formatter now does its own rewrites via the `--migrate` flag, but doesn't quite cover every possible automated rewrite on the hard deprecations list. Styler tries to cover the rest! + +Styler will rewrite deprecations so long as their alternative is available on the given elixir version. In other words, Styler doesn't care what version of Elixir you're using when it applies the ex-1.18 rewrites - all it cares about is that the alternative is valid in your version of elixir. -\* For both of the "decreasing range" changes, the rewrite can only be applied if the range is being passed as an argument to the function. +### elixir `main` -### 1.18 Deprecations +https://github.com/elixir-lang/elixir/blob/main/CHANGELOG.md#4-hard-deprecations + +These deprecations will be released with Elixir 1.18 #### `List.zip/1` -``` +```elixir # Before List.zip(list) # Styled Enum.zip(list) ``` +#### `unless` + +This is covered by the Elixir Formatter with the `--migrate` flag, but Styler brings the same transformation to codebases on earlier versions of Elixir. + +Rewrite `unless x` to `if !x` + +### 1.17 + +[1.17 Deprecations](https://hexdocs.pm/elixir/1.17.0/changelog.html#4-hard-deprecations) + #### Range Matching Without Step ```elixir @@ -36,9 +43,11 @@ def foo(x..y), do: :ok def foo(x..y//_), do: :ok ``` -### 1.16+ +### 1.16 + +[1.16 Deprecations](https://hexdocs.pm/elixir/1.16.0/changelog.html#4-hard-deprecations) -`File.stream!/3` `:line` and `:bytes` deprecation +#### `File.stream!/3` `:line` and `:bytes` deprecation ```elixir # Before @@ -46,3 +55,31 @@ File.stream!(path, [encoding: :utf8, trim_bom: true], :line) # Styled File.stream!(path, :line, encoding: :utf8, trim_bom: true) ``` + +### Explicit decreasing ranges + +In all these cases, the rewrite will only be applied when literals are being passed to the function. In other words, variables will not be traced back to their assignment, and so it is still possible to receive deprecation warnings on this issue. + +```elixir +# Before +Enum.slice(x, 1..-2) +# Styled +Enum.slice(x, 1..-2//1) + +# Before +Date.range(~D[2000-01-01], ~D[1999-01-01]) +# Styled +Date.range(~D[2000-01-01], ~D[1999-01-01], -1) +``` + +### 1.15 + +[1.15 Deprecations](https://hexdocs.pm/elixir/1.15.0/changelog.html#4-hard-deprecations) + +| Before | After | +|--------|-------| +| `Logger.warn` | `Logger.warning`| +| `Path.safe_relative_to/2` | `Path.safe_relative/2`| +| `~R/my_regex/` | `~r/my_regex/`| +| `Date.range/2` with decreasing range | `Date.range/3` *| +| `IO.read/bin_read` with `:all` option | replace `:all` with `:eof`| diff --git a/docs/pipes.md b/docs/pipes.md index 08673c94..8d800984 100644 --- a/docs/pipes.md +++ b/docs/pipes.md @@ -1,6 +1,6 @@ -# Pipe Chains +## Pipe Chains -## Pipe Start +### Pipe Start Styler will ensure that the start of a pipechain is a 0-arity function, a raw value, or a variable. @@ -120,3 +120,13 @@ map = a |> Enum.map(mapper) |> Map.new() # Styled: map = Map.new(a, mapper) ``` + +### Pipe-ify + +If the first argument to a function call is a pipe, Styler makes the function call the final pipe of the chain. + +```elixir +d(a |> b |> c) +# Styled +a |> b() |> c() |> d() +``` diff --git a/mix.exs b/mix.exs index 2cabf780..9e804879 100644 --- a/mix.exs +++ b/mix.exs @@ -65,9 +65,10 @@ defmodule Styler.MixProject do extras: [ "CHANGELOG.md": [title: "Changelog"], "docs/styles.md": [title: "Basic Styles"], + "docs/deprecations.md": [title: "Deprecated Elixirisms"], "docs/pipes.md": [title: "Pipe Chains"], "docs/control_flow_macros.md": [title: "Control Flow Macros (if, case, ...)"], - "docs/mix_configs.md": [title: "Mix Configs (config/config.exs, ...)"], + "docs/mix_configs.md": [title: "Mix Configs (config/*.exs)"], "docs/module_directives.md": [title: "Module Directives (use, alias, ...)"], "docs/credo.md": [title: "Styler & Credo"], "README.md": [title: "Styler"] From 65007bade5bc3c906fd9434d86c7ad4984cc7481 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Wed, 20 Nov 2024 11:21:06 -0800 Subject: [PATCH 026/145] v1.2.0 --- README.md | 2 +- mix.exs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f6978a52..840b221e 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Add `:styler` as a dependency to your project's `mix.exs`: ```elixir def deps do [ - {:styler, "~> 1.1", only: [:dev, :test], runtime: false}, + {:styler, "~> 1.2", only: [:dev, :test], runtime: false}, ] end ``` diff --git a/mix.exs b/mix.exs index 9e804879..1bea0327 100644 --- a/mix.exs +++ b/mix.exs @@ -12,7 +12,7 @@ defmodule Styler.MixProject do use Mix.Project # Don't forget to bump the README when doing non-patch version changes - @version "1.1.2" + @version "1.2.0" @url "https://github.com/adobe/elixir-styler" def project do From d930cab52758b2c8827ca26f4dcd0a882f7ff322 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Wed, 20 Nov 2024 11:39:27 -0800 Subject: [PATCH 027/145] correct docs on Enum.into --- docs/styles.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/styles.md b/docs/styles.md index bf404aa3..10769d3e 100644 --- a/docs/styles.md +++ b/docs/styles.md @@ -47,7 +47,7 @@ Note that all of the examples below also apply to pipes (`enum |> Enum.into(...) | `Enum.into(enum, %{})` | `Map.new(enum)`| | `Enum.into(enum, Map.new())` | `Map.new(enum)`| | `Enum.into(enum, Keyword.new())` | `Keyword.new(enum)`| -| `Enum.into(enum, MapSet.new())` | `Keyword.new(enum)`| +| `Enum.into(enum, MapSet.new())` | `MapSet.new(enum)`| | `Enum.into(enum, %{}, fn x -> {x, x} end)` | `Map.new(enum, fn x -> {x, x} end)`| | `Enum.into(enum, [])` | `Enum.to_list(enum)` | | `Enum.into(enum, [], mapper)` | `Enum.map(enum, mapper)`| From 872ad3479f15d9e5e2ce4e33013c905db8af6e2b Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Thu, 21 Nov 2024 09:37:04 -0800 Subject: [PATCH 028/145] Fix pipifying pipes-in-pipes (Closes #204) --- CHANGELOG.md | 6 ++++++ lib/style/pipes.ex | 2 +- test/style/pipes_test.exs | 8 ++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ffeaa3dc..e3cb4bc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ they can and will change without that change being reflected in Styler's semantic version. ## main +## 1.2.1 + +### Fixes + +* `|>` don't pipify when the call is itself in a pipe (aka don't touch `a |> b(c |> d() |>e()) |> f()`) (Closes #204, h/t @paulswartz) + ## 1.2.0 ### Improvements diff --git a/lib/style/pipes.ex b/lib/style/pipes.ex index 76a97b44..cb269a7f 100644 --- a/lib/style/pipes.ex +++ b/lib/style/pipes.ex @@ -154,7 +154,7 @@ defmodule Styler.Style.Pipes do {:cont, zipper, ctx} # string interpolation, module attribute assignment, or prettier bools with not - parent in [:"::", :@, :not] -> + parent in [:"::", :@, :not, :|>] -> {:cont, zipper, ctx} # double down on being good to exunit macros, and any other special ops diff --git a/test/style/pipes_test.exs b/test/style/pipes_test.exs index 0da3eb5d..3feae8ec 100644 --- a/test/style/pipes_test.exs +++ b/test/style/pipes_test.exs @@ -934,6 +934,14 @@ defmodule Styler.Style.PipesTest do assert_style ~s<"\#{#{pipe}}"> end + test "when it's not actually the first argument!" do + assert_style """ + a + |> M.f0(b |> M.f1() |> M.f2()) + |> M.f3() + """ + end + test "pipifying" do assert_style "d(a |> b |> c)", "a |> b() |> c() |> d()" From 4ec6ba7824ea626db12ff5751b6a754f1715250d Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Thu, 21 Nov 2024 09:39:37 -0800 Subject: [PATCH 029/145] v1.2.1 --- mix.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mix.exs b/mix.exs index 1bea0327..e7297a53 100644 --- a/mix.exs +++ b/mix.exs @@ -12,7 +12,7 @@ defmodule Styler.MixProject do use Mix.Project # Don't forget to bump the README when doing non-patch version changes - @version "1.2.0" + @version "1.2.1" @url "https://github.com/adobe/elixir-styler" def project do From b7dcfd5057cfc0ef7311a22818f0030e293a93f8 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Fri, 22 Nov 2024 18:37:37 -0700 Subject: [PATCH 030/145] introduce `# styler:sort` comment directive (#205) closes #167 --- CHANGELOG.md | 66 +++++++++++ lib/style/comment_directives.ex | 67 +++++++++++ lib/styler.ex | 3 +- test/style/comment_directives_test.exs | 157 +++++++++++++++++++++++++ 4 files changed, 292 insertions(+), 1 deletion(-) create mode 100644 lib/style/comment_directives.ex create mode 100644 test/style/comment_directives_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index e3cb4bc1..e40e0c66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,72 @@ they can and will change without that change being reflected in Styler's semantic version. ## main +### Improvements + +#### `# styler:sort` Styler's first comment directive + +Styler will now keep a user-designated list or wordlist (`~w` sigil) sorted as part of formatting via the use of comments. + +The intention is to remove comments to humans, like `# Please keep this list sorted!`, in favor of comments to robots: `# styler:sort`. Personally speaking, Styler is much better at alphabetical-order than I ever will be. + +To use the new directive, put it on the line before a list or wordlist. + +This example: + +```elixir +# styler:sort +[:c, :a, :b] + +# styler:sort +~w(a list of words) + +# styler:sort +@country_codes ~w( + en_US + po_PO + fr_CA + ja_JP +) + +# styler:sort +a_var = + [ + Modules, + In, + A, + List + ] +``` + +Would yield: + +```elixir +# styler:sort +[:a, :b, :c] + +# styler:sort +~w(a list of words) + +# styler:sort +@country_codes ~w( + en_US + fr_CA + ja_JP + po_PO +) + +# styler:sort +a_var = + [ + A, + In, + List, + Modules + ] +``` + +Sorting is done according to erlang term ordering, so lists with elements of multiple types will work just fine. + ## 1.2.1 ### Fixes diff --git a/lib/style/comment_directives.ex b/lib/style/comment_directives.ex new file mode 100644 index 00000000..d41e0881 --- /dev/null +++ b/lib/style/comment_directives.ex @@ -0,0 +1,67 @@ +# Copyright 2024 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. You may obtain a copy +# of the License at http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +# OF ANY KIND, either express or implied. See the License for the specific language +# governing permissions and limitations under the License. + +defmodule Styler.Style.CommentDirectives do + @moduledoc "TODO" + + @behaviour Styler.Style + + alias Styler.Zipper + + def run(zipper, ctx) do + zipper = + ctx.comments + |> Enum.filter(&(&1.text == "# styler:sort")) + |> Enum.map(& &1.line) + |> Enum.reduce(zipper, fn line, zipper -> + found = + Zipper.find(zipper, fn + {_, meta, _} -> Keyword.get(meta, :line, -1) >= line + _ -> false + end) + + if found do + Zipper.update(found, &sort/1) + else + zipper + end + end) + + {:halt, zipper, ctx} + end + + defp sort({:__block__, meta, [list]}) when is_list(list) do + list = Enum.sort_by(list, fn {f, _, a} -> {f, a} end) + {:__block__, meta, [list]} + end + + defp sort({:sigil_w, sm, [{:<<>>, bm, [string]}, modifiers]}) do + # ew. gotta be a better way. + # this keeps indentation for the sigil via joiner, while prepend and append are the bookending whitespace + {prepend, joiner, append} = + case Regex.run(~r|^\s+|, string) do + # oneliner like `~w|c a b|` + nil -> {"", " ", ""} + # multline like + # `"\n a\n list\n long\n of\n static\n values\n"` + # ^^^^ `prepend` ^^^^ `joiner` ^^ `append` + # note that joiner and prepend are the same in a multiline (unsure if this is always true) + # @TODO: get all 3 in one pass of a regex. probably have to turn off greedy or something... + [joiner] -> {joiner, joiner, ~r|\s+$| |> Regex.run(string) |> hd()} + end + + string = string |> String.split() |> Enum.sort() |> Enum.join(joiner) + {:sigil_w, sm, [{:<<>>, bm, [prepend, string, append]}, modifiers]} + end + + defp sort({:=, m, [lhs, rhs]}), do: {:=, m, [lhs, sort(rhs)]} + defp sort({:@, m, [{a, am, [assignment]}]}), do: {:@, m, [{a, am, [sort(assignment)]}]} + defp sort(x), do: x +end diff --git a/lib/styler.ex b/lib/styler.ex index 8179c415..50e0d20b 100644 --- a/lib/styler.ex +++ b/lib/styler.ex @@ -25,7 +25,8 @@ defmodule Styler do Styler.Style.Defs, Styler.Style.Blocks, Styler.Style.Deprecations, - Styler.Style.Configs + Styler.Style.Configs, + Styler.Style.CommentDirectives ] @doc false diff --git a/test/style/comment_directives_test.exs b/test/style/comment_directives_test.exs new file mode 100644 index 00000000..16c8621a --- /dev/null +++ b/test/style/comment_directives_test.exs @@ -0,0 +1,157 @@ +# Copyright 2024 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. You may obtain a copy +# of the License at http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +# OF ANY KIND, either express or implied. See the License for the specific language +# governing permissions and limitations under the License. + +defmodule Styler.Style.CommentDirectivesTest do + @moduledoc false + use Styler.StyleCase, async: true + + describe "sort" do + test "we dont just sort by accident" do + assert_style "[:c, :b, :a]" + end + + test "sorts lists of atoms" do + assert_style( + """ + # styler:sort + [ + :c, + :b, + :a + ] + """, + """ + # styler:sort + [ + :a, + :b, + :c + ] + """ + ) + end + + test "sorts sigils" do + assert_style("# styler:sort\n~w|c a b|", "# styler:sort\n~w|a b c|") + + assert_style( + """ + # styler:sort + ~w( + a + long + list + of + static + values + ) + """, + """ + # styler:sort + ~w( + a + list + long + of + static + values + ) + """ + ) + end + + test "assignments" do + assert_style( + """ + # styler:sort + my_var = + ~w( + a + long + list + of + static + values + ) + """, + """ + # styler:sort + my_var = + ~w( + a + list + long + of + static + values + ) + """ + ) + + assert_style( + """ + defmodule M do + @moduledoc false + # styler:sort + @attr ~w( + a + long + list + of + static + values + ) + end + """, + """ + defmodule M do + @moduledoc false + # styler:sort + @attr ~w( + a + list + long + of + static + values + ) + end + """ + ) + end + + test "doesnt affect downstream nodes" do + assert_style( + """ + # styler:sort + [:c, :a, :b] + + @country_codes ~w( + po_PO + en_US + fr_CA + ja_JP + ) + """, + """ + # styler:sort + [:a, :b, :c] + + @country_codes ~w( + po_PO + en_US + fr_CA + ja_JP + ) + """ + ) + end + end +end From 411cc93f6e5a1002a8f162fbbaa53dc379241205 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Sat, 23 Nov 2024 10:25:02 -0700 Subject: [PATCH 031/145] styler:sort - handle tuples --- lib/style.ex | 13 ++++++++++++- lib/style/comment_directives.ex | 7 +++---- lib/style/module_directives.ex | 5 +---- test/style/comment_directives_test.exs | 24 ++++++++++++++++++++++++ 4 files changed, 40 insertions(+), 9 deletions(-) diff --git a/lib/style.ex b/lib/style.ex index da93416b..4b3a9a9b 100644 --- a/lib/style.ex +++ b/lib/style.ex @@ -59,9 +59,20 @@ defmodule Styler.Style do @doc "Traverses an ast node, updating all nodes' meta with `meta_fun`" def update_all_meta(node, meta_fun), do: Macro.prewalk(node, &Macro.update_meta(&1, meta_fun)) - # useful for comparing AST without meta (line numbers, etc) interfering + @doc "prewalks ast and sets all meta to `nil`. useful for comparing AST without meta (line numbers, etc) interfering" def without_meta(ast), do: update_all_meta(ast, fn _ -> nil end) + @doc "sorts a list of nodes according to their string representations" + def sort(ast, opts \\ []) when is_list(ast) do + format = if opts[:format] == :downcase, do: &String.downcase/1, else: &(&1) + + ast + |> Enum.map(&{&1, &1 |> Macro.to_string() |> format.()}) + |> Enum.uniq_by(&elem(&1, 1)) + |> List.keysort(1) + |> Enum.map(&elem(&1, 0)) + end + @doc """ Returns the current node (wrapped in a `__block__` if necessary) if it's a valid place to insert additional nodes """ diff --git a/lib/style/comment_directives.ex b/lib/style/comment_directives.ex index d41e0881..fb783910 100644 --- a/lib/style/comment_directives.ex +++ b/lib/style/comment_directives.ex @@ -13,6 +13,7 @@ defmodule Styler.Style.CommentDirectives do @behaviour Styler.Style + alias Styler.Style alias Styler.Zipper def run(zipper, ctx) do @@ -28,6 +29,7 @@ defmodule Styler.Style.CommentDirectives do end) if found do + #@TODO fix line numbers, move comments Zipper.update(found, &sort/1) else zipper @@ -37,10 +39,7 @@ defmodule Styler.Style.CommentDirectives do {:halt, zipper, ctx} end - defp sort({:__block__, meta, [list]}) when is_list(list) do - list = Enum.sort_by(list, fn {f, _, a} -> {f, a} end) - {:__block__, meta, [list]} - end + defp sort({:__block__, meta, [list]}) when is_list(list), do: {:__block__, meta, [Style.sort(list)]} defp sort({:sigil_w, sm, [{:<<>>, bm, [string]}, modifiers]}) do # ew. gotta be a better way. diff --git a/lib/style/module_directives.ex b/lib/style/module_directives.ex index 623ce5f0..f9eba852 100644 --- a/lib/style/module_directives.ex +++ b/lib/style/module_directives.ex @@ -413,10 +413,7 @@ defmodule Styler.Style.ModuleDirectives do defp sort(directives) do # sorting is done with `downcase` to match Credo directives - |> Enum.map(&{&1, &1 |> Macro.to_string() |> String.downcase()}) - |> Enum.uniq_by(&elem(&1, 1)) - |> List.keysort(1) - |> Enum.map(&elem(&1, 0)) + |> Style.sort(format: :downcase) |> Style.reset_newlines() end end diff --git a/test/style/comment_directives_test.exs b/test/style/comment_directives_test.exs index 16c8621a..7c889f3f 100644 --- a/test/style/comment_directives_test.exs +++ b/test/style/comment_directives_test.exs @@ -153,5 +153,29 @@ defmodule Styler.Style.CommentDirectivesTest do """ ) end + + test "list of tuples" do + # 2ples are represented as block literals while >2ples are created via `:{}` + # decided the easiest way to handle this is to just use string representation for meow + assert_style """ + # styler:sort + [ + {:styler, github: "adobe/elixir-styler"}, + {:ash, "~> 3.0"}, + {:fluxon, "~> 1.0.0", repo: :fluxon}, + {:phoenix_live_reload, "~> 1.2", only: :dev}, + {:tailwind, "~> 0.2", runtime: Mix.env() == :dev} + ] + """,""" + # styler:sort + [ + {:ash, "~> 3.0"}, + {:fluxon, "~> 1.0.0", repo: :fluxon}, + {:phoenix_live_reload, "~> 1.2", only: :dev}, + {:styler, github: "adobe/elixir-styler"}, + {:tailwind, "~> 0.2", runtime: Mix.env() == :dev} + ] + """ + end end end From b13d045cb32f499afc58fabbd60b0e240e4cd599 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Sat, 23 Nov 2024 10:35:16 -0700 Subject: [PATCH 032/145] mix format :X --- lib/style.ex | 2 +- lib/style/comment_directives.ex | 2 +- test/style/comment_directives_test.exs | 41 ++++++++++++++------------ 3 files changed, 24 insertions(+), 21 deletions(-) diff --git a/lib/style.ex b/lib/style.ex index 4b3a9a9b..9854a6d0 100644 --- a/lib/style.ex +++ b/lib/style.ex @@ -64,7 +64,7 @@ defmodule Styler.Style do @doc "sorts a list of nodes according to their string representations" def sort(ast, opts \\ []) when is_list(ast) do - format = if opts[:format] == :downcase, do: &String.downcase/1, else: &(&1) + format = if opts[:format] == :downcase, do: &String.downcase/1, else: & &1 ast |> Enum.map(&{&1, &1 |> Macro.to_string() |> format.()}) diff --git a/lib/style/comment_directives.ex b/lib/style/comment_directives.ex index fb783910..12b8993b 100644 --- a/lib/style/comment_directives.ex +++ b/lib/style/comment_directives.ex @@ -29,7 +29,7 @@ defmodule Styler.Style.CommentDirectives do end) if found do - #@TODO fix line numbers, move comments + # @TODO fix line numbers, move comments Zipper.update(found, &sort/1) else zipper diff --git a/test/style/comment_directives_test.exs b/test/style/comment_directives_test.exs index 7c889f3f..fe299199 100644 --- a/test/style/comment_directives_test.exs +++ b/test/style/comment_directives_test.exs @@ -157,25 +157,28 @@ defmodule Styler.Style.CommentDirectivesTest do test "list of tuples" do # 2ples are represented as block literals while >2ples are created via `:{}` # decided the easiest way to handle this is to just use string representation for meow - assert_style """ - # styler:sort - [ - {:styler, github: "adobe/elixir-styler"}, - {:ash, "~> 3.0"}, - {:fluxon, "~> 1.0.0", repo: :fluxon}, - {:phoenix_live_reload, "~> 1.2", only: :dev}, - {:tailwind, "~> 0.2", runtime: Mix.env() == :dev} - ] - """,""" - # styler:sort - [ - {:ash, "~> 3.0"}, - {:fluxon, "~> 1.0.0", repo: :fluxon}, - {:phoenix_live_reload, "~> 1.2", only: :dev}, - {:styler, github: "adobe/elixir-styler"}, - {:tailwind, "~> 0.2", runtime: Mix.env() == :dev} - ] - """ + assert_style( + """ + # styler:sort + [ + {:styler, github: "adobe/elixir-styler"}, + {:ash, "~> 3.0"}, + {:fluxon, "~> 1.0.0", repo: :fluxon}, + {:phoenix_live_reload, "~> 1.2", only: :dev}, + {:tailwind, "~> 0.2", runtime: Mix.env() == :dev} + ] + """, + """ + # styler:sort + [ + {:ash, "~> 3.0"}, + {:fluxon, "~> 1.0.0", repo: :fluxon}, + {:phoenix_live_reload, "~> 1.2", only: :dev}, + {:styler, github: "adobe/elixir-styler"}, + {:tailwind, "~> 0.2", runtime: Mix.env() == :dev} + ] + """ + ) end end end From 0e8485b108b8ca9596a0ca51f5f8cf528a1251f7 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Sat, 23 Nov 2024 13:48:15 -0700 Subject: [PATCH 033/145] style:sort - dont dedupe --- lib/style.ex | 11 ----------- lib/style/comment_directives.ex | 4 ++-- lib/style/module_directives.ex | 5 ++++- test/style/comment_directives_test.exs | 2 ++ 4 files changed, 8 insertions(+), 14 deletions(-) diff --git a/lib/style.ex b/lib/style.ex index 9854a6d0..d58ad1cb 100644 --- a/lib/style.ex +++ b/lib/style.ex @@ -62,17 +62,6 @@ defmodule Styler.Style do @doc "prewalks ast and sets all meta to `nil`. useful for comparing AST without meta (line numbers, etc) interfering" def without_meta(ast), do: update_all_meta(ast, fn _ -> nil end) - @doc "sorts a list of nodes according to their string representations" - def sort(ast, opts \\ []) when is_list(ast) do - format = if opts[:format] == :downcase, do: &String.downcase/1, else: & &1 - - ast - |> Enum.map(&{&1, &1 |> Macro.to_string() |> format.()}) - |> Enum.uniq_by(&elem(&1, 1)) - |> List.keysort(1) - |> Enum.map(&elem(&1, 0)) - end - @doc """ Returns the current node (wrapped in a `__block__` if necessary) if it's a valid place to insert additional nodes """ diff --git a/lib/style/comment_directives.ex b/lib/style/comment_directives.ex index 12b8993b..214a1a5b 100644 --- a/lib/style/comment_directives.ex +++ b/lib/style/comment_directives.ex @@ -13,7 +13,6 @@ defmodule Styler.Style.CommentDirectives do @behaviour Styler.Style - alias Styler.Style alias Styler.Zipper def run(zipper, ctx) do @@ -39,7 +38,8 @@ defmodule Styler.Style.CommentDirectives do {:halt, zipper, ctx} end - defp sort({:__block__, meta, [list]}) when is_list(list), do: {:__block__, meta, [Style.sort(list)]} + defp sort({:__block__, meta, [list]}) when is_list(list), do: {:__block__, meta, [sort(list)]} + defp sort(list) when is_list(list), do: Enum.sort_by(list, &Macro.to_string/1) defp sort({:sigil_w, sm, [{:<<>>, bm, [string]}, modifiers]}) do # ew. gotta be a better way. diff --git a/lib/style/module_directives.ex b/lib/style/module_directives.ex index f9eba852..623ce5f0 100644 --- a/lib/style/module_directives.ex +++ b/lib/style/module_directives.ex @@ -413,7 +413,10 @@ defmodule Styler.Style.ModuleDirectives do defp sort(directives) do # sorting is done with `downcase` to match Credo directives - |> Style.sort(format: :downcase) + |> Enum.map(&{&1, &1 |> Macro.to_string() |> String.downcase()}) + |> Enum.uniq_by(&elem(&1, 1)) + |> List.keysort(1) + |> Enum.map(&elem(&1, 0)) |> Style.reset_newlines() end end diff --git a/test/style/comment_directives_test.exs b/test/style/comment_directives_test.exs index fe299199..487be6c1 100644 --- a/test/style/comment_directives_test.exs +++ b/test/style/comment_directives_test.exs @@ -24,6 +24,7 @@ defmodule Styler.Style.CommentDirectivesTest do [ :c, :b, + :c, :a ] """, @@ -32,6 +33,7 @@ defmodule Styler.Style.CommentDirectivesTest do [ :a, :b, + :c, :c ] """ From 5b56613d8738ca28565e8538b9385a2783be348c Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Mon, 25 Nov 2024 10:42:06 -0700 Subject: [PATCH 034/145] docs and fix a typo --- lib/style/comment_directives.ex | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/style/comment_directives.ex b/lib/style/comment_directives.ex index 214a1a5b..7b1ccb70 100644 --- a/lib/style/comment_directives.ex +++ b/lib/style/comment_directives.ex @@ -9,7 +9,11 @@ # governing permissions and limitations under the License. defmodule Styler.Style.CommentDirectives do - @moduledoc "TODO" + @moduledoc """ + Leave a comment for Styler asking it to maintain code in a certain way. + + `# styler:sort` maintains sorting of wordlists (by string comparison) and lists (string comparison of code representation) + """ @behaviour Styler.Style @@ -48,7 +52,7 @@ defmodule Styler.Style.CommentDirectives do case Regex.run(~r|^\s+|, string) do # oneliner like `~w|c a b|` nil -> {"", " ", ""} - # multline like + # multiline like # `"\n a\n list\n long\n of\n static\n values\n"` # ^^^^ `prepend` ^^^^ `joiner` ^^ `append` # note that joiner and prepend are the same in a multiline (unsure if this is always true) From d35a28dfc4e4c64d53df7245b1d8272c6ebbeb32 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Wed, 27 Nov 2024 11:23:22 -0700 Subject: [PATCH 035/145] fiddle with private apis to ease iex playing --- lib/styler.ex | 13 ++++++------- lib/zipper.ex | 24 ++++++++++++------------ test/style/configs_test.exs | 2 +- test/style_test.exs | 2 +- test/support/style_case.ex | 10 +++++----- 5 files changed, 25 insertions(+), 26 deletions(-) diff --git a/lib/styler.ex b/lib/styler.ex index 50e0d20b..dbb3992e 100644 --- a/lib/styler.ex +++ b/lib/styler.ex @@ -63,21 +63,21 @@ defmodule Styler do def features(_opts), do: [sigils: [], extensions: [".ex", ".exs"]] @impl Format - def format(input, formatter_opts) do + def format(input, formatter_opts \\ []) do file = formatter_opts[:file] styler_opts = formatter_opts[:styler] || [] {ast, comments} = input - |> string_to_quoted_with_comments(to_string(file)) + |> string_to_ast(to_string(file)) |> style(file, styler_opts) - quoted_to_string(ast, comments, formatter_opts) + ast_to_string(ast, comments, formatter_opts) end @doc false # Wrap `Code.string_to_quoted_with_comments` with our desired options - def string_to_quoted_with_comments(code, file \\ "nofile") when is_binary(code) do + def string_to_ast(code, file \\ "nofile") when is_binary(code) do Code.string_to_quoted_with_comments!(code, literal_encoder: &__MODULE__.literal_encoder/2, token_metadata: true, @@ -89,9 +89,8 @@ defmodule Styler do @doc false def literal_encoder(literal, meta), do: {:ok, {:__block__, meta, [literal]}} - @doc false - # Turns an ast and comments back into code, formatting it along the way. - def quoted_to_string(ast, comments, formatter_opts \\ []) do + @doc "Turns an ast and comments back into code, formatting it along the way." + def ast_to_string(ast, comments \\ [], formatter_opts \\ []) do opts = [{:comments, comments}, {:escape, false} | formatter_opts] {line_length, opts} = Keyword.pop(opts, :line_length, 122) diff --git a/lib/zipper.ex b/lib/zipper.ex index a5728e03..969b7577 100644 --- a/lib/zipper.ex +++ b/lib/zipper.ex @@ -317,8 +317,11 @@ defmodule Styler.Zipper do if next = next(zipper), do: do_traverse(next, acc, fun), else: {top(zipper), acc} end - # Same as `traverse/3`, but doesn't waste cycles going back to the top of the tree when traversal is finished - @doc false + @doc """ + Same as `traverse/3`, but doesn't waste cycles going back to the top of the tree when traversal is finished + + Useful when only the accumulator is of interest, and no updates to the zipper are. + """ @spec reduce(zipper, term, (zipper, term -> {zipper, term})) :: term def reduce({_, nil} = zipper, acc, fun) do do_reduce(zipper, acc, fun) @@ -390,17 +393,14 @@ defmodule Styler.Zipper do end end - @doc false - # Similar to traverse_while/3, but returns the `acc` directly, skipping the return to the top of the zipper. - # For that reason the :halt tuple is instead just a 2-ple of `{:halt, acc}` - @spec reduce_while(zipper, term, (zipper, term -> {command, zipper, term})) :: {zipper, term} - def reduce_while({_tree, nil} = zipper, acc, fun) do - do_reduce_while(zipper, acc, fun) - end + @doc """ + Same as `traverse_while/3` except it only returns the acc, saving the work of returning to the top of the zipper. - def reduce_while({tree, meta}, acc, fun) do - {{updated, _meta}, acc} = do_reduce_while({tree, nil}, acc, fun) - {{updated, meta}, acc} + For that reason the `:halt` tuple is instead just a 2-ple of `{:halt, acc}` + """ + @spec reduce_while(zipper, term, (zipper, term -> {:cont | :skip, zipper, term} | {:halt, term})) :: term + def reduce_while({tree, _meta}, acc, fun) do + do_reduce_while({tree, nil}, acc, fun) end defp do_reduce_while(zipper, acc, fun) do diff --git a/test/style/configs_test.exs b/test/style/configs_test.exs index f4c0cdb5..3f3db06f 100644 --- a/test/style/configs_test.exs +++ b/test/style/configs_test.exs @@ -15,7 +15,7 @@ defmodule Styler.Style.ConfigsTest do alias Styler.Style.Configs test "only runs on exs files in config folders" do - {ast, _} = Styler.string_to_quoted_with_comments("import Config\n\nconfig :bar, boop: :baz") + {ast, _} = Styler.string_to_ast("import Config\n\nconfig :bar, boop: :baz") zipper = Styler.Zipper.zip(ast) for file <- ~w(dev.exs my_app.exs config.exs) do diff --git a/test/style_test.exs b/test/style_test.exs index fbe7a009..0f7f6785 100644 --- a/test/style_test.exs +++ b/test/style_test.exs @@ -35,7 +35,7 @@ defmodule Styler.StyleTest do # After module """ - @comments @code |> Styler.string_to_quoted_with_comments() |> elem(1) + @comments @code |> Styler.string_to_ast() |> elem(1) describe "displace_comments/2" do test "Doesn't lose any comments" do diff --git a/test/support/style_case.ex b/test/support/style_case.ex index f87c7bd3..a7425772 100644 --- a/test/support/style_case.ex +++ b/test/support/style_case.ex @@ -40,11 +40,11 @@ defmodule Styler.StyleCase do if styled != expected and ExUnit.configuration()[:trace] do IO.puts("\n======Given=============\n") IO.puts(before) - {before_ast, before_comments} = Styler.string_to_quoted_with_comments(before) + {before_ast, before_comments} = Styler.string_to_ast(before) dbg(before_ast) dbg(before_comments) IO.puts("======Expected AST==========\n") - {expected_ast, expected_comments} = Styler.string_to_quoted_with_comments(expected) + {expected_ast, expected_comments} = Styler.string_to_ast(expected) dbg(expected_ast) dbg(expected_comments) IO.puts("======Got AST===============\n") @@ -107,7 +107,7 @@ defmodule Styler.StyleCase do dbg(styled_ast) IO.puts("expected:") - dbg(elem(Styler.string_to_quoted_with_comments(expected), 0)) + dbg(elem(Styler.string_to_ast(expected), 0)) IO.puts("code:\n#{styled}") flunk("") @@ -133,11 +133,11 @@ defmodule Styler.StyleCase do end def style(code, filename \\ "testfile") do - {ast, comments} = Styler.string_to_quoted_with_comments(code) + {ast, comments} = Styler.string_to_ast(code) {styled_ast, comments} = Styler.style({ast, comments}, filename, on_error: :raise) try do - styled_code = styled_ast |> Styler.quoted_to_string(comments) |> String.trim_trailing("\n") + styled_code = styled_ast |> Styler.ast_to_string(comments) |> String.trim_trailing("\n") {styled_ast, styled_code, comments} rescue exception -> From 98c5c1c7e39f743b2dc36dd7b1539bb9aba95673 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Wed, 27 Nov 2024 11:47:09 -0700 Subject: [PATCH 036/145] remove Zipper.reduce/3 - bugged, but more importantly unused --- lib/zipper.ex | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/lib/zipper.ex b/lib/zipper.ex index 969b7577..0cdb17e8 100644 --- a/lib/zipper.ex +++ b/lib/zipper.ex @@ -317,26 +317,6 @@ defmodule Styler.Zipper do if next = next(zipper), do: do_traverse(next, acc, fun), else: {top(zipper), acc} end - @doc """ - Same as `traverse/3`, but doesn't waste cycles going back to the top of the tree when traversal is finished - - Useful when only the accumulator is of interest, and no updates to the zipper are. - """ - @spec reduce(zipper, term, (zipper, term -> {zipper, term})) :: term - def reduce({_, nil} = zipper, acc, fun) do - do_reduce(zipper, acc, fun) - end - - def reduce({tree, meta}, acc, fun) do - {{updated, _meta}, acc} = do_reduce({tree, nil}, acc, fun) - {{updated, meta}, acc} - end - - defp do_reduce(zipper, acc, fun) do - {zipper, acc} = fun.(zipper, acc) - if next = next(zipper), do: do_reduce(next, acc, fun), else: acc - end - @doc """ Traverses the tree in depth-first pre-order calling the given function for each node. From cef60157c18096527b4058f6eb3684b61f9d1fd8 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Mon, 13 Jan 2025 12:02:06 -0700 Subject: [PATCH 037/145] Maintain comment-node relations when applying `#styler:sort` directive (#207) Co-authored-by: Greg Mefford Closes #167 --- lib/style.ex | 147 ++++++++++++++----------- lib/style/comment_directives.ex | 45 +++++--- lib/style/configs.ex | 76 +------------ lib/style/module_directives.ex | 64 ++++++++++- test/style/comment_directives_test.exs | 51 +++++++++ test/style_test.exs | 14 --- 6 files changed, 232 insertions(+), 165 deletions(-) diff --git a/lib/style.ex b/lib/style.ex index d58ad1cb..80c50b23 100644 --- a/lib/style.ex +++ b/lib/style.ex @@ -170,69 +170,6 @@ defmodule Styler.Style do {directive, updated_meta, children} end - @doc """ - "Fixes" the line numbers of nodes who have had their orders changed via sorting or other methods. - This "fix" simply ensures that comments don't get wrecked as part of us moving AST nodes willy-nilly. - - The fix is rather naive, and simply enforces the following property on the code: - A given node must have a line number less than the following node. - Et voila! Comments behave much better. - - ## In Detail - - For example, given document - - 1: defmodule ... - 2: alias B - 3: # this is foo - 4: def foo ... - 5: alias A - - Sorting aliases the ast node for would put `alias A` (line 5) before `alias B` (line 2). - - 1: defmodule ... - 5: alias A - 2: alias B - 3: # this is foo - 4: def foo ... - - Elixir's document algebra would then encounter `line: 5` and immediately dump all comments with `line <= 5`, - meaning after running through the formatter we'd end up with - - 1: defmodule - 2: # hi - 3: # this is foo - 4: alias A - 5: alias B - 6: - 7: def foo ... - - This function fixes that by seeing that `alias A` has a higher line number than its following sibling `alias B` and so - updates `alias A`'s line to be preceding `alias B`'s line. - - Running the results of this function through the formatter now no longer dumps the comments prematurely - - 1: defmodule ... - 2: alias A - 3: alias B - 4: # this is foo - 5: def foo ... - """ - def fix_line_numbers(nodes, nil), do: fix_line_numbers(nodes, 999_999) - def fix_line_numbers(nodes, {_, meta, _}), do: fix_line_numbers(nodes, meta[:line]) - def fix_line_numbers(nodes, max), do: nodes |> Enum.reverse() |> do_fix_lines(max, []) - - defp do_fix_lines([], _, acc), do: acc - - defp do_fix_lines([{_, meta, _} = node | nodes], max, acc) do - line = meta[:line] - - # the -2 is just an ugly hack to leave room for one-liner comments and not hijack them. - if line > max, - do: do_fix_lines(nodes, max, [shift_line(node, max - line - 2) | acc]), - else: do_fix_lines(nodes, line, [node | acc]) - end - def max_line([_ | _] = list), do: list |> List.last() |> max_line() def max_line(ast) do @@ -257,4 +194,88 @@ defmodule Styler.Style do max_line end end + + def order_line_meta_and_comments(nodes, comments, first_line) do + {nodes, comments, node_comments} = fix_lines(nodes, comments, first_line, [], []) + {nodes, Enum.sort_by(comments ++ node_comments, & &1.line)} + end + + defp fix_lines([], comments, _, node_acc, c_acc), do: {Enum.reverse(node_acc), comments, c_acc} + + defp fix_lines([{_, meta, _} = node | nodes], comments, start_line, n_acc, c_acc) do + line = meta[:line] + last_line = meta[:end_of_expression][:line] || max_line(node) + + {node, node_comments, comments} = + if start_line == line do + {node, [], comments} + else + {mine, comments} = comments_for_lines(comments, line, last_line) + line_with_comments = (List.first(mine)[:line] || line) - (List.first(mine)[:previous_eol_count] || 1) + 1 + + if line_with_comments == start_line do + {node, mine, comments} + else + shift = start_line - line_with_comments + # fix the node's line + node = shift_line(node, shift) + # fix the comment's line + mine = Enum.map(mine, &%{&1 | line: &1.line + shift}) + {node, mine, comments} + end + end + + {_, meta, _} = node + # @TODO what about comments that were free floating between blocks? i'm just ignoring them and maybe always will... + # kind of just want to shove them to the end though, so that they don't interrupt existing stanzas. + # i think that's accomplishable by doing a final call above that finds all comments in the comments list that weren't moved + # and which are in the range of start..finish and sets their lines to finish! + last_line = meta[:end_of_expression][:line] || max_line(node) + last_line = (meta[:end_of_expression][:newlines] || 1) + last_line + fix_lines(nodes, comments, last_line, [node | n_acc], node_comments ++ c_acc) + end + + @doc """ + Returns all comments "for" a node, including on the line before it. + see `comments_for_lines` for more + """ + def comments_for_node({_, m, _} = node, comments) do + last_line = m[:end_of_expression][:line] || max_line(node) + comments_for_lines(comments, m[:line], last_line) + end + + @doc """ + Gets all comments in range start_line..last_line, and any comments immediately before start_line.s + + 1. code + 2. # a + 3. # b + 4. code # c + 5. # d + 6. code + 7. # e + + here, comments_for_lines(comments, 4, 6) is "a", "b", "c", "d" + """ + def comments_for_lines(comments, start_line, last_line) do + comments |> Enum.reverse() |> comments_for_lines(start_line, last_line, [], []) + end + + defp comments_for_lines(reversed_comments, start, last, match, acc) + + defp comments_for_lines([], _, _, match, acc), do: {Enum.reverse(match), acc} + + defp comments_for_lines([%{line: line} = comment | rev_comments], start, last, match, acc) do + cond do + # after our block - no match + line > last -> comments_for_lines(rev_comments, start, last, match, [comment | acc]) + # after start, before last -- it's a match! + line >= start -> comments_for_lines(rev_comments, start, last, [comment | match], acc) + # this is a comment immediately before start, which means it's modifying this block... + # we count that as a match, and look above it to see if it's a multiline comment + line == start - 1 -> comments_for_lines(rev_comments, start - 1, last, [comment | match], acc) + # comment before start - we've thus iterated through all comments which could be in our range + true -> {match, Enum.reverse(rev_comments, [comment | acc])} + end + end end diff --git a/lib/style/comment_directives.ex b/lib/style/comment_directives.ex index 7b1ccb70..34e59b84 100644 --- a/lib/style/comment_directives.ex +++ b/lib/style/comment_directives.ex @@ -17,14 +17,15 @@ defmodule Styler.Style.CommentDirectives do @behaviour Styler.Style + alias Styler.Style alias Styler.Zipper def run(zipper, ctx) do - zipper = + {zipper, comments} = ctx.comments |> Enum.filter(&(&1.text == "# styler:sort")) |> Enum.map(& &1.line) - |> Enum.reduce(zipper, fn line, zipper -> + |> Enum.reduce({zipper, ctx.comments}, fn line, {zipper, comments} -> found = Zipper.find(zipper, fn {_, meta, _} -> Keyword.get(meta, :line, -1) >= line @@ -32,20 +33,30 @@ defmodule Styler.Style.CommentDirectives do end) if found do - # @TODO fix line numbers, move comments - Zipper.update(found, &sort/1) + {node, _} = found + {sorted, comments} = sort(node, ctx.comments) + {Zipper.replace(found, sorted), comments} else - zipper + {zipper, comments} end end) - {:halt, zipper, ctx} + {:halt, zipper, %{ctx | comments: comments}} end - defp sort({:__block__, meta, [list]}) when is_list(list), do: {:__block__, meta, [sort(list)]} - defp sort(list) when is_list(list), do: Enum.sort_by(list, &Macro.to_string/1) + defp sort({:__block__, meta, [list]} = node, comments) when is_list(list) do + list = Enum.sort_by(list, &Macro.to_string/1) + line = meta[:line] + # no need to fix line numbers if it's a single line structure + {list, comments} = + if line == Style.max_line(node), + do: {list, comments}, + else: Style.order_line_meta_and_comments(list, comments, line) - defp sort({:sigil_w, sm, [{:<<>>, bm, [string]}, modifiers]}) do + {{:__block__, meta, [list]}, comments} + end + + defp sort({:sigil_w, sm, [{:<<>>, bm, [string]}, modifiers]}, comments) do # ew. gotta be a better way. # this keeps indentation for the sigil via joiner, while prepend and append are the bookending whitespace {prepend, joiner, append} = @@ -61,10 +72,18 @@ defmodule Styler.Style.CommentDirectives do end string = string |> String.split() |> Enum.sort() |> Enum.join(joiner) - {:sigil_w, sm, [{:<<>>, bm, [prepend, string, append]}, modifiers]} + {{:sigil_w, sm, [{:<<>>, bm, [prepend, string, append]}, modifiers]}, comments} + end + + defp sort({:=, m, [lhs, rhs]}, comments) do + {rhs, comments} = sort(rhs, comments) + {{:=, m, [lhs, rhs]}, comments} + end + + defp sort({:@, m, [{a, am, [assignment]}]}, comments) do + {assignment, comments} = sort(assignment, comments) + {{:@, m, [{a, am, [assignment]}]}, comments} end - defp sort({:=, m, [lhs, rhs]}), do: {:=, m, [lhs, sort(rhs)]} - defp sort({:@, m, [{a, am, [assignment]}]}), do: {:@, m, [{a, am, [sort(assignment)]}]} - defp sort(x), do: x + defp sort(x, comments), do: {x, comments} end diff --git a/lib/style/configs.ex b/lib/style/configs.ex index ab110a0b..75a0d750 100644 --- a/lib/style/configs.ex +++ b/lib/style/configs.ex @@ -80,19 +80,14 @@ defmodule Styler.Style.Configs do |> Style.reset_newlines() |> Enum.concat(configs) - # `set_lines` performs better than `fix_line_numbers` for a large number of nodes moving, as it moves their comments with them - # however, it will also move any comments not associated with a node, causing wildly unpredictable sad times! - # so i'm trying to guess which change will be less damaging. - # moving >=3 nodes hints that this is an initial run, where `set_lines` definitely outperforms. {nodes, comments} = if changed?(nodes) do # after running, this block should take up the same # of lines that it did before # the first node of `rest` is greater than the highest line in configs, assignments # config line is the first line to be used as part of this block - # that will change when we consider preceding comments - {node_comments, _} = comments_for_node(config, comments) + {node_comments, _} = Style.comments_for_node(config, comments) first_line = min(List.last(node_comments)[:line] || cfm[:line], cfm[:line]) - set_lines(nodes, comments, first_line) + Style.order_line_meta_and_comments(nodes, comments, first_line) else {nodes, comments} end @@ -121,73 +116,6 @@ defmodule Styler.Style.Configs do defp changed?(_), do: false - defp set_lines(nodes, comments, first_line) do - {nodes, comments, node_comments} = set_lines(nodes, comments, first_line, [], []) - # @TODO if there are dangling comments between the nodes min/max, push them somewhere? - # likewise deal with conflicting line comments? - {nodes, Enum.sort_by(comments ++ node_comments, & &1.line)} - end - - def set_lines([], comments, _, node_acc, c_acc), do: {Enum.reverse(node_acc), comments, c_acc} - - def set_lines([{_, meta, _} = node | nodes], comments, start_line, n_acc, c_acc) do - line = meta[:line] - last_line = meta[:end_of_expression][:line] || Style.max_line(node) - - {node, node_comments, comments} = - if start_line == line do - {node, [], comments} - else - {mine, comments} = comments_for_lines(comments, line, last_line) - line_with_comments = (List.first(mine)[:line] || line) - (List.first(mine)[:previous_eol_count] || 1) + 1 - - if line_with_comments == start_line do - {node, mine, comments} - else - shift = start_line - line_with_comments - node = Style.shift_line(node, shift) - - mine = Enum.map(mine, &%{&1 | line: &1.line + shift}) - {node, mine, comments} - end - end - - {_, meta, _} = node - # @TODO what about comments that were free floating between blocks? i'm just ignoring them and maybe always will... - # kind of just want to shove them to the end though, so that they don't interrupt existing stanzas. - # i think that's accomplishable by doing a final call above that finds all comments in the comments list that weren't moved - # and which are in the range of start..finish and sets their lines to finish! - last_line = meta[:end_of_expression][:line] || Style.max_line(node) - last_line = (meta[:end_of_expression][:newlines] || 1) + last_line - set_lines(nodes, comments, last_line, [node | n_acc], node_comments ++ c_acc) - end - - defp comments_for_node({_, m, _} = node, comments) do - last_line = m[:end_of_expression][:line] || Style.max_line(node) - comments_for_lines(comments, m[:line], last_line) - end - - defp comments_for_lines(comments, start_line, last_line) do - comments - |> Enum.reverse() - |> comments_for_lines(start_line, last_line, [], []) - end - - defp comments_for_lines(reversed_comments, start, last, match, acc) - - defp comments_for_lines([], _, _, match, acc), do: {Enum.reverse(match), acc} - - defp comments_for_lines([%{line: line} = comment | rev_comments], start, last, match, acc) do - cond do - line > last -> comments_for_lines(rev_comments, start, last, match, [comment | acc]) - line >= start -> comments_for_lines(rev_comments, start, last, [comment | match], acc) - # @TODO bug: match line looks like `x = :foo # comment for x` - # could account for that by pre-running the formatter on config files :/ - line == start - 1 -> comments_for_lines(rev_comments, start - 1, last, [comment | match], acc) - true -> {match, Enum.reverse(rev_comments, [comment | acc])} - end - end - defp accumulate([{:config, _, [_, _ | _]} = c | siblings], cs, as), do: accumulate(siblings, [c | cs], as) defp accumulate([{:=, _, [_lhs, _rhs]} = a | siblings], cs, as), do: accumulate(siblings, cs, [a | as]) defp accumulate(rest, configs, assignments), do: {configs, assignments, rest} diff --git a/lib/style/module_directives.ex b/lib/style/module_directives.ex index 623ce5f0..eb10761e 100644 --- a/lib/style/module_directives.ex +++ b/lib/style/module_directives.ex @@ -263,7 +263,7 @@ defmodule Styler.Style.ModuleDirectives do acc.require ] |> Stream.concat() - |> Style.fix_line_numbers(List.first(nondirectives)) + |> fix_line_numbers(List.first(nondirectives)) # the # of aliases can be decreased during sorting - if there were any, we need to be sure to write the deletion if Enum.empty?(directives) do @@ -419,4 +419,66 @@ defmodule Styler.Style.ModuleDirectives do |> Enum.map(&elem(&1, 0)) |> Style.reset_newlines() end + + # TODO investigate removing this in favor of the Style.post_sort_cleanup(node, comments) + # "Fixes" the line numbers of nodes who have had their orders changed via sorting or other methods. + # This "fix" simply ensures that comments don't get wrecked as part of us moving AST nodes willy-nilly. + # + # The fix is rather naive, and simply enforces the following property on the code: + # A given node must have a line number less than the following node. + # Et voila! Comments behave much better. + # + # ## In Detail + # + # For example, given document + # + # 1: defmodule ... + # 2: alias B + # 3: # this is foo + # 4: def foo ... + # 5: alias A + # + # Sorting aliases the ast node for would put `alias A` (line 5) before `alias B` (line 2). + # + # 1: defmodule ... + # 5: alias A + # 2: alias B + # 3: # this is foo + # 4: def foo ... + # + # Elixir's document algebra would then encounter `line: 5` and immediately dump all comments with `line <= 5`, + # meaning after running through the formatter we'd end up with + # + # 1: defmodule + # 2: # hi + # 3: # this is foo + # 4: alias A + # 5: alias B + # 6: + # 7: def foo ... + # + # This function fixes that by seeing that `alias A` has a higher line number than its following sibling `alias B` and so + # updates `alias A`'s line to be preceding `alias B`'s line. + # + # Running the results of this function through the formatter now no longer dumps the comments prematurely + # + # 1: defmodule ... + # 2: alias A + # 3: alias B + # 4: # this is foo + # 5: def foo ... + defp fix_line_numbers(nodes, nil), do: fix_line_numbers(nodes, 999_999) + defp fix_line_numbers(nodes, {_, meta, _}), do: fix_line_numbers(nodes, meta[:line]) + defp fix_line_numbers(nodes, max), do: nodes |> Enum.reverse() |> do_fix_lines(max, []) + + defp do_fix_lines([], _, acc), do: acc + + defp do_fix_lines([{_, meta, _} = node | nodes], max, acc) do + line = meta[:line] + + # the -2 is just an ugly hack to leave room for one-liner comments and not hijack them. + if line > max, + do: do_fix_lines(nodes, max, [Style.shift_line(node, max - line - 2) | acc]), + else: do_fix_lines(nodes, line, [node | acc]) + end end diff --git a/test/style/comment_directives_test.exs b/test/style/comment_directives_test.exs index 487be6c1..def6af55 100644 --- a/test/style/comment_directives_test.exs +++ b/test/style/comment_directives_test.exs @@ -182,5 +182,56 @@ defmodule Styler.Style.CommentDirectivesTest do """ ) end + + test "treats comments nicely" do + assert_style( + """ + # pre-amble comment + # styler:sort + [ + {:phoenix, "~> 1.7"}, + # hackney comment + {:hackney, "1.18.1", override: true}, + {:styler, "~> 1.2", only: [:dev, :test], runtime: false}, + {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, + {:excellent_migrations, "~> 0.1", only: [:dev, :test], runtime: false}, + # ecto + {:ecto, "~> 3.12"}, + {:ecto_sql, "~> 3.12"}, + # genstage comment 1 + # genstage comment 2 + {:gen_stage, "~> 1.0", override: true}, + # telemetry + {:telemetry, "~> 1.0", override: true}, + # dangling comment + ] + + # some other comment + """, + """ + # pre-amble comment + # styler:sort + [ + {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, + # ecto + {:ecto, "~> 3.12"}, + {:ecto_sql, "~> 3.12"}, + {:excellent_migrations, "~> 0.1", only: [:dev, :test], runtime: false}, + # genstage comment 1 + # genstage comment 2 + {:gen_stage, "~> 1.0", override: true}, + # hackney comment + {:hackney, "1.18.1", override: true}, + {:phoenix, "~> 1.7"}, + {:styler, "~> 1.2", only: [:dev, :test], runtime: false}, + # telemetry + {:telemetry, "~> 1.0", override: true} + # dangling comment + ] + + # some other comment + """ + ) + end end end diff --git a/test/style_test.exs b/test/style_test.exs index 0f7f6785..43bc8b27 100644 --- a/test/style_test.exs +++ b/test/style_test.exs @@ -3,8 +3,6 @@ defmodule Styler.StyleTest do import Styler.Style, only: [displace_comments: 2, shift_comments: 3] - alias Styler.Style - @code """ # Above module defmodule Foo do @@ -117,16 +115,4 @@ defmodule Styler.StyleTest do end end end - - describe "fix_line_numbers" do - test "returns ast list with increasing line numbers" do - nodes = for n <- [1, 2, 999, 1000, 5, 6], do: {:node, [line: n], [n]} - fixed = Style.fix_line_numbers(nodes, 7) - - Enum.scan(fixed, fn {_, [line: this_line], _} = this_node, {_, [line: previous_line], _} -> - assert this_line >= previous_line - this_node - end) - end - end end From 13bc947745afa3e82c55ec37845f03def6aff896 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Mon, 13 Jan 2025 12:09:05 -0700 Subject: [PATCH 038/145] v1.3.0 --- CHANGELOG.md | 2 ++ README.md | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++-- mix.exs | 2 +- 3 files changed, 65 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e40e0c66..bdacbfcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ they can and will change without that change being reflected in Styler's semantic version. ## main +## 1.3.0 + ### Improvements #### `# styler:sort` Styler's first comment directive diff --git a/README.md b/README.md index 840b221e..6042da5c 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,11 @@ You can learn more about the history, purpose and implementation of Styler from ## Features -- auto-fixes [many credo rules](docs/credo.md), meaning you can turn them off to speed credo up +### AST Rewrites as part of `mix format` + +[See our Rewrites documentation on hexdocs](https://hexdocs.pm/styler/styles.html) +Styler fixes a plethora of elixir style and optimization issues automatically as part of `mix format`. In addition to automating corrections for [many credo rules](docs/credo.md) (meaning you can turn them off to speed credo up), Styler: + - [keeps a strict module layout](docs/module_directives.md#directive-organization) - alphabetizes module directives - [extracts repeated aliases](docs/module_directives.md#alias-lifting) @@ -21,7 +25,63 @@ You can learn more about the history, purpose and implementation of Styler from - replaces strings with sigils when the string has many escaped quotes - ... and so much more -[See our Rewrites documentation on hexdocs](https://hexdocs.pm/styler/styles.html) +### Maintain static list order via `# styler:sort` + +Styler can keep static values sorted for your team as part of its formatting pass. To instruct it to do so, replace any `# Please keep this list sorted!` notes you wrote to your teammates with `# styler:sort`. + +#### Examples + +```elixir +# styler:sort +[:c, :a, :b] + +# styler:sort +~w(a list of words) + +# styler:sort +@country_codes ~w( + en_US + po_PO + fr_CA + ja_JP +) + +# styler:sort +a_var = + [ + Modules, + In, + A, + List + ] +``` + +Would yield: + +```elixir +# styler:sort +[:a, :b, :c] + +# styler:sort +~w(a list of words) + +# styler:sort +@country_codes ~w( + en_US + fr_CA + ja_JP + po_PO +) + +# styler:sort +a_var = + [ + A, + In, + List, + Modules + ] +``` ## Who is Styler for? diff --git a/mix.exs b/mix.exs index e7297a53..692511e5 100644 --- a/mix.exs +++ b/mix.exs @@ -12,7 +12,7 @@ defmodule Styler.MixProject do use Mix.Project # Don't forget to bump the README when doing non-patch version changes - @version "1.2.1" + @version "1.3.0" @url "https://github.com/adobe/elixir-styler" def project do From 03b1ee6a706e858534d870ba961912ca456ed380 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Mon, 13 Jan 2025 12:14:00 -0700 Subject: [PATCH 039/145] correct changelog --- CHANGELOG.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bdacbfcf..9938679e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ they can and will change without that change being reflected in Styler's semanti #### `# styler:sort` Styler's first comment directive -Styler will now keep a user-designated list or wordlist (`~w` sigil) sorted as part of formatting via the use of comments. +Styler will now keep a user-designated list or wordlist (`~w` sigil) sorted as part of formatting via the use of comments. Elements of the list are sorted by their string representation. The intention is to remove comments to humans, like `# Please keep this list sorted!`, in favor of comments to robots: `# styler:sort`. Personally speaking, Styler is much better at alphabetical-order than I ever will be. @@ -70,8 +70,6 @@ a_var = ] ``` -Sorting is done according to erlang term ordering, so lists with elements of multiple types will work just fine. - ## 1.2.1 ### Fixes From d6a2a5e2f91c5aceede2fb4b78c07c0fda223e4f Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Mon, 13 Jan 2025 12:43:39 -0700 Subject: [PATCH 040/145] fix twople bug in sort directive, add map sorting --- CHANGELOG.md | 12 ++++ lib/style.ex | 10 +++- lib/style/comment_directives.ex | 14 ++++- test/style/comment_directives_test.exs | 76 ++++++++++++++++++++++++++ 4 files changed, 108 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9938679e..5b94ed85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ they can and will change without that change being reflected in Styler's semantic version. ## main +## 1.3.1 + +### Improvements + +- `# styler:sort` now works with maps and the `defstruct` macro + +### Fixes + +- `# styler:sort` no longer blows up on keyword lists :X + +### Fixes + ## 1.3.0 ### Improvements diff --git a/lib/style.ex b/lib/style.ex index 80c50b23..52a2fd35 100644 --- a/lib/style.ex +++ b/lib/style.ex @@ -202,7 +202,8 @@ defmodule Styler.Style do defp fix_lines([], comments, _, node_acc, c_acc), do: {Enum.reverse(node_acc), comments, c_acc} - defp fix_lines([{_, meta, _} = node | nodes], comments, start_line, n_acc, c_acc) do + defp fix_lines([node | nodes], comments, start_line, n_acc, c_acc) do + meta = meta(node) line = meta[:line] last_line = meta[:end_of_expression][:line] || max_line(node) @@ -225,7 +226,7 @@ defmodule Styler.Style do end end - {_, meta, _} = node + meta = meta(node) # @TODO what about comments that were free floating between blocks? i'm just ignoring them and maybe always will... # kind of just want to shove them to the end though, so that they don't interrupt existing stanzas. # i think that's accomplishable by doing a final call above that finds all comments in the comments list that weren't moved @@ -235,6 +236,11 @@ defmodule Styler.Style do fix_lines(nodes, comments, last_line, [node | n_acc], node_comments ++ c_acc) end + # typical node + def meta({_, meta, _}), do: meta + # kwl tuple ala a: :b + def meta({{_, meta, _}, _}), do: meta + @doc """ Returns all comments "for" a node, including on the line before it. see `comments_for_lines` for more diff --git a/lib/style/comment_directives.ex b/lib/style/comment_directives.ex index 34e59b84..7eade124 100644 --- a/lib/style/comment_directives.ex +++ b/lib/style/comment_directives.ex @@ -44,7 +44,7 @@ defmodule Styler.Style.CommentDirectives do {:halt, zipper, %{ctx | comments: comments}} end - defp sort({:__block__, meta, [list]} = node, comments) when is_list(list) do + defp sort({parent, meta, [list]} = node, comments) when parent in ~w(defstruct __block__)a and is_list(list) do list = Enum.sort_by(list, &Macro.to_string/1) line = meta[:line] # no need to fix line numbers if it's a single line structure @@ -53,7 +53,17 @@ defmodule Styler.Style.CommentDirectives do do: {list, comments}, else: Style.order_line_meta_and_comments(list, comments, line) - {{:__block__, meta, [list]}, comments} + {{parent, meta, [list]}, comments} + end + + defp sort({:%{}, meta, list}, comments) when is_list(list) do + {{:__block__, meta, [list]}, comments} = sort({:__block__, meta, [list]}, comments) + {{:%{}, meta, list}, comments} + end + + defp sort({:%, m, [struct, map]}, comments) do + {map, comments} = sort(map, comments) + {{:%, m, [struct, map]}, comments} end defp sort({:sigil_w, sm, [{:<<>>, bm, [string]}, modifiers]}, comments) do diff --git a/test/style/comment_directives_test.exs b/test/style/comment_directives_test.exs index def6af55..13dc0bfa 100644 --- a/test/style/comment_directives_test.exs +++ b/test/style/comment_directives_test.exs @@ -40,6 +40,82 @@ defmodule Styler.Style.CommentDirectivesTest do ) end + test "sort keywordy things" do + assert_style( + """ + # styler:sort + [ + c: 2, + b: 3, + a: 4, + d: 1 + ] + """, + """ + # styler:sort + [ + a: 4, + b: 3, + c: 2, + d: 1 + ] + """ + ) + + assert_style( + """ + # styler:sort + %{ + c: 2, + b: 3, + a: 4, + d: 1 + } + """, + """ + # styler:sort + %{ + a: 4, + b: 3, + c: 2, + d: 1 + } + """ + ) + + assert_style( + """ + # styler:sort + %Struct{ + c: 2, + b: 3, + a: 4, + d: 1 + } + """, + """ + # styler:sort + %Struct{ + a: 4, + b: 3, + c: 2, + d: 1 + } + """ + ) + + assert_style( + """ + # styler:sort + defstruct c: 2, b: 3, a: 4, d: 1 + """, + """ + # styler:sort + defstruct a: 4, b: 3, c: 2, d: 1 + """ + ) + end + test "sorts sigils" do assert_style("# styler:sort\n~w|c a b|", "# styler:sort\n~w|a b c|") From 30446bf75db5a7df6b3d40a78694a2e7ce7036f7 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Mon, 13 Jan 2025 12:43:56 -0700 Subject: [PATCH 041/145] v1.3.1 --- mix.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mix.exs b/mix.exs index 692511e5..2c3040b3 100644 --- a/mix.exs +++ b/mix.exs @@ -12,7 +12,7 @@ defmodule Styler.MixProject do use Mix.Project # Don't forget to bump the README when doing non-patch version changes - @version "1.3.0" + @version "1.3.1" @url "https://github.com/adobe/elixir-styler" def project do From 5d3d620406ca64f29c8fb7161fe36957408e12a0 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Mon, 13 Jan 2025 12:50:11 -0700 Subject: [PATCH 042/145] defstruct with list literal --- lib/style/comment_directives.ex | 7 +++++++ test/style/comment_directives_test.exs | 25 +++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/lib/style/comment_directives.ex b/lib/style/comment_directives.ex index 7eade124..1677570d 100644 --- a/lib/style/comment_directives.ex +++ b/lib/style/comment_directives.ex @@ -44,6 +44,7 @@ defmodule Styler.Style.CommentDirectives do {:halt, zipper, %{ctx | comments: comments}} end + # defstruct with a syntax-sugared keyword list hits here defp sort({parent, meta, [list]} = node, comments) when parent in ~w(defstruct __block__)a and is_list(list) do list = Enum.sort_by(list, &Macro.to_string/1) line = meta[:line] @@ -56,6 +57,12 @@ defmodule Styler.Style.CommentDirectives do {{parent, meta, [list]}, comments} end + # defstruct with a literal list + defp sort({:defstruct, meta, [{:__block__, _, [_]} = list]}, comments) do + {list, comments} = sort(list, comments) + {{:defstruct, meta, [list]}, comments} + end + defp sort({:%{}, meta, list}, comments) when is_list(list) do {{:__block__, meta, [list]}, comments} = sort({:__block__, meta, [list]}, comments) {{:%{}, meta, list}, comments} diff --git a/test/style/comment_directives_test.exs b/test/style/comment_directives_test.exs index 13dc0bfa..5fe60c87 100644 --- a/test/style/comment_directives_test.exs +++ b/test/style/comment_directives_test.exs @@ -114,6 +114,31 @@ defmodule Styler.Style.CommentDirectivesTest do defstruct a: 4, b: 3, c: 2, d: 1 """ ) + + assert_style( + """ + # styler:sort + defstruct [ + :repo, + :query, + :order, + :chunk_size, + :timeout, + :cursor + ] + """, + """ + # styler:sort + defstruct [ + :chunk_size, + :cursor, + :order, + :query, + :repo, + :timeout + ] + """ + ) end test "sorts sigils" do From bef00c9a88940014aa6eb2afb725a3579f96008f Mon Sep 17 00:00:00 2001 From: Theodor Fiedler Date: Mon, 13 Jan 2025 21:54:02 +0100 Subject: [PATCH 043/145] ci: update elixir and erlang versions --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c233861a..a51e0fc2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,8 +9,8 @@ jobs: name: Ex${{matrix.elixir}}/OTP${{matrix.otp}} strategy: matrix: - elixir: ['1.15.7', '1.16.0', '1.17.0-rc.0'] - otp: ['25.1.2'] + elixir: ['1.15.8', '1.16.3', '1.17.3'] + otp: ['25.3.2'] steps: - uses: actions/checkout@v4 - uses: erlef/setup-beam@v1 From d132d7978c08d8af9d3b8a33487e870e06a5657d Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Mon, 13 Jan 2025 14:01:03 -0700 Subject: [PATCH 044/145] sort directive: sort values of keys. Closes #208 --- CHANGELOG.md | 4 +++ lib/style.ex | 1 + lib/style/comment_directives.ex | 11 ++++-- test/style/comment_directives_test.exs | 49 ++++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b94ed85..0056aa7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ they can and will change without that change being reflected in Styler's semantic version. ## main +### Improvements + +- `# styler:sort` can be used to sort values of key-value pairs. eg, sort the value of a single key in a map (Closes #208, h/t @ypconstante) + ## 1.3.1 ### Improvements diff --git a/lib/style.ex b/lib/style.ex index 52a2fd35..f1a2dcf8 100644 --- a/lib/style.ex +++ b/lib/style.ex @@ -240,6 +240,7 @@ defmodule Styler.Style do def meta({_, meta, _}), do: meta # kwl tuple ala a: :b def meta({{_, meta, _}, _}), do: meta + def meta(_), do: nil @doc """ Returns all comments "for" a node, including on the line before it. diff --git a/lib/style/comment_directives.ex b/lib/style/comment_directives.ex index 1677570d..5f70aa30 100644 --- a/lib/style/comment_directives.ex +++ b/lib/style/comment_directives.ex @@ -27,9 +27,9 @@ defmodule Styler.Style.CommentDirectives do |> Enum.map(& &1.line) |> Enum.reduce({zipper, ctx.comments}, fn line, {zipper, comments} -> found = - Zipper.find(zipper, fn - {_, meta, _} -> Keyword.get(meta, :line, -1) >= line - _ -> false + Zipper.find(zipper, fn node -> + node_line = Style.meta(node)[:line] || -1 + node_line >= line end) if found do @@ -102,5 +102,10 @@ defmodule Styler.Style.CommentDirectives do {{:@, m, [{a, am, [assignment]}]}, comments} end + defp sort({key, value}, comments) do + {value, comments} = sort(value, comments) + {{key, value}, comments} + end + defp sort(x, comments), do: {x, comments} end diff --git a/test/style/comment_directives_test.exs b/test/style/comment_directives_test.exs index 5fe60c87..f7fa7b12 100644 --- a/test/style/comment_directives_test.exs +++ b/test/style/comment_directives_test.exs @@ -141,6 +141,55 @@ defmodule Styler.Style.CommentDirectivesTest do ) end + test "inside keywords" do + assert_style( + """ + %{ + key: + # styler:sort + [ + 3, + 2, + 1 + ] + } + """, + """ + %{ + # styler:sort + key: [ + 1, + 2, + 3 + ] + } + """ + ) + + assert_style( + """ + %{ + # styler:sort + key: [ + 3, + 2, + 1 + ] + } + """, + """ + %{ + # styler:sort + key: [ + 1, + 2, + 3 + ] + } + """ + ) + end + test "sorts sigils" do assert_style("# styler:sort\n~w|c a b|", "# styler:sort\n~w|a b c|") From 88d3334811de7407cd95d0e784f1e2ab20f6e162 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Tue, 14 Jan 2025 11:00:30 -0700 Subject: [PATCH 045/145] v1.3.2 --- CHANGELOG.md | 2 ++ mix.exs | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0056aa7f..4f65224e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ they can and will change without that change being reflected in Styler's semantic version. ## main +## 1.3.2 + ### Improvements - `# styler:sort` can be used to sort values of key-value pairs. eg, sort the value of a single key in a map (Closes #208, h/t @ypconstante) diff --git a/mix.exs b/mix.exs index 2c3040b3..7834455d 100644 --- a/mix.exs +++ b/mix.exs @@ -12,7 +12,7 @@ defmodule Styler.MixProject do use Mix.Project # Don't forget to bump the README when doing non-patch version changes - @version "1.3.1" + @version "1.3.2" @url "https://github.com/adobe/elixir-styler" def project do From 9b0207b89547fbd4d613663bafdceaddf8acaf2d Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Wed, 15 Jan 2025 15:42:51 -0700 Subject: [PATCH 046/145] fix comments bug in styler:sort directive --- CHANGELOG.md | 4 ++++ lib/style/comment_directives.ex | 3 +-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f65224e..327c8960 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ they can and will change without that change being reflected in Styler's semantic version. ## main +### Fixes + +- fix a bug in comment-movement when multiple `# styler:sort` directives are added to a file at the same time + ## 1.3.2 ### Improvements diff --git a/lib/style/comment_directives.ex b/lib/style/comment_directives.ex index 5f70aa30..3c25c7c4 100644 --- a/lib/style/comment_directives.ex +++ b/lib/style/comment_directives.ex @@ -33,8 +33,7 @@ defmodule Styler.Style.CommentDirectives do end) if found do - {node, _} = found - {sorted, comments} = sort(node, ctx.comments) + {sorted, comments} = found |> Zipper.node() |> sort(comments) {Zipper.replace(found, sorted), comments} else {zipper, comments} From efb2cb9c5cf87c5ecbf5123455a35cf2f0f2c544 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Thu, 16 Jan 2025 14:13:59 -0700 Subject: [PATCH 047/145] styler:sort arbitrary ast within do end blocks --- CHANGELOG.md | 28 ++++++++++++++++++++++ lib/style/comment_directives.ex | 18 ++++++++++++++ test/style/comment_directives_test.exs | 33 ++++++++++++++++++++++++++ 3 files changed, 79 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 327c8960..52ac2024 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,34 @@ they can and will change without that change being reflected in Styler's semantic version. ## main +### Improvements + +- `# styler:sort` will sort arbitrary ast nodes within a `do end` block: + + Given: + # styler:sort + my_macro "some arg" do + another_macro :q + another_macro :w + another_macro :e + another_macro :r + another_macro :t + another_macro :y + end + + We get + # styler:sort + my_macro "some arg" do + another_macro :e + another_macro :q + another_macro :r + another_macro :t + another_macro :w + another_macro :y + end + + + ### Fixes - fix a bug in comment-movement when multiple `# styler:sort` directives are added to a file at the same time diff --git a/lib/style/comment_directives.ex b/lib/style/comment_directives.ex index 3c25c7c4..95edec58 100644 --- a/lib/style/comment_directives.ex +++ b/lib/style/comment_directives.ex @@ -106,5 +106,23 @@ defmodule Styler.Style.CommentDirectives do {{key, value}, comments} end + # sorts arbitrary ast nodes within a `do end` list + defp sort({f, m, args} = node, comments) do + if m[:do] && m[:end] && match?([{{:__block__, _, [:do]}, {:__block__, _, _}}], List.last(args)) do + {[{{:__block__, m1, [:do]}, {:__block__, m2, nodes}}], args} = List.pop_at(args, -1) + + {nodes, comments} = + nodes + |> Enum.sort_by(&Macro.to_string/1) + |> Style.order_line_meta_and_comments(comments, m[:line]) + + args = List.insert_at(args, -1, [{{:__block__, m1, [:do]}, {:__block__, m2, nodes}}]) + + {{f, m, args}, comments} + else + {node, comments} + end + end + defp sort(x, comments), do: {x, comments} end diff --git a/test/style/comment_directives_test.exs b/test/style/comment_directives_test.exs index f7fa7b12..68b6f4f9 100644 --- a/test/style/comment_directives_test.exs +++ b/test/style/comment_directives_test.exs @@ -333,6 +333,39 @@ defmodule Styler.Style.CommentDirectivesTest do ) end + test "nodes within a do end block" do + assert_style( + """ + # styler:sort + my_macro "some arg" do + another_macro :q + # w + another_macro :w + another_macro :e + # r comment 1 + # r comment 2 + another_macro :r + another_macro :t + another_macro :y + end + """, + """ + # styler:sort + my_macro "some arg" do + another_macro(:e) + another_macro(:q) + # r comment 1 + # r comment 2 + another_macro(:r) + another_macro(:t) + # w + another_macro(:w) + another_macro(:y) + end + """ + ) + end + test "treats comments nicely" do assert_style( """ From 9983bf2ae08837ea1d16b1e87e782a41265800b0 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Tue, 21 Jan 2025 16:52:12 -0700 Subject: [PATCH 048/145] improve `with` statement replacements --- CHANGELOG.md | 3 +- lib/style/blocks.ex | 210 ++++++++++++++++++++----------------- lib/zipper.ex | 12 +-- test/style/blocks_test.exs | 7 +- test/zipper_test.exs | 6 +- 5 files changed, 131 insertions(+), 107 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52ac2024..2c95620c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ they can and will change without that change being reflected in Styler's semanti ### Improvements +- `with do: body` and variations with no arrows in the head will be rewritten to just `body` - `# styler:sort` will sort arbitrary ast nodes within a `do end` block: Given: @@ -30,8 +31,6 @@ they can and will change without that change being reflected in Styler's semanti another_macro :y end - - ### Fixes - fix a bug in comment-movement when multiple `# styler:sort` directives are added to a file at the same time diff --git a/lib/style/blocks.ex b/lib/style/blocks.ex index 96e10ce9..3cb1ed47 100644 --- a/lib/style/blocks.ex +++ b/lib/style/blocks.ex @@ -75,103 +75,32 @@ defmodule Styler.Style.Blocks do {:cont, Zipper.replace(zipper, {:if, m, children}), ctx} end - # Credo.Check.Refactor.WithClauses - def run({{:with, with_meta, children}, _} = zipper, ctx) when is_list(children) do - # a std lib `with` block will have at least one left arrow and a `do` body. anything else we skip ¯\_(ツ)_/¯ - arrow_or_match? = &(left_arrow?(&1) || match?({:=, _, _}, &1)) - - if Enum.any?(children, arrow_or_match?) and Enum.any?(children, &Style.do_block?/1) 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 = - case elses do - [{{_, _, [:else]}, [{:->, _, [[left], right]}]}] -> if nodes_equivalent?(left, right), do: [], else: elses - _ -> elses - end - - {reversed_clauses, do_body} = - cond do - # Put the postroll into the body - Enum.any?(postroll) -> - {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} - - # Credo.Check.Refactor.RedundantWithClauseResult - Enum.empty?(elses) and nodes_equivalent?(lhs, do_body) -> - {rest, rhs} - - # no change - true -> - {reversed_clauses, do_body} - end - - do_line = do_meta[:line] - final_clause_line = final_clause_meta[:line] - - do_line = - cond do - do_meta[:format] == :keyword && final_clause_line + 1 >= do_line -> do_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}) + def run({{:with, _, [[{{:__block__, _, [:do]}, body} | _]]}, _} = zipper, ctx) do + {:cont, Zipper.replace(zipper, body), ctx} + end - 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 `<-` canged, so we should have another look at this with statement - Enum.any?(postroll) -> - run(zipper, ctx) + # 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} - true -> - # of clauess didn't change, so don't reecurse or we'll loop FOREEEVEERR - {:cont, zipper, ctx} - end - end - else - # maybe this isn't a with statement - could be a function named `with` - # or it's just a with statement with no arrows, but that's too saddening to imagine - {:cont, zipper, ctx} + # maybe this isn't a with statement - could be a function named `with` or something. + true -> + {:cont, zipper, ctx} end end @@ -217,6 +146,97 @@ defmodule Styler.Style.Blocks do 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 = + case elses do + [{{_, _, [:else]}, [{:->, _, [[left], right]}]}] -> if nodes_equivalent?(left, right), do: [], else: elses + _ -> elses + end + + {reversed_clauses, do_body} = + cond do + # Put the postroll into the body + Enum.any?(postroll) -> + {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} + + # Credo.Check.Refactor.RedundantWithClauseResult + Enum.empty?(elses) and nodes_equivalent?(lhs, do_body) -> + {rest, rhs} + + # no change + true -> + {reversed_clauses, do_body} + end + + do_line = do_meta[:line] + final_clause_line = final_clause_meta[:line] + + do_line = + cond do + do_meta[:format] == :keyword && final_clause_line + 1 >= do_line -> do_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 `<-` canged, so we should have another look at this with statement + Enum.any?(postroll) -> + run(zipper, ctx) + + true -> + # of clauess 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` diff --git a/lib/zipper.ex b/lib/zipper.ex index 0cdb17e8..9b43d1e1 100644 --- a/lib/zipper.ex +++ b/lib/zipper.ex @@ -172,18 +172,18 @@ defmodule Styler.Zipper do 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]}} + def insert_left(zipper, child), do: prepend_siblings(zipper, [child]) @doc """ Inserts many siblings to the left. + If the node is at the top of the tree, builds a new root `:__block__` while maintaining focus on the current node. Equivalent to Enum.reduce(siblings, zipper, &Zipper.insert_left(&2, &1)) """ @spec prepend_siblings(zipper, [tree]) :: zipper - def prepend_siblings({_, nil}, _), do: raise(ArgumentError, message: "Can't insert siblings at the top level.") + def prepend_siblings({node, nil}, siblings), do: {:__block__, [], siblings ++ [node]} |> zip() |> down() |> rightmost() def prepend_siblings({tree, meta}, siblings), do: {tree, %{meta | l: Enum.reverse(siblings, meta.l)}} @doc """ @@ -192,18 +192,18 @@ defmodule Styler.Zipper do 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]}} + def insert_right(zipper, child), do: insert_siblings(zipper, [child]) @doc """ Inserts many siblings to the right. + If the node is at the top of the tree, builds a new root `:__block__` while maintaining focus on the current node. Equivalent to Enum.reduce(siblings, zipper, &Zipper.insert_right(&2, &1)) """ @spec insert_siblings(zipper, [tree]) :: zipper - def insert_siblings({_, nil}, _), do: raise(ArgumentError, message: "Can't insert siblings at the top level.") + def insert_siblings({node, nil}, siblings), do: {:__block__, [], [node | siblings]} |> zip() |> down() def insert_siblings({tree, meta}, siblings), do: {tree, %{meta | r: siblings ++ meta.r}} @doc """ diff --git a/test/style/blocks_test.exs b/test/style/blocks_test.exs index fae1144b..83cbab40 100644 --- a/test/style/blocks_test.exs +++ b/test/style/blocks_test.exs @@ -384,6 +384,11 @@ defmodule Styler.Style.BlocksTest do z """ ) + + assert_style "with do: x", "x" + assert_style "with do x end", "x" + assert_style "with do x else foo -> bar end", "x" + assert_style "with foo() do bar() else _ -> baz() end", "foo()\nbar()" end test "doesn't false positive with vars" do @@ -430,7 +435,7 @@ defmodule Styler.Style.BlocksTest do """ ) - for nontrivial_head <- ["foo", ":ok <- foo, :ok <- bar"] do + for nontrivial_head <- [":ok <- foo, :ok <- bar"] do assert_style(""" with #{nontrivial_head} do :success diff --git a/test/zipper_test.exs b/test/zipper_test.exs index fc03cd28..2359d481 100644 --- a/test/zipper_test.exs +++ b/test/zipper_test.exs @@ -470,9 +470,9 @@ defmodule StylerTest.ZipperTest do |> 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 + test "builds a new root node made of a block" do + assert {42, %{l: [:nope], ptree: {{:__block__, _, _}, nil}}} = 42 |> Zipper.zip() |> Zipper.insert_left(:nope) + assert {42, %{r: [:nope], ptree: {{:__block__, _, _}, nil}}} = 42 |> Zipper.zip() |> Zipper.insert_right(:nope) end end From 470b3906fbcf16482b0784f3bfc6c1b9ef37be22 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Tue, 21 Jan 2025 16:54:20 -0700 Subject: [PATCH 049/145] v1.3.3 --- CHANGELOG.md | 3 +++ mix.exs | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c95620c..ca40bf50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,11 @@ **Note** Styler's only public API is its usage as a formatter plugin. While you're welcome to play with its internals, they can and will change without that change being reflected in Styler's semantic version. + ## main +## 1.3.3 + ### Improvements - `with do: body` and variations with no arrows in the head will be rewritten to just `body` diff --git a/mix.exs b/mix.exs index 7834455d..8d3df8e5 100644 --- a/mix.exs +++ b/mix.exs @@ -12,7 +12,7 @@ defmodule Styler.MixProject do use Mix.Project # Don't forget to bump the README when doing non-patch version changes - @version "1.3.2" + @version "1.3.3" @url "https://github.com/adobe/elixir-styler" def project do From 7932147e5e986b954eedf1bc38d3f080c04fea89 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Fri, 24 Jan 2025 07:13:16 -0500 Subject: [PATCH 050/145] alias lifting: shrink when alias already exists. closes #201 --- CHANGELOG.md | 18 +++++++++++ lib/style/module_directives.ex | 32 ++++++++++++------- .../module_directives/alias_lifting_test.exs | 19 +++++++++++ 3 files changed, 57 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca40bf50..2902eae2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ they can and will change without that change being reflected in Styler's semanti ## main +### Improvements + +- alias lifting: styler will now replace an expanded alias with its alias when the user has already defined that alias (#201, h/t me) + + example: + alias A.B.C + + A.B.C.foo() + A.B.C.bar() + A.B.C.baz() + + becomes: + alias A.B.C + + C.foo() + C.bar() + C.baz() + ## 1.3.3 ### Improvements diff --git a/lib/style/module_directives.ex b/lib/style/module_directives.ex index eb10761e..6d442dd5 100644 --- a/lib/style/module_directives.ex +++ b/lib/style/module_directives.ex @@ -282,8 +282,7 @@ defmodule Styler.Style.ModuleDirectives do # we can't use the dealias map built into state as that's what things look like before sorting # now that we've sorted, it could be different! dealiases = AliasEnv.define(aliases) - excluded = dealiases |> Map.keys() |> Enum.into(Styler.Config.get(:lifting_excludes)) - liftable = find_liftable_aliases(requires ++ nondirectives, excluded) + liftable = find_liftable_aliases(requires ++ nondirectives, dealiases) if Enum.any?(liftable) do # This is a silly hack that helps comments stay put. @@ -306,7 +305,9 @@ defmodule Styler.Style.ModuleDirectives do end end - defp find_liftable_aliases(ast, excluded) do + defp find_liftable_aliases(ast, dealiases) do + excluded = dealiases |> Map.keys() |> Enum.into(Styler.Config.get(:lifting_excludes)) + ast |> Zipper.zip() |> Zipper.reduce_while(%{}, fn @@ -333,15 +334,22 @@ defmodule Styler.Style.ModuleDirectives do last = List.last(aliases) lifts = - if last in excluded or not Enum.all?(aliases, &is_atom/1) do - lifts - else - Map.update(lifts, last, {aliases, false}, fn - {^aliases, _} -> {aliases, true} - # if we have `Foo.Bar.Baz` and `Foo.Bar.Bop.Baz` both not aliased, we'll create a collision by lifting both - # grouping by last alias lets us detect these collisions - _ -> :collision_with_last - end) + cond do + # this alias already exists, they just wrote it out fully and are leaving it up to us to shorten it down! + dealiases[last] == aliases -> + Map.put(lifts, last, {aliases, true}) + + last in excluded or Enum.any?(aliases, &(not is_atom(&1))) -> + lifts + + # track how often we see this alias - once we've seen it a second time we'll known + true -> + Map.update(lifts, last, {aliases, false}, fn + {^aliases, _} -> {aliases, true} + # if we have `Foo.Bar.Baz` and `Foo.Bar.Bop.Baz` both not aliased, we'll create a collision by lifting both + # grouping by last alias lets us detect these collisions + _ -> :collision_with_last + end) end {:skip, zipper, lifts} diff --git a/test/style/module_directives/alias_lifting_test.exs b/test/style/module_directives/alias_lifting_test.exs index e1793c0d..454654d4 100644 --- a/test/style/module_directives/alias_lifting_test.exs +++ b/test/style/module_directives/alias_lifting_test.exs @@ -202,6 +202,25 @@ defmodule Styler.Style.ModuleDirectives.AliasLiftingTest do ) end + test "replaces known aliases" do + assert_style( + """ + alias A.B.C + + A.B.C.foo() + A.B.C.foo() + A.B.C.foo() + """, + """ + alias A.B.C + + C.foo() + C.foo() + C.foo() + """ + ) + end + describe "comments stay put" do test "comments before alias stanza" do assert_style( From ff7755d64d1bb588dac98edc2952dc07ec1134ee Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Fri, 24 Jan 2025 11:26:46 -0500 Subject: [PATCH 051/145] alias lifting: be better about conflicts. Closes #193 --- CHANGELOG.md | 6 +- lib/style/module_directives.ex | 60 ++++++- .../module_directives/alias_lifting_test.exs | 157 +++++++++++++++--- 3 files changed, 196 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2902eae2..09724539 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,10 @@ they can and will change without that change being reflected in Styler's semanti ### Improvements -- alias lifting: styler will now replace an expanded alias with its alias when the user has already defined that alias (#201, h/t me) +This release taught Styler to try just that little bit harder when doing alias lifting. + +- general improvements around conflict detection, lifting in more correct places and fewer incorrect places (#193, h/t @jsw800) +- use knowledge of existing aliases to shorten invocations (#201, h/t me) example: alias A.B.C @@ -23,6 +26,7 @@ they can and will change without that change being reflected in Styler's semanti C.bar() C.baz() + ## 1.3.3 ### Improvements diff --git a/lib/style/module_directives.ex b/lib/style/module_directives.ex index 6d442dd5..bafc0124 100644 --- a/lib/style/module_directives.ex +++ b/lib/style/module_directives.ex @@ -308,8 +308,12 @@ defmodule Styler.Style.ModuleDirectives do defp find_liftable_aliases(ast, dealiases) do excluded = dealiases |> Map.keys() |> Enum.into(Styler.Config.get(:lifting_excludes)) + firsts = MapSet.new(dealiases, fn {_last, [first | _]} -> first end) + ast |> Zipper.zip() + # we're reducing a datastructure that looks like + # %{last => {aliases, seen_before?} | :some_collision_probelm} |> Zipper.reduce_while(%{}, fn # we don't want to rewrite alias name `defx Aliases ... do` of these three keywords {{defx, _, args}, _} = zipper, lifts when defx in ~w(defmodule defimpl defprotocol)a -> @@ -330,7 +334,7 @@ defmodule Styler.Style.ModuleDirectives do {{:quote, _, _}, _} = zipper, lifts -> {:skip, zipper, lifts} - {{:__aliases__, _, [_, _, _ | _] = aliases}, _} = zipper, lifts -> + {{:__aliases__, _, [first, _, _ | _] = aliases}, _} = zipper, lifts -> last = List.last(aliases) lifts = @@ -342,13 +346,54 @@ defmodule Styler.Style.ModuleDirectives do last in excluded or Enum.any?(aliases, &(not is_atom(&1))) -> lifts - # track how often we see this alias - once we've seen it a second time we'll known + # aliasing this would change the meaning of an existing alias + last > first and last in firsts -> + lifts + + # We've seen this once before, time to mark it for lifting and do some bookkeeping for first-collisions + lifts[last] == {aliases, false} -> + lifts + |> Map.put(last, {aliases, true}) + |> Map.update!(first, fn + {:collision_with_first, claimants, colliders} -> + # release our claim on this collision + claimants = MapSet.delete(claimants, aliases) + + if Enum.empty?(claimants) and Enum.any?(colliders) do + # no more claimants, try to promote a collider to be lifted + + colliders = Enum.to_list(colliders) + # There's no longer a collision because the only claimant is being lifted. + # So, promote a claimant with these criteria + # - required: its first comes _after_ last, so we aren't promoting an alias that changes the meaning of the other alias we're doing + # - preferred: take a collider we know we want to lift (we've seen it multiple times) + Enum.find(colliders, fn {[first | _], seen?} -> seen? and first > last end) || + Enum.find(colliders, fn {[first | _], _} -> first > last end) || + :collision_with_first + else + {:collision_with_first, claimants, colliders} + end + + other -> + other + end) + true -> - Map.update(lifts, last, {aliases, false}, fn - {^aliases, _} -> {aliases, true} - # if we have `Foo.Bar.Baz` and `Foo.Bar.Bop.Baz` both not aliased, we'll create a collision by lifting both - # grouping by last alias lets us detect these collisions - _ -> :collision_with_last + lifts + |> Map.update(last, {aliases, false}, fn + # if something is claiming the atom we want, add ourselves to the list of colliders + {:collision_with_first, claimers, colliders} -> + {:collision_with_first, claimers, Map.update(colliders, aliases, false, fn _ -> true end)} + + other -> + other + end) + |> Map.update(first, {:collision_with_first, MapSet.new([aliases]), %{}}, fn + {:collision_with_first, claimers, colliders} -> + {:collision_with_first, MapSet.put(claimers, aliases), colliders} + + other -> + other end) end @@ -362,6 +407,7 @@ defmodule Styler.Style.ModuleDirectives do # C.foo() # # lifting A.B.C would create a collision with C. + # unlike the collision_with_first tuple book-keeping, there's no recovery here because we won't lift a < 3 length alias {:skip, zipper, Map.put(lifts, first, :collision_with_first)} zipper, lifts -> diff --git a/test/style/module_directives/alias_lifting_test.exs b/test/style/module_directives/alias_lifting_test.exs index 454654d4..b08befe7 100644 --- a/test/style/module_directives/alias_lifting_test.exs +++ b/test/style/module_directives/alias_lifting_test.exs @@ -221,6 +221,109 @@ defmodule Styler.Style.ModuleDirectives.AliasLiftingTest do ) end + test "two modules that seem to conflict but don't!" do + assert_style( + """ + defmodule Foo do + @moduledoc false + + A.B.C.foo(X.Y.A) + A.B.C.bar() + + X.Y.A + end + """, + """ + defmodule Foo do + @moduledoc false + + alias A.B.C + alias X.Y.A + + C.foo(A) + C.bar() + + A + end + """ + ) + end + + test "if multiple lifts collide, lifts only one" do + assert_style( + """ + defmodule Foo do + @moduledoc false + + A.B.C.f() + A.B.C.f() + X.Y.C.f() + end + """, + """ + defmodule Foo do + @moduledoc false + + alias A.B.C + + C.f() + C.f() + X.Y.C.f() + end + """ + ) + + assert_style( + """ + defmodule Foo do + @moduledoc false + + A.B.C.f() + X.Y.C.f() + X.Y.C.f() + A.B.C.f() + end + """, + """ + defmodule Foo do + @moduledoc false + + alias A.B.C + + C.f() + X.Y.C.f() + X.Y.C.f() + C.f() + end + """ + ) + + assert_style( + """ + defmodule Foo do + @moduledoc false + + X.Y.C.f() + A.B.C.f() + X.Y.C.f() + A.B.C.f() + end + """, + """ + defmodule Foo do + @moduledoc false + + alias X.Y.C + + C.f() + A.B.C.f() + C.f() + A.B.C.f() + end + """ + ) + end + describe "comments stay put" do test "comments before alias stanza" do assert_style( @@ -306,44 +409,60 @@ defmodule Styler.Style.ModuleDirectives.AliasLiftingTest do end end - test "collisions with other lifts" do + test "collisions with submodules" do assert_style """ - defmodule NuhUh do + defmodule A do @moduledoc false A.B.C.f() + + defmodule C do + @moduledoc false + A.B.C.f() + end + A.B.C.f() - X.Y.C.f() end """ + end + test "collisions with 3-deep one-off" do assert_style """ - defmodule NuhUh do + defmodule Foo do @moduledoc false - A.B.C.f() - A.B.C.f() - X.Y.C.f() - X.Y.C.f() + X.Y.Z.foo(A.B.X) + + A.B.X end """ end - test "collisions with submodules" do - assert_style """ - defmodule A do - @moduledoc false + test "when new alias being sorted in would change an existing alias" do + assert_style( + """ + defmodule Foo do + @moduledoc false - A.B.C.f() + X.Y.Z.foo(A.B.X) + X.Y.Z.bar() - defmodule C do - @moduledoc false - A.B.C.f() + A.B.X end + """, + """ + defmodule Foo do + @moduledoc false - A.B.C.f() - end - """ + alias X.Y.Z + + Z.foo(A.B.X) + Z.bar() + + A.B.X + end + """ + ) end test "defprotocol, defmodule, or defimpl" do From 3750e0c8cd22b866f530bece05f592c444b548f6 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Fri, 24 Jan 2025 10:39:50 -0700 Subject: [PATCH 052/145] improve alias lift collision case --- lib/style/module_directives.ex | 49 ++++++++++++++++++++-------------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/lib/style/module_directives.ex b/lib/style/module_directives.ex index bafc0124..204ecd21 100644 --- a/lib/style/module_directives.ex +++ b/lib/style/module_directives.ex @@ -352,31 +352,40 @@ defmodule Styler.Style.ModuleDirectives do # We've seen this once before, time to mark it for lifting and do some bookkeeping for first-collisions lifts[last] == {aliases, false} -> - lifts - |> Map.put(last, {aliases, true}) - |> Map.update!(first, fn + lifts = Map.put(lifts, last, {aliases, true}) + + # Here's the bookkeeping for collisions with this alias's first module name... + case lifts[first] do {:collision_with_first, claimants, colliders} -> # release our claim on this collision claimants = MapSet.delete(claimants, aliases) - - if Enum.empty?(claimants) and Enum.any?(colliders) do - # no more claimants, try to promote a collider to be lifted - - colliders = Enum.to_list(colliders) - # There's no longer a collision because the only claimant is being lifted. - # So, promote a claimant with these criteria - # - required: its first comes _after_ last, so we aren't promoting an alias that changes the meaning of the other alias we're doing - # - preferred: take a collider we know we want to lift (we've seen it multiple times) - Enum.find(colliders, fn {[first | _], seen?} -> seen? and first > last end) || - Enum.find(colliders, fn {[first | _], _} -> first > last end) || - :collision_with_first - else - {:collision_with_first, claimants, colliders} + empty? = Enum.empty?(claimants) + + cond do + empty? and Enum.any?(colliders) -> + # no more claimants, try to promote a collider to be lifted + colliders = Enum.to_list(colliders) + # There's no longer a collision because the only claimant is being lifted. + # So, promote a claimant with these criteria + # - required: its first comes _after_ last, so we aren't promoting an alias that changes the meaning of the other alias we're doing + # - preferred: take a collider we know we want to lift (we've seen it multiple times) + lift = + Enum.find(colliders, fn {[first | _], seen?} -> seen? and first > last end) || + Enum.find(colliders, fn {[first | _], _} -> first > last end) || + :collision_with_first + + Map.put(lifts, first, lift) + + empty? -> + Map.delete(lifts, first) + + true -> + Map.put(lifts, first, {:collision_with_first, claimants, colliders}) end - other -> - other - end) + _ -> + lifts + end true -> lifts From fc71aee72bfa2a64122139ff0e472f35bc6c5d14 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Sat, 25 Jan 2025 08:54:54 -0700 Subject: [PATCH 053/145] remove vestigial with rewriting head --- lib/style/blocks.ex | 4 ---- 1 file changed, 4 deletions(-) diff --git a/lib/style/blocks.ex b/lib/style/blocks.ex index 3cb1ed47..ac8e7a51 100644 --- a/lib/style/blocks.ex +++ b/lib/style/blocks.ex @@ -75,10 +75,6 @@ defmodule Styler.Style.Blocks do {:cont, Zipper.replace(zipper, {:if, m, children}), ctx} end - def run({{:with, _, [[{{:__block__, _, [:do]}, body} | _]]}, _} = zipper, ctx) do - {:cont, Zipper.replace(zipper, body), 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) From 9404d5fc39309d7f0579ae51f2e8423ebabc828f Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Thu, 13 Feb 2025 10:33:19 -0700 Subject: [PATCH 054/145] pipes: handle pipifying functions whose first arg is itself a pipe. closes #193 --- CHANGELOG.md | 3 +++ lib/style/module_directives.ex | 13 ++++++++++--- lib/style/pipes.ex | 5 ++++- test/style/pipes_test.exs | 4 ++++ 4 files changed, 21 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09724539..45f7c69d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,9 @@ This release taught Styler to try just that little bit harder when doing alias l C.bar() C.baz() +### Fixes + +- `pipes`: handle pipifying when the first arg is itself a pipe: `c(a |> b, d)` => `a |> b() |> c(d)` (#213, h/t @kybishop) ## 1.3.3 diff --git a/lib/style/module_directives.ex b/lib/style/module_directives.ex index 204ecd21..b88f3c62 100644 --- a/lib/style/module_directives.ex +++ b/lib/style/module_directives.ex @@ -370,9 +370,16 @@ defmodule Styler.Style.ModuleDirectives do # - required: its first comes _after_ last, so we aren't promoting an alias that changes the meaning of the other alias we're doing # - preferred: take a collider we know we want to lift (we've seen it multiple times) lift = - Enum.find(colliders, fn {[first | _], seen?} -> seen? and first > last end) || - Enum.find(colliders, fn {[first | _], _} -> first > last end) || - :collision_with_first + Enum.reduce_while(colliders, :collision_with_first, fn + {[first | _], true} = liftable, _ when first > last -> + {:halt, liftable} + + {[first | _], _false} = promotable, :collision_with_first when first > last -> + {:cont, promotable} + + _, result -> + {:cont, result} + end) Map.put(lifts, first, lift) diff --git a/lib/style/pipes.ex b/lib/style/pipes.ex index cb269a7f..2b43338b 100644 --- a/lib/style/pipes.ex +++ b/lib/style/pipes.ex @@ -168,7 +168,10 @@ defmodule Styler.Style.Pipes do {:cont, zipper, ctx} true -> - {:cont, Zipper.replace(zipper, {:|>, m, [pipe, {f, m, args}]}), ctx} + # Recurse in case the function-looking is a multi pipe + zipper + |> Zipper.replace({:|>, m, [pipe, {f, m, args}]}) + |> run(ctx) end end diff --git a/test/style/pipes_test.exs b/test/style/pipes_test.exs index 3feae8ec..ae1dee90 100644 --- a/test/style/pipes_test.exs +++ b/test/style/pipes_test.exs @@ -934,6 +934,9 @@ defmodule Styler.Style.PipesTest do assert_style ~s<"\#{#{pipe}}"> end + test "pipifying pipes" do + end + test "when it's not actually the first argument!" do assert_style """ a @@ -944,6 +947,7 @@ defmodule Styler.Style.PipesTest do test "pipifying" do assert_style "d(a |> b |> c)", "a |> b() |> c() |> d()" + assert_style("c(a |> b, d)", "a |> b() |> c(d)") assert_style( """ From ff004cab02ed75fd76e225ac4acf991480695071 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Thu, 13 Feb 2025 10:35:54 -0700 Subject: [PATCH 055/145] =?UTF-8?q?cleanup=20the=20messes=20left=20in=20th?= =?UTF-8?q?e=20previous=20commit=20=F0=9F=99=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/style/pipes.ex | 2 +- test/style/pipes_test.exs | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/style/pipes.ex b/lib/style/pipes.ex index 2b43338b..51b4ca92 100644 --- a/lib/style/pipes.ex +++ b/lib/style/pipes.ex @@ -168,7 +168,7 @@ defmodule Styler.Style.Pipes do {:cont, zipper, ctx} true -> - # Recurse in case the function-looking is a multi pipe + # Recurse in case this is a multi pipe zipper |> Zipper.replace({:|>, m, [pipe, {f, m, args}]}) |> run(ctx) diff --git a/test/style/pipes_test.exs b/test/style/pipes_test.exs index ae1dee90..5a4332ec 100644 --- a/test/style/pipes_test.exs +++ b/test/style/pipes_test.exs @@ -934,9 +934,6 @@ defmodule Styler.Style.PipesTest do assert_style ~s<"\#{#{pipe}}"> end - test "pipifying pipes" do - end - test "when it's not actually the first argument!" do assert_style """ a From 5a23833ce5c0661323b815ef7eceb284450f6d54 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Thu, 13 Feb 2025 11:01:04 -0700 Subject: [PATCH 056/145] correct issue number in change log --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45f7c69d..d6acd664 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,7 @@ This release taught Styler to try just that little bit harder when doing alias l ### Fixes -- `pipes`: handle pipifying when the first arg is itself a pipe: `c(a |> b, d)` => `a |> b() |> c(d)` (#213, h/t @kybishop) +- `pipes`: handle pipifying when the first arg is itself a pipe: `c(a |> b, d)` => `a |> b() |> c(d)` (#214, h/t @kybishop) ## 1.3.3 From 297106ac8c25a2f199824331ee57cae80ea93959 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Thu, 20 Feb 2025 10:08:33 -0700 Subject: [PATCH 057/145] test against 1.18 --- .github/workflows/ci.yml | 2 +- .tool-versions | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a51e0fc2..9ab4bb2c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,7 @@ jobs: name: Ex${{matrix.elixir}}/OTP${{matrix.otp}} strategy: matrix: - elixir: ['1.15.8', '1.16.3', '1.17.3'] + elixir: ['1.15.8', '1.16.3', '1.17.3', '1.18.2'] otp: ['25.3.2'] steps: - uses: actions/checkout@v4 diff --git a/.tool-versions b/.tool-versions index 16f4970a..a6cd6f91 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,3 @@ -erlang 26.1.2 -elixir 1.16.0-otp-26 +elixir 1.18.2-otp-27 +erlang 27.2.3 +nodejs 16.11.1 From ee34edd3a2476c8884a67e93bd3c54c9dbbeddd0 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Thu, 20 Feb 2025 10:19:29 -0700 Subject: [PATCH 058/145] 1.18 warnings + formatting --- lib/style/deprecations.ex | 4 ++-- lib/style/pipes.ex | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/style/deprecations.ex b/lib/style/deprecations.ex index f810e5a3..e536cbff 100644 --- a/lib/style/deprecations.ex +++ b/lib/style/deprecations.ex @@ -98,7 +98,7 @@ defmodule Styler.Style.Deprecations do defp style(node), do: node - defp rewrite_range_match({:.., dm, [first, {_, m, _} = last]}), do: {:"..//", dm, [first, last, {:_, m, nil}]} + defp rewrite_range_match({:.., dm, [first, {_, m, _} = last]}), do: {:..//, dm, [first, last, {:_, m, nil}]} defp rewrite_range_match(x), do: x defp add_step_to_date_range?(first, last) do @@ -117,7 +117,7 @@ defmodule Styler.Style.Deprecations do {:ok, stop} <- extract_value_from_range(last), true <- start > stop do step = {:__block__, [token: "1", line: lm[:line]], [1]} - {:"..//", rm, [first, last, step]} + {:..//, rm, [first, last, step]} else _ -> range end diff --git a/lib/style/pipes.ex b/lib/style/pipes.ex index 51b4ca92..bffb5e1f 100644 --- a/lib/style/pipes.ex +++ b/lib/style/pipes.ex @@ -95,7 +95,7 @@ defmodule Styler.Style.Pipes do comments = ctx.comments - |> Style.displace_comments(lhs_line..(rhs_line - 1)) + |> Style.displace_comments(lhs_line..(rhs_line - 1)//1) |> Style.shift_comments(rhs_line..rhs_max_line, shift + 1) {:cont, Zipper.replace(single_pipe_zipper, {fun, meta, [lhs | args]}), %{ctx | comments: comments}} From 8d921d9ad33a23575aa3fd9cdd7027041406155e Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Thu, 20 Feb 2025 10:31:26 -0700 Subject: [PATCH 059/145] no one saw that right? --- .tool-versions | 1 - 1 file changed, 1 deletion(-) diff --git a/.tool-versions b/.tool-versions index a6cd6f91..0b6770c6 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,3 +1,2 @@ elixir 1.18.2-otp-27 erlang 27.2.3 -nodejs 16.11.1 From e083b4b9de5dc44c98e51e06494eec4bfb27b80a Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Thu, 20 Feb 2025 10:18:35 -0700 Subject: [PATCH 060/145] ex1.17+: replace `:timer.units(x)` with the new `to_timeout(unit: x)` for `hours|minutes|seconds` Closes #218 --- CHANGELOG.md | 6 +++++ lib/style/deprecations.ex | 7 +++++ test/style/deprecations_test.exs | 46 +++++++++++++++++++++----------- 3 files changed, 43 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6acd664..351a1afb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ they can and will change without that change being reflected in Styler's semanti ### Improvements +#### Ex1.17+ + +Replace `:timer.units(x)` with the new `to_timeout(unit: x)` for `hours|minutes|seconds` + +#### Alias Lifting + This release taught Styler to try just that little bit harder when doing alias lifting. - general improvements around conflict detection, lifting in more correct places and fewer incorrect places (#193, h/t @jsw800) diff --git a/lib/style/deprecations.ex b/lib/style/deprecations.ex index e536cbff..4edeaa2b 100644 --- a/lib/style/deprecations.ex +++ b/lib/style/deprecations.ex @@ -58,6 +58,13 @@ defmodule Styler.Style.Deprecations do do: {:|>, m, [lhs, {f, fm, [lob, opts]}]} end + if Version.match?(System.version(), ">= 1.17.0-dev") do + for {erl, ex} <- [hours: :hour, minutes: :minute, seconds: :second] do + defp style({{:., _, [{:__block__, _, [:timer]}, unquote(erl)]}, fm, [x]}), + do: {:to_timeout, fm, [[{{:__block__, [format: :keyword, line: fm[:line]], [unquote(ex)]}, x}]]} + end + end + # For ranges where `start > stop`, you need to explicitly include the step # Enum.slice(enumerable, 1..-2) => Enum.slice(enumerable, 1..-2//1) # String.slice("elixir", 2..-1) => String.slice("elixir", 2..-1//1) diff --git a/test/style/deprecations_test.exs b/test/style/deprecations_test.exs index cb9396fd..8b5211b3 100644 --- a/test/style/deprecations_test.exs +++ b/test/style/deprecations_test.exs @@ -67,22 +67,6 @@ defmodule Styler.Style.DeprecationsTest do assert_style "foo |> List.zip", "Enum.zip(foo)" end - describe "1.16 deprecations" do - @describetag skip: Version.match?(System.version(), "< 1.16.0-dev") - - test "File.stream!(path, modes, line_or_bytes) to File.stream!(path, line_or_bytes, modes)" do - assert_style( - "File.stream!(path, [encoding: :utf8, trim_bom: true], :line)", - "File.stream!(path, :line, encoding: :utf8, trim_bom: true)" - ) - - assert_style( - "f |> File.stream!([encoding: :utf8, trim_bom: true], :line) |> Enum.take(2)", - "f |> File.stream!(:line, encoding: :utf8, trim_bom: true) |> Enum.take(2)" - ) - end - end - test "~R is deprecated in favor of ~r" do assert_style(~s|Regex.match?(~R/foo/, "foo")|, ~s|Regex.match?(~r/foo/, "foo")|) end @@ -131,4 +115,34 @@ defmodule Styler.Style.DeprecationsTest do assert_style("foo |> bar() |> #{mod}.slice(x..y)") end end + + describe "1.16+" do + @describetag skip: Version.match?(System.version(), "< 1.16.0-dev") + + test "File.stream!(path, modes, line_or_bytes) to File.stream!(path, line_or_bytes, modes)" do + assert_style( + "File.stream!(path, [encoding: :utf8, trim_bom: true], :line)", + "File.stream!(path, :line, encoding: :utf8, trim_bom: true)" + ) + + assert_style( + "f |> File.stream!([encoding: :utf8, trim_bom: true], :line) |> Enum.take(2)", + "f |> File.stream!(:line, encoding: :utf8, trim_bom: true) |> Enum.take(2)" + ) + end + end + + describe "1.17+" do + @describetag skip: Version.match?(System.version(), "< 1.17.0-dev") + + test "to_timeout/1 vs :timer.units(x)" do + assert_style ":timer.hours(x)", "to_timeout(hour: x)" + assert_style ":timer.minutes(x)", "to_timeout(minute: x)" + assert_style ":timer.seconds(x)", "to_timeout(second: x)" + + assert_style "a |> x() |> :timer.hours()" + assert_style "a |> x() |> :timer.minutes()" + assert_style "a |> x() |> :timer.seconds()" + end + end end From d0ecf1d1219cd1fca6144d1c5afbbe43e7df9919 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Thu, 20 Feb 2025 10:53:23 -0700 Subject: [PATCH 061/145] 1.18+: change struct updates to map updates. Closes #199 --- CHANGELOG.md | 13 +++++++++++++ lib/style/deprecations.ex | 5 +++++ test/style/deprecations_test.exs | 8 ++++++++ test/style/pipes_test.exs | 2 +- 4 files changed, 27 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 351a1afb..9ebaff73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,19 @@ they can and will change without that change being reflected in Styler's semanti Replace `:timer.units(x)` with the new `to_timeout(unit: x)` for `hours|minutes|seconds` +#### Ex1.18+ + +Delete deprecated struct update syntax in favor of map update syntax. + +```elixir +# This +%Struct{x | y} +# Styles to this +%{x | y} +``` + +**WARNING** Double check your diffs to make sure your variable is pattern matching against the same struct if you want to harness 1.18's type checking features. (#199, h/t @SteffenDE) + #### Alias Lifting This release taught Styler to try just that little bit harder when doing alias lifting. diff --git a/lib/style/deprecations.ex b/lib/style/deprecations.ex index 4edeaa2b..93baee08 100644 --- a/lib/style/deprecations.ex +++ b/lib/style/deprecations.ex @@ -65,6 +65,11 @@ defmodule Styler.Style.Deprecations do end end + if Version.match?(System.version(), ">= 1.18.0-dev") do + # Struct update syntax was deprecated `%Foo{x | y} => %{x | y}` + defp style({:%, _, [_struct, {:%{}, _, [{:|, _, _}]} = update]}), do: update + end + # For ranges where `start > stop`, you need to explicitly include the step # Enum.slice(enumerable, 1..-2) => Enum.slice(enumerable, 1..-2//1) # String.slice("elixir", 2..-1) => String.slice("elixir", 2..-1//1) diff --git a/test/style/deprecations_test.exs b/test/style/deprecations_test.exs index 8b5211b3..140a87c4 100644 --- a/test/style/deprecations_test.exs +++ b/test/style/deprecations_test.exs @@ -145,4 +145,12 @@ defmodule Styler.Style.DeprecationsTest do assert_style "a |> x() |> :timer.seconds()" end end + + describe "1.18+" do + @describetag skip: Version.match?(System.version(), "< 1.18.0-dev") + + test "struct update" do + assert_style "%Foo{widget | bar: :baz}", "%{widget | bar: :baz}" + end + end end diff --git a/test/style/pipes_test.exs b/test/style/pipes_test.exs index 5a4332ec..c3858c71 100644 --- a/test/style/pipes_test.exs +++ b/test/style/pipes_test.exs @@ -408,7 +408,7 @@ defmodule Styler.Style.PipesTest do """, """ def halt(exec, halt_message) do - put_halt_message(%__MODULE__{exec | halted: true}, halt_message) + put_halt_message(%{exec | halted: true}, halt_message) end """ ) From 74d6fd25af0adc2830d1210253898db6a20cb21f Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Thu, 20 Feb 2025 11:34:16 -0700 Subject: [PATCH 062/145] ensure test works across versions --- test/style/pipes_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/style/pipes_test.exs b/test/style/pipes_test.exs index c3858c71..f87b3a36 100644 --- a/test/style/pipes_test.exs +++ b/test/style/pipes_test.exs @@ -402,7 +402,7 @@ defmodule Styler.Style.PipesTest do assert_style( """ def halt(exec, halt_message) do - %__MODULE__{exec | halted: true} + %{exec | halted: true} |> put_halt_message(halt_message) end """, From fc6fb5d4cb4b9d9b1cd139aee9cad0f628060fc1 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Thu, 20 Feb 2025 12:06:17 -0700 Subject: [PATCH 063/145] fix `with` rewrites when keyword with an else stab (#220) Closes #219 fixes for both <1.17 and >=1.17 --- CHANGELOG.md | 4 +++- lib/style/blocks.ex | 25 +++++++++++++++++++++++-- test/style/blocks_test.exs | 16 +++++++++++++++- 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ebaff73..7468937b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,8 @@ they can and will change without that change being reflected in Styler's semanti #### Ex1.17+ -Replace `:timer.units(x)` with the new `to_timeout(unit: x)` for `hours|minutes|seconds` +- Replace `:timer.units(x)` with the new `to_timeout(unit: x)` for `hours|minutes|seconds` +- Handle `, else: (_ -> x)` bugs introduced by `(_ -> x)` being termed a literal (#219, h/t @iamhassangm) #### Ex1.18+ @@ -48,6 +49,7 @@ This release taught Styler to try just that little bit harder when doing alias l ### Fixes - `pipes`: handle pipifying when the first arg is itself a pipe: `c(a |> b, d)` => `a |> b() |> c(d)` (#214, h/t @kybishop) +- `with`: correctly handle a stabby `with` `, else: (_ -> :ok)` being rewritten to a case (#219, h/t @iamhassangm) ## 1.3.3 diff --git a/lib/style/blocks.ex b/lib/style/blocks.ex index ac8e7a51..fd51be13 100644 --- a/lib/style/blocks.ex +++ b/lib/style/blocks.ex @@ -53,10 +53,31 @@ defmodule Styler.Style.Blocks do # 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_meta, [:else]}, elses} = elses + {{:__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, m, [single_statement, clauses]}, zm}, ctx) + run({{:case, case_meta, [single_statement, clauses]}, zm}, ctx) end # `with true <- x, do: bar` =>`if x, do: bar` diff --git a/test/style/blocks_test.exs b/test/style/blocks_test.exs index 83cbab40..cf2d1644 100644 --- a/test/style/blocks_test.exs +++ b/test/style/blocks_test.exs @@ -239,7 +239,7 @@ defmodule Styler.Style.BlocksTest do end end - describe "with statements" do + describe "with" do test "replacement due to no (or all removed) arrows" do assert_style( """ @@ -787,6 +787,20 @@ defmodule Styler.Style.BlocksTest do end """ end + + test "elixir1.17+ stab regressions" do + assert_style( + """ + with :ok <- foo, do: :bar, else: (_ -> :baz) + """, + """ + case foo do + :ok -> :bar + _ -> :baz + end + """ + ) + end end test "Credo.Check.Refactor.CondStatements" do From a46c43f3739f1796958c2054eb49879726e4de9b Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Thu, 20 Feb 2025 12:21:33 -0700 Subject: [PATCH 064/145] pipify nested function calls with pipe as the first argument. closes #216 --- CHANGELOG.md | 1 + lib/style/pipes.ex | 11 ++++++----- test/style/pipes_test.exs | 5 ++--- test/support/style_case.ex | 1 + 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7468937b..eb872701 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ This release taught Styler to try just that little bit harder when doing alias l ### Fixes - `pipes`: handle pipifying when the first arg is itself a pipe: `c(a |> b, d)` => `a |> b() |> c(d)` (#214, h/t @kybishop) +- `pipes`: handle pipifying nested functions `d(c(a |> b))` => `a |> b |> c() |> d` (#216, h/t @emkguts) - `with`: correctly handle a stabby `with` `, else: (_ -> :ok)` being rewritten to a case (#219, h/t @iamhassangm) ## 1.3.3 diff --git a/lib/style/pipes.ex b/lib/style/pipes.ex index bffb5e1f..dc56ad27 100644 --- a/lib/style/pipes.ex +++ b/lib/style/pipes.ex @@ -132,7 +132,7 @@ defmodule Styler.Style.Pipes do # a(b |> c[, ...args]) # The first argument to a function-looking node is a pipe. - # Maybe pipe the whole thing? + # Maybe pipify the whole thing? def run({{f, m, [{:|>, _, _} = pipe | args]}, _} = zipper, ctx) do parent = case Zipper.up(zipper) do @@ -168,10 +168,11 @@ defmodule Styler.Style.Pipes do {:cont, zipper, ctx} true -> - # Recurse in case this is a multi pipe - zipper - |> Zipper.replace({:|>, m, [pipe, {f, m, args}]}) - |> run(ctx) + zipper = Zipper.replace(zipper, {:|>, m, [pipe, {f, m, args}]}) + # it's possible this is a nested function call `c(b(a |> b))`, so we should walk up the tree for de-nesting + zipper = Zipper.up(zipper) || zipper + # recursion ensures we get those nested function calls and any additional pipes + run(zipper, ctx) end end diff --git a/test/style/pipes_test.exs b/test/style/pipes_test.exs index f87b3a36..c5617952 100644 --- a/test/style/pipes_test.exs +++ b/test/style/pipes_test.exs @@ -943,9 +943,8 @@ defmodule Styler.Style.PipesTest do end test "pipifying" do - assert_style "d(a |> b |> c)", "a |> b() |> c() |> d()" - assert_style("c(a |> b, d)", "a |> b() |> c(d)") - + assert_style("e(d(a |> b |> c), f)", "a |> b() |> c() |> d() |> e(f)") + assert_style( """ # d diff --git a/test/support/style_case.ex b/test/support/style_case.ex index a7425772..66e34c65 100644 --- a/test/support/style_case.ex +++ b/test/support/style_case.ex @@ -82,6 +82,7 @@ defmodule Styler.StyleCase do _ -> false end + # This isn't enabled in any test, but can be a useful audit if @ordered_siblings do case Zipper.left(zipper) do {{_, prev_meta, _} = prev, _} -> From ceb827abc249c347c05d7132fa5e29443b37c279 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Thu, 20 Feb 2025 12:43:48 -0700 Subject: [PATCH 065/145] change struct update deprecation to ex1.19+ --- CHANGELOG.md | 43 ++++++++++++++++++++------------ lib/style/deprecations.ex | 2 +- test/style/deprecations_test.exs | 4 +-- 3 files changed, 30 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb872701..86534753 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,25 +5,16 @@ they can and will change without that change being reflected in Styler's semanti ## main -### Improvements - -#### Ex1.17+ - -- Replace `:timer.units(x)` with the new `to_timeout(unit: x)` for `hours|minutes|seconds` -- Handle `, else: (_ -> x)` bugs introduced by `(_ -> x)` being termed a literal (#219, h/t @iamhassangm) +## 1.4 -#### Ex1.18+ +- A very nice change in alias lifting means Styler will make sure that your code is _using_ the aliases that it's specified. +- Shoutout to the smartrent folks for finding pipifying recursion issues +- Elixir 1.17 improvements and fixes +- Elixir 1.19-dev: delete struct updates -Delete deprecated struct update syntax in favor of map update syntax. +Read on for details. -```elixir -# This -%Struct{x | y} -# Styles to this -%{x | y} -``` - -**WARNING** Double check your diffs to make sure your variable is pattern matching against the same struct if you want to harness 1.18's type checking features. (#199, h/t @SteffenDE) +### Improvements #### Alias Lifting @@ -46,6 +37,26 @@ This release taught Styler to try just that little bit harder when doing alias l C.bar() C.baz() +#### Ex1.17+ + +- Replace `:timer.units(x)` with the new `to_timeout(unit: x)` for `hours|minutes|seconds` +- Handle `, else: (_ -> x)` bugs introduced by `(_ -> x)` being termed a literal (#219, h/t @iamhassangm) + +#### Ex1.19+ (experimental) + +1.19 deprecates struct update syntax in favor of map update syntax. Styler will do this update for you if you're on Elixir 1.19.0-dev or later. + +```elixir +# This +%Struct{x | y} +# Styles to this +%{x | y} +``` + +**WARNING** Double check your diffs to make sure your variable is pattern matching against the same struct if you want to harness 1.18's type checking features. + +A future version of Styler may be smart enough to do this check for you and perform the appropriate updates to the assignment location; no guarantees though. Track via #199, h/t @SteffenDE + ### Fixes - `pipes`: handle pipifying when the first arg is itself a pipe: `c(a |> b, d)` => `a |> b() |> c(d)` (#214, h/t @kybishop) diff --git a/lib/style/deprecations.ex b/lib/style/deprecations.ex index 93baee08..2cbdf115 100644 --- a/lib/style/deprecations.ex +++ b/lib/style/deprecations.ex @@ -65,7 +65,7 @@ defmodule Styler.Style.Deprecations do end end - if Version.match?(System.version(), ">= 1.18.0-dev") do + if Version.match?(System.version(), ">= 1.19.0-dev") do # Struct update syntax was deprecated `%Foo{x | y} => %{x | y}` defp style({:%, _, [_struct, {:%{}, _, [{:|, _, _}]} = update]}), do: update end diff --git a/test/style/deprecations_test.exs b/test/style/deprecations_test.exs index 140a87c4..041110d5 100644 --- a/test/style/deprecations_test.exs +++ b/test/style/deprecations_test.exs @@ -146,8 +146,8 @@ defmodule Styler.Style.DeprecationsTest do end end - describe "1.18+" do - @describetag skip: Version.match?(System.version(), "< 1.18.0-dev") + describe "1.19+" do + @describetag skip: Version.match?(System.version(), "< 1.19.0-dev") test "struct update" do assert_style "%Foo{widget | bar: :baz}", "%{widget | bar: :baz}" From d015a990bb9b0018f3c5ef64752ea9e825a1f693 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Sat, 25 Jan 2025 08:52:53 -0700 Subject: [PATCH 066/145] docs docs docs docs docs! --- README.md | 79 ++++------------------------- docs/comment_directives.md | 101 +++++++++++++++++++++++++++++++++++++ docs/deprecations.md | 23 +++++++++ docs/module_directives.md | 14 +++++ mix.exs | 1 + 5 files changed, 149 insertions(+), 69 deletions(-) create mode 100644 docs/comment_directives.md diff --git a/README.md b/README.md index 6042da5c..647f73b3 100644 --- a/README.md +++ b/README.md @@ -11,77 +11,18 @@ You can learn more about the history, purpose and implementation of Styler from ## Features -### AST Rewrites as part of `mix format` +Styler fixes a plethora of elixir style and optimization issues automatically as part of mix format. -[See our Rewrites documentation on hexdocs](https://hexdocs.pm/styler/styles.html) -Styler fixes a plethora of elixir style and optimization issues automatically as part of `mix format`. In addition to automating corrections for [many credo rules](docs/credo.md) (meaning you can turn them off to speed credo up), Styler: +[See Styler's documentation on Hex](https://hexdocs.pm/styler/styles.html) for the comprehensive list of its features. -- [keeps a strict module layout](docs/module_directives.md#directive-organization) - - alphabetizes module directives -- [extracts repeated aliases](docs/module_directives.md#alias-lifting) -- [makes your pipe chains pretty as can be](docs/pipes.md) - - pipes and unpipes function calls based on the number of calls - - optimizes standard library calls (`a |> Enum.map(m) |> Enum.into(Map.new)` => `Map.new(a, m)`) -- replaces strings with sigils when the string has many escaped quotes -- ... and so much more +The fastest way to see what all it can do you for you is to just try it out in your codebase... but here's a list of a few features to help you decide if you're interested in Styler: -### Maintain static list order via `# styler:sort` - -Styler can keep static values sorted for your team as part of its formatting pass. To instruct it to do so, replace any `# Please keep this list sorted!` notes you wrote to your teammates with `# styler:sort`. - -#### Examples - -```elixir -# styler:sort -[:c, :a, :b] - -# styler:sort -~w(a list of words) - -# styler:sort -@country_codes ~w( - en_US - po_PO - fr_CA - ja_JP -) - -# styler:sort -a_var = - [ - Modules, - In, - A, - List - ] -``` - -Would yield: - -```elixir -# styler:sort -[:a, :b, :c] - -# styler:sort -~w(a list of words) - -# styler:sort -@country_codes ~w( - en_US - fr_CA - ja_JP - po_PO -) - -# styler:sort -a_var = - [ - A, - In, - List, - Modules - ] -``` +- sorts and organizes `import`/`alias`/`require` and other [module directives](docs/module_directives.md) +- keeps lists, sigils, and even arbitrary code sorted with the `# styler:sort` [comment directive](docs/comment_directives.md) +- automatically creates aliases for repeatedly referenced modules names ([_"alias lifting"_](docs/module_directives.md#alias-lifting)) +- optimizes pipe chains for [readability and performance](docs/pipes.md) +- rewrites strings as sigils when it results in fewer escapes +- auto-fixes [many credo rules](docs/credo.md), meaning you can spend less time fighting with CI ## Who is Styler for? @@ -101,7 +42,7 @@ Add `:styler` as a dependency to your project's `mix.exs`: ```elixir def deps do [ - {:styler, "~> 1.2", only: [:dev, :test], runtime: false}, + {:styler, "~> 1.4", only: [:dev, :test], runtime: false}, ] end ``` diff --git a/docs/comment_directives.md b/docs/comment_directives.md new file mode 100644 index 00000000..41dc0a63 --- /dev/null +++ b/docs/comment_directives.md @@ -0,0 +1,101 @@ +## Comment Directives + +Comment Directives are a Styler feature that let you instruct Styler to do maintain additional formatting via comments. + +The plural in the name is optimistic as there's currently only one, but who knows + +### `# styler:sort` + +Styler can keep static values sorted for your team as part of its formatting pass. To instruct it to do so, replace any `# Please keep this list sorted!` notes you wrote to your teammates with `# styler:sort` + +Sorting is done via string comparison of the code. + +Styler knows how to sort the following things: + +- lists of elements +- arbitrary code within `do end` blocks (helpful for schema-like macros) +- `~w` sigils elements +- keyword shapes (structs, maps, and keywords) + +Since you can't have comments in arbitrary places when using Elixir's formatter, +Styler will apply those sorts when they're on the righthand side fo the following operators: + +- module directives (eg `@my_dir ~w(a list of things)`) +- assignments (eg `x = ~w(a list again)`) +- `defstruct` + +#### Examples + +**Before** + +```elixir +# styler:sort +[:c, :a, :b] + +# styler:sort +~w(a list of words) + +# styler:sort +@country_codes ~w( + en_US + po_PO + fr_CA + ja_JP +) + +# styler:sort +a_var = + [ + Modules, + In, + A, + List + ] + +# styler:sort +my_macro "some arg" do + another_macro :q + another_macro :w + another_macro :e + another_macro :r + another_macro :t + another_macro :y +end +``` + +**After** + +```elixir +# styler:sort +[:a, :b, :c] + +# styler:sort +~w(a list of words) + +# styler:sort +@country_codes ~w( + en_US + fr_CA + ja_JP + po_PO +) + +# styler:sort +a_var = + [ + A, + In, + List, + Modules + ] + +# styler:sort +my_macro "some arg" do + another_macro :e + another_macro :q + another_macro :r + another_macro :t + another_macro :w + another_macro :y +end +``` diff --git a/docs/deprecations.md b/docs/deprecations.md index bbc7c190..976b9327 100644 --- a/docs/deprecations.md +++ b/docs/deprecations.md @@ -25,10 +25,33 @@ This is covered by the Elixir Formatter with the `--migrate` flag, but Styler br Rewrite `unless x` to `if !x` +### 1.19 + +#### Change Struct Updates to Map Updates (Experimental) + +1.19 deprecates struct update syntax in favor of map update syntax. Styler will do this update for you if you're on Elixir 1.19.0-dev or later. + +```elixir +# This +%Struct{x | y} +# Styles to this +%{x | y} +``` + +**WARNING** Double check your diffs to make sure your variable is pattern matching against the same struct if you want to harness 1.18's type checking features. + +A future version of Styler may be smart enough to do this check for you and perform the appropriate updates to the assignment location; no guarantees though. Track via #199, h/t @SteffenDE + +### 1.18 + +None? + ### 1.17 [1.17 Deprecations](https://hexdocs.pm/elixir/1.17.0/changelog.html#4-hard-deprecations) +- Replace `:timer.units(x)` with the new `to_timeout(unit: x)` for `hours|minutes|seconds` + #### Range Matching Without Step ```elixir diff --git a/docs/module_directives.md b/docs/module_directives.md index ff3cc8cb..4bfcd590 100644 --- a/docs/module_directives.md +++ b/docs/module_directives.md @@ -162,6 +162,20 @@ C.foo() C.bar() ``` +Styler also notices when you have a module aliased and aren't employing that alias and will do the updates for you. + +```elixir +# Given +alias My.Apps.Widget + +x = Repo.get(My.Apps.Widget, id) + +# Styled +alias My.Apps.Widget + +x = Repo.get(Widget, id) +``` + ### Collisions Styler won't lift aliases that will collide with existing aliases, and likewise won't lift any module whose name would collide with a standard library name. diff --git a/mix.exs b/mix.exs index 8d3df8e5..c8426a74 100644 --- a/mix.exs +++ b/mix.exs @@ -70,6 +70,7 @@ defmodule Styler.MixProject do "docs/control_flow_macros.md": [title: "Control Flow Macros (if, case, ...)"], "docs/mix_configs.md": [title: "Mix Configs (config/*.exs)"], "docs/module_directives.md": [title: "Module Directives (use, alias, ...)"], + "docs/comment_directives.md": [title: "Comment Directives (# styler:sort)"], "docs/credo.md": [title: "Styler & Credo"], "README.md": [title: "Styler"] ] From 6896d97a1820fe66d9d6264d5376f739bb16206c Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Thu, 20 Feb 2025 13:28:08 -0700 Subject: [PATCH 067/145] ship struct update to map update changes after all --- CHANGELOG.md | 17 +++++++---------- docs/deprecations.md | 10 +++------- lib/style/deprecations.ex | 7 +++---- test/style/deprecations_test.exs | 12 ++++-------- 4 files changed, 17 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86534753..59b3a788 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,14 +37,9 @@ This release taught Styler to try just that little bit harder when doing alias l C.bar() C.baz() -#### Ex1.17+ - -- Replace `:timer.units(x)` with the new `to_timeout(unit: x)` for `hours|minutes|seconds` -- Handle `, else: (_ -> x)` bugs introduced by `(_ -> x)` being termed a literal (#219, h/t @iamhassangm) - -#### Ex1.19+ (experimental) +#### Struct Updates => Map Updates -1.19 deprecates struct update syntax in favor of map update syntax. Styler will do this update for you if you're on Elixir 1.19.0-dev or later. +1.19 deprecates struct update syntax in favor of map update syntax. ```elixir # This @@ -53,15 +48,17 @@ This release taught Styler to try just that little bit harder when doing alias l %{x | y} ``` -**WARNING** Double check your diffs to make sure your variable is pattern matching against the same struct if you want to harness 1.18's type checking features. +**WARNING** Double check your diffs to make sure your variable is pattern matching against the same struct if you want to harness 1.19's type checking features. Apologies to folks who hoped Styler would do this step for you <3 (#199, h/t @SteffenDE) + +#### Ex1.17+ -A future version of Styler may be smart enough to do this check for you and perform the appropriate updates to the assignment location; no guarantees though. Track via #199, h/t @SteffenDE +- Replace `:timer.units(x)` with the new `to_timeout(unit: x)` for `hours|minutes|seconds` (This style is only applied if you're on 1.17+) ### Fixes - `pipes`: handle pipifying when the first arg is itself a pipe: `c(a |> b, d)` => `a |> b() |> c(d)` (#214, h/t @kybishop) - `pipes`: handle pipifying nested functions `d(c(a |> b))` => `a |> b |> c() |> d` (#216, h/t @emkguts) -- `with`: correctly handle a stabby `with` `, else: (_ -> :ok)` being rewritten to a case (#219, h/t @iamhassangm) +- `with`: fix a stabby `with` `, else: (_ -> :ok)` being rewritten to a case (#219, h/t @iamhassangm) ## 1.3.3 diff --git a/docs/deprecations.md b/docs/deprecations.md index 976b9327..45bdbe24 100644 --- a/docs/deprecations.md +++ b/docs/deprecations.md @@ -25,11 +25,9 @@ This is covered by the Elixir Formatter with the `--migrate` flag, but Styler br Rewrite `unless x` to `if !x` -### 1.19 +### Change Struct Updates to Map Updates -#### Change Struct Updates to Map Updates (Experimental) - -1.19 deprecates struct update syntax in favor of map update syntax. Styler will do this update for you if you're on Elixir 1.19.0-dev or later. +1.19 deprecates struct update syntax in favor of map update syntax. ```elixir # This @@ -38,9 +36,7 @@ Rewrite `unless x` to `if !x` %{x | y} ``` -**WARNING** Double check your diffs to make sure your variable is pattern matching against the same struct if you want to harness 1.18's type checking features. - -A future version of Styler may be smart enough to do this check for you and perform the appropriate updates to the assignment location; no guarantees though. Track via #199, h/t @SteffenDE +**WARNING** Double check your diffs to make sure your variable is pattern matching against the same struct if you want to harness 1.19's type checking features. ### 1.18 diff --git a/lib/style/deprecations.ex b/lib/style/deprecations.ex index 2cbdf115..824a88e6 100644 --- a/lib/style/deprecations.ex +++ b/lib/style/deprecations.ex @@ -65,10 +65,9 @@ defmodule Styler.Style.Deprecations do end end - if Version.match?(System.version(), ">= 1.19.0-dev") do - # Struct update syntax was deprecated `%Foo{x | y} => %{x | y}` - defp style({:%, _, [_struct, {:%{}, _, [{:|, _, _}]} = update]}), do: update - end + # Struct update syntax is deprecated in 1.19 + # `%Foo{x | y} => %{x | y}` + defp style({:%, _, [_struct, {:%{}, _, [{:|, _, _}]} = update]}), do: update # For ranges where `start > stop`, you need to explicitly include the step # Enum.slice(enumerable, 1..-2) => Enum.slice(enumerable, 1..-2//1) diff --git a/test/style/deprecations_test.exs b/test/style/deprecations_test.exs index 041110d5..d0633f91 100644 --- a/test/style/deprecations_test.exs +++ b/test/style/deprecations_test.exs @@ -116,6 +116,10 @@ defmodule Styler.Style.DeprecationsTest do end end + test "struct update, deprecated in 1.19" do + assert_style "%Foo{widget | bar: :baz}", "%{widget | bar: :baz}" + end + describe "1.16+" do @describetag skip: Version.match?(System.version(), "< 1.16.0-dev") @@ -145,12 +149,4 @@ defmodule Styler.Style.DeprecationsTest do assert_style "a |> x() |> :timer.seconds()" end end - - describe "1.19+" do - @describetag skip: Version.match?(System.version(), "< 1.19.0-dev") - - test "struct update" do - assert_style "%Foo{widget | bar: :baz}", "%{widget | bar: :baz}" - end - end end From aaedd0c9e8c11c55b0ee83e31a2eaab97ae94923 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Thu, 20 Feb 2025 13:32:46 -0700 Subject: [PATCH 068/145] v1.4.0 --- mix.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mix.exs b/mix.exs index c8426a74..344cd357 100644 --- a/mix.exs +++ b/mix.exs @@ -12,7 +12,7 @@ defmodule Styler.MixProject do use Mix.Project # Don't forget to bump the README when doing non-patch version changes - @version "1.3.3" + @version "1.4.0" @url "https://github.com/adobe/elixir-styler" def project do From 3d5a485846b2c5497c2133e4b35ea8b7222945dd Mon Sep 17 00:00:00 2001 From: Fabian Becker Date: Fri, 21 Feb 2025 09:50:28 +0100 Subject: [PATCH 069/145] Add OTP26/27 but only run for 1.17/1.18 --- .github/workflows/ci.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ab4bb2c..a2391e35 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,8 +9,13 @@ jobs: name: Ex${{matrix.elixir}}/OTP${{matrix.otp}} strategy: matrix: - elixir: ['1.15.8', '1.16.3', '1.17.3', '1.18.2'] - otp: ['25.3.2'] + elixir: ["1.15.8", "1.16.3", "1.17.3", "1.18.2"] + otp: ["25.3.2", "26.2.5", "27.2.4"] + exclude: + - elixir: "1.15.8" + otp: "27.2.4" + - elixir: "1.16.3" + otp: "27.2.4" steps: - uses: actions/checkout@v4 - uses: erlef/setup-beam@v1 From 941d06728b5c2ffab19e05ee84b52a47294cf397 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Fri, 21 Feb 2025 08:15:12 -0700 Subject: [PATCH 070/145] fix `with` redundant body + non-arrow behind redundant clause. Closes #221 --- lib/style/blocks.ex | 46 ++++++++++++++++++++------------------ test/style/blocks_test.exs | 2 ++ test/style/pipes_test.exs | 2 +- 3 files changed, 27 insertions(+), 23 deletions(-) diff --git a/lib/style/blocks.ex b/lib/style/blocks.ex index fd51be13..7a74378c 100644 --- a/lib/style/blocks.ex +++ b/lib/style/blocks.ex @@ -187,35 +187,37 @@ defmodule Styler.Style.Blocks do # drop singleton identity else clauses like `else foo -> foo end` elses = - case elses do - [{{_, _, [:else]}, [{:->, _, [[left], right]}]}] -> if nodes_equivalent?(left, right), do: [], else: elses - _ -> 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} = - cond do - # Put the postroll into the body - Enum.any?(postroll) -> - {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} - - # Credo.Check.Refactor.RedundantWithClauseResult - Enum.empty?(elses) and nodes_equivalent?(lhs, do_body) -> - {rest, rhs} - - # no change - true -> - {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 - do_line = do_meta[:line] final_clause_line = final_clause_meta[:line] do_line = cond do - do_meta[:format] == :keyword && final_clause_line + 1 >= do_line -> do_line + 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 @@ -243,12 +245,12 @@ defmodule Styler.Style.Blocks do |> Zipper.prepend_siblings(preroll) |> run(ctx) - # the # of `<-` canged, so we should have another look at this with statement + # the # of `<-` changed, so we should have another look at this with statement Enum.any?(postroll) -> run(zipper, ctx) true -> - # of clauess didn't change, so don't reecurse or we'll loop FOREEEVEERR + # of clauses didn't change, so don't reecurse or we'll loop FOREEEVEERR {:cont, zipper, ctx} end end diff --git a/test/style/blocks_test.exs b/test/style/blocks_test.exs index cf2d1644..e8f4626e 100644 --- a/test/style/blocks_test.exs +++ b/test/style/blocks_test.exs @@ -727,6 +727,7 @@ defmodule Styler.Style.BlocksTest do assert_style( """ with {:ok, a} <- foo(), + x = y, {:ok, b} <- bar(a) do {:ok, b} else @@ -735,6 +736,7 @@ defmodule Styler.Style.BlocksTest do """, """ with {:ok, a} <- foo() do + x = y bar(a) end """ diff --git a/test/style/pipes_test.exs b/test/style/pipes_test.exs index c5617952..50284537 100644 --- a/test/style/pipes_test.exs +++ b/test/style/pipes_test.exs @@ -944,7 +944,7 @@ defmodule Styler.Style.PipesTest do test "pipifying" do assert_style("e(d(a |> b |> c), f)", "a |> b() |> c() |> d() |> e(f)") - + assert_style( """ # d From 9990eb6682cb5100f73b9ede848ff69d6a6a6c5d Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Mon, 24 Feb 2025 13:32:43 -0700 Subject: [PATCH 071/145] link to quokka --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 647f73b3..78a1ed84 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,9 @@ Styler's only current configuration option is `:alias_lifting_exclude`, which ac #### No Credo-Style Enable/Disable -Styler [will not add configuration](https://github.com/adobe/elixir-styler/pull/127#issuecomment-1912242143) for ad-hoc enabling/disabling of rewrites. Sorry! Its implementation simply does not support that approach. There are however many forks out there that have attempted this; please explore the [Github forks tab](https://github.com/adobe/elixir-styler/forks) to see if there's a project that suits your needs or that you can draw inspiration from. +Styler [will not add configuration](https://github.com/adobe/elixir-styler/pull/127#issuecomment-1912242143) for ad-hoc enabling/disabling of rewrites. Sorry! + +However, Smartrent has a fork of Styler named [Quokka](https://github.com/smartrent/quokka) that allows for finegrained control of Styler. Maybe it's what you're looking for. If not, you can always fork it or Styler as a starting point for your own tool! Ultimately Styler is @adobe's internal tool that we're happy to share with the world. We're delighted if you like it as is, and just as excited if it's a starting point for you to make something even better for yourself. From 3b0571e110c8b9e91c3649c901e3e780709ac337 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Mon, 3 Mar 2025 11:13:25 -0700 Subject: [PATCH 072/145] rewrite `to_timeout(unit: x * m)` to use larger units in some cases --- CHANGELOG.md | 13 ++++++++++ lib/style/single_node.ex | 43 +++++++++++++++++++++++++++++++++ lib/styler.ex | 2 +- test/style/single_node_test.exs | 30 +++++++++++++++++++++++ 4 files changed, 87 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59b3a788..25503ace 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ they can and will change without that change being reflected in Styler's semanti ## main +### Improvements + +- `to_timeout/1` use the next largest unit in some simple instances + + ```elixir + # before + to_timeout(second: 60 * m) + to_timeout(day: 7) + # after + to_timeout(minute: m) + to_timeout(week: 1) + ``` + ## 1.4 - A very nice change in alias lifting means Styler will make sure that your code is _using_ the aliases that it's specified. diff --git a/lib/style/single_node.ex b/lib/style/single_node.ex index 3d159b43..9ccb7329 100644 --- a/lib/style/single_node.ex +++ b/lib/style/single_node.ex @@ -186,6 +186,49 @@ defmodule Styler.Style.SingleNode do defp style({:case, cm, [head, [{do_, arrows}]]}), do: {:case, cm, [head, [{do_, rewrite_arrows(arrows)}]]} defp style({:fn, m, arrows}), do: {:fn, m, rewrite_arrows(arrows)} + defp style({:to_timeout, meta, [[{{:__block__, um, [unit]}, {:*, _, [left, right]}}]]} = node) + when unit in ~w(day hour minute second millisecond)a do + [l, r] = + Enum.map([left, right], fn + {_, _, [x]} -> x + _ -> nil + end) + + {step, next_unit} = + case unit do + :day -> {7, :week} + :hour -> {24, :day} + :minute -> {60, :hour} + :second -> {60, :minute} + :millisecond -> {1000, :second} + end + + if step in [l, r] do + n = if l == step, do: right, else: left + style({:to_timeout, meta, [[{{:__block__, um, [next_unit]}, n}]]}) + else + node + end + end + + defp style({:to_timeout, meta, [[{{:__block__, um, [unit]}, {:__block__, tm, [n]}}]]} = node) do + step_up = + case {unit, n} do + {:day, 7} -> :week + {:hour, 24} -> :day + {:minute, 60} -> :hour + {:second, 60} -> :minute + {:millisecond, 1000} -> :second + _ -> nil + end + + if step_up do + {:to_timeout, meta, [[{{:__block__, um, [step_up]}, {:__block__, [token: "1", line: tm[:line]], [1]}}]]} + else + node + end + end + defp style(node), do: node defp replace_into({:., dm, [{_, am, _} = enum, _]}, collectable, rest) do diff --git a/lib/styler.ex b/lib/styler.ex index dbb3992e..e8c8c749 100644 --- a/lib/styler.ex +++ b/lib/styler.ex @@ -21,10 +21,10 @@ defmodule Styler do @styles [ Styler.Style.ModuleDirectives, Styler.Style.Pipes, + Styler.Style.Deprecations, Styler.Style.SingleNode, Styler.Style.Defs, Styler.Style.Blocks, - Styler.Style.Deprecations, Styler.Style.Configs, Styler.Style.CommentDirectives ] diff --git a/test/style/single_node_test.exs b/test/style/single_node_test.exs index f8e40539..46de5f94 100644 --- a/test/style/single_node_test.exs +++ b/test/style/single_node_test.exs @@ -341,4 +341,34 @@ defmodule Styler.Style.SingleNodeTest do assert_style("Enum.reverse(foo, bar) ++ bar") end end + + describe "to_timeout" do + test "to next unit" do + facts = [ + {1000, :millisecond, :second}, + {60, :second, :minute}, + {60, :minute, :hour}, + {24, :hour, :day}, + {7, :day, :week} + ] + + for {n, unit, next} <- facts do + assert_style "to_timeout(#{unit}: #{n} * m)", "to_timeout(#{next}: m)" + assert_style "to_timeout(#{unit}: m * #{n})", "to_timeout(#{next}: m)" + assert_style "to_timeout(#{unit}: #{n})", "to_timeout(#{next}: 1)" + end + + assert_style "to_timeout(second: 60 * 60)", "to_timeout(hour: 1)" + end + + test "combined with :timer.x deprecation rewrite" do + assert_style ":timer.minutes(60 * 4)", "to_timeout(hour: 4)" + end + + test "doesnt mess with" do + assert_style "to_timeout(hour: n * m)" + assert_style "to_timeout(whatever)" + assert_style "to_timeout(hour: 24 * 1, second: 60 * 4)" + end + end end From 8fe1ca0efbb224de6be9e31318e2c16230c638c4 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Mon, 3 Mar 2025 11:14:30 -0700 Subject: [PATCH 073/145] defs test describe formatting --- test/style/defs_test.exs | 408 +++++++++++++++++++-------------------- 1 file changed, 203 insertions(+), 205 deletions(-) diff --git a/test/style/defs_test.exs b/test/style/defs_test.exs index d80f5871..808d80fe 100644 --- a/test/style/defs_test.exs +++ b/test/style/defs_test.exs @@ -11,232 +11,230 @@ defmodule Styler.Style.DefsTest do use Styler.StyleCase, async: true - describe "run" do - test "comments stay put when we can't shrink the head, even with blocks" do - assert_style(""" - def my_function( - so_long_that_this_head_will_not_fit_on_one_lineso_long_that_this_head_will_not_fit_on_one_line, - so_long_that_this_head_will_not_fit_on_one_line - ) do - result = - case foo do - :bar -> :baz - :baz -> :bong - end - - # My comment - Context.process(result) - end - """) - end + test "comments stay put when we can't shrink the head, even with blocks" do + assert_style(""" + def my_function( + so_long_that_this_head_will_not_fit_on_one_lineso_long_that_this_head_will_not_fit_on_one_line, + so_long_that_this_head_will_not_fit_on_one_line + ) do + result = + case foo do + :bar -> :baz + :baz -> :bong + end - test "function with do keyword" do - assert_style( - """ - # Top comment - def save( - # Socket comment - %Socket{assigns: %{user: user, live_action: :new}} = initial_socket, - # Params comment - params - ), - do: :ok - """, - """ - # Top comment - # Socket comment - # Params comment - def save(%Socket{assigns: %{user: user, live_action: :new}} = initial_socket, params), do: :ok - """ - ) + # My comment + Context.process(result) end + """) + end - test "bodyless function with spec" do - assert_style(""" - @spec original_object(atom()) :: atom() - def original_object(object) - """) - end + test "function with do keyword" do + assert_style( + """ + # Top comment + def save( + # Socket comment + %Socket{assigns: %{user: user, live_action: :new}} = initial_socket, + # Params comment + params + ), + do: :ok + """, + """ + # Top comment + # Socket comment + # Params comment + def save(%Socket{assigns: %{user: user, live_action: :new}} = initial_socket, params), do: :ok + """ + ) + end - test "block function body doesn't get newlined" do - assert_style(""" - # Here's a comment - 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 - # This comment could make sense - {:ok, post_process_the_results_somehow(results)} - 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(""" + # Here's a comment + 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 + # This comment could make sense + {: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 "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 # Comments in the darndest places - ) do - :ok - end - """, - """ - # Comments in the darndest places - def save(%Socket{assigns: %{user: user, live_action: :new}} = initial_socket, params) do - :ok - end - """ - ) - end + test "function with do block" do + assert_style( + """ + def save( + %Socket{assigns: %{user: user, live_action: :new}} = initial_socket, + params # Comments in the darndest places + ) do + :ok + end + """, + """ + # Comments in the darndest places + 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_nor_parens_yikes!" - - assert_style( - """ - # Top comment - def no_body( - foo, # This is a foo - bar # This is a bar - ) - - # Another comment for this head - def no_body(nil, _), do: nil - """, - """ - # Top comment - # This is a foo - # This is a bar - def no_body(foo, bar) - - # Another comment for this head - def no_body(nil, _), do: nil - """ - ) - end + test "no body" do + assert_style "def no_body_nor_parens_yikes!" - test "when clause w kwl do" do - assert_style( - """ - def foo(%{ - bar: baz - }) - # Self-documenting code! - when baz in [ - :a, # Obviously, this is a - :b # ... and this is b - ], - do: :never_write_code_like_this - """, - """ - # Self-documenting code! - # Obviously, this is a - # ... and this is b - def foo(%{bar: baz}) when baz in [:a, :b], do: :never_write_code_like_this - """ + assert_style( + """ + # Top comment + def no_body( + foo, # This is a foo + bar # This is a bar ) - end - test "keyword do with a list" do - assert_style( - """ - def foo, - do: [ - # Weirdo comment - :never_write_code_like_this - ] - """, - """ - # Weirdo comment - def foo, do: [:never_write_code_like_this] - """ - ) - end + # Another comment for this head + def no_body(nil, _), do: nil + """, + """ + # Top comment + # This is a foo + # This is a bar + def no_body(foo, bar) + + # Another comment for this head + def no_body(nil, _), do: nil + """ + ) + end + + test "when clause w kwl do" do + assert_style( + """ + def foo(%{ + bar: baz + }) + # Self-documenting code! + when baz in [ + :a, # Obviously, this is a + :b # ... and this is b + ], + do: :never_write_code_like_this + """, + """ + # Self-documenting code! + # Obviously, this is a + # ... and this is b + 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 + test "keyword do with a list" do + assert_style( + """ + def foo, + do: [ + # Weirdo comment + :never_write_code_like_this + ] + """, + """ + # Weirdo comment + def foo, do: [:never_write_code_like_this] + """ + ) + end - def foo( - too, - # Long long is too long - long - ), do: :ok - """, - """ - def foo, do: :ok + test "rewrites subsequent definitions" do + assert_style( + """ + def foo(), do: :ok + def foo( + too, # Long long is too long - def foo(too, long), do: :ok - """ - ) - end + long + ), do: :ok + """, + """ + def foo, do: :ok + + # Long long is too long + def foo(too, long), do: :ok + """ + ) + end - test "when clause with block do" do - assert_style( - """ - # Foo takes a bar - def foo(%{ - bar: baz - }) - # Baz should be either :a or :b - when baz in [ - :a, - :b - ] - do # Weird place for a comment - # Above the body - :never_write_code_like_this - # Below the body - end - """, - """ - # Foo takes a bar + test "when clause with block do" do + assert_style( + """ + # Foo takes a bar + def foo(%{ + bar: baz + }) # Baz should be either :a or :b - # Weird place for a comment - def foo(%{bar: baz}) when baz in [:a, :b] do - # Above the body - :never_write_code_like_this - # Below the body - end - """ - ) - end + when baz in [ + :a, + :b + ] + do # Weird place for a comment + # Above the body + :never_write_code_like_this + # Below the body + end + """, + """ + # Foo takes a bar + # Baz should be either :a or :b + # Weird place for a comment + def foo(%{bar: baz}) when baz in [:a, :b] do + # Above the body + :never_write_code_like_this + # Below the body + end + """ + ) + end - test "Doesn't move stuff around if it would make the line too long" do - assert_style(""" - @doc "this is a doc" - # And also a comment - def wow_this_function_name_is_super_long(it_also, has_a, ton_of, arguments), - do: "this is going to end up making the line too long if we inline it" - - @doc "this is another function" - # And it also has a comment - def this_one_fits_on_one_line, do: :ok - """) - end + test "Doesn't move stuff around if it would make the line too long" do + assert_style(""" + @doc "this is a doc" + # And also a comment + def wow_this_function_name_is_super_long(it_also, has_a, ton_of, arguments), + do: "this is going to end up making the line too long if we inline it" + + @doc "this is another function" + # And it also has a comment + def this_one_fits_on_one_line, do: :ok + """) + end - test "Doesn't collapse pipe chains in a def do ... end" do - assert_style(""" - def foo(some_list) do - some_list - |> Enum.reject(&is_nil/1) - |> Enum.map(&transform/1) - end - """) + test "Doesn't collapse pipe chains in a def do ... end" do + assert_style(""" + def foo(some_list) do + some_list + |> Enum.reject(&is_nil/1) + |> Enum.map(&transform/1) end + """) + end - test "regression: @def module attribute" do - assert_style("@def ~s(this should be okay)") - end + test "regression: @def module attribute" do + assert_style("@def ~s(this should be okay)") end end From 13320e95d029416b3df646e69e29020f318b1c0f Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Mon, 3 Mar 2025 11:19:28 -0700 Subject: [PATCH 074/145] dont crash on invalid defs --- CHANGELOG.md | 4 ++++ lib/style/defs.ex | 30 +++++++++++------------------- test/style/defs_test.exs | 12 ++++++++++-- 3 files changed, 25 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25503ace..063917a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ they can and will change without that change being reflected in Styler's semanti to_timeout(week: 1) ``` +### Fixes + +- fixed styler raising when encountering invalid function definition ast + ## 1.4 - A very nice change in alias lifting means Styler will make sure that your code is _using_ the aliases that it's specified. diff --git a/lib/style/defs.ex b/lib/style/defs.ex index f1670fd5..8a6243f5 100644 --- a/lib/style/defs.ex +++ b/lib/style/defs.ex @@ -62,19 +62,18 @@ defmodule Styler.Style.Defs do end end - # all the other kinds of defs! - # @TODO all paths here skip, which means that `def a .. quote do def b ...` won't style `def b` - def run({{def, def_meta, [head, body]}, _} = zipper, ctx) when def in [:def, :defp] do + def run({{def, def_meta, [head, [{{:__block__, dm, [:do]}, {_, bm, _}} | _] = body]}, _} = zipper, ctx) + when def in [:def, :defp] do def_line = def_meta[:line] + end_line = def_meta[:end][:line] || bm[:closing][:line] || dm[:line] - if do_meta = def_meta[:do] do - # This is a def with a do end block - end_line = def_meta[:end][:line] - - if def_line == end_line do + cond do + def_line == end_line -> {:skip, zipper, ctx} - else - do_line = do_meta[:line] + + # def do end + Keyword.has_key?(def_meta, :do) -> + do_line = dm[:line] delta = def_line - do_line def_meta = @@ -92,19 +91,12 @@ defmodule Styler.Style.Defs do |> Style.shift_comments(do_line..end_line, delta) {:skip, Zipper.replace(zipper, node), %{ctx | comments: comments}} - end - else - # This is a def with a keyword do - [{{:__block__, do_meta, [:do]}, {_, body_meta, _}}] = body - end_line = body_meta[:closing][:line] || do_meta[:line] - if def_line == end_line do - {:skip, zipper, ctx} - else + # def , do: + true -> node = Style.set_line({def, def_meta, [head, body]}, def_line) comments = Style.displace_comments(ctx.comments, def_line..end_line) {:skip, Zipper.replace(zipper, node), %{ctx | comments: comments}} - end end end diff --git a/test/style/defs_test.exs b/test/style/defs_test.exs index 808d80fe..f7a9fab7 100644 --- a/test/style/defs_test.exs +++ b/test/style/defs_test.exs @@ -234,7 +234,15 @@ defmodule Styler.Style.DefsTest do """) end - test "regression: @def module attribute" do - assert_style("@def ~s(this should be okay)") + describe "no ops" do + test "regression: @def module attribute" do + assert_style("@def ~s(this should be okay)") + end + + test "no explode on invalid def syntax" do + assert_style("def foo, true") + assert_style("def foo(a), true") + assert_raise SyntaxError, fn -> assert_style("def foo(a) true") end + end end end From 1df5f1d5b4e86547ba79244c7216c7cdaebeb743 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Wed, 5 Mar 2025 23:21:42 -0800 Subject: [PATCH 075/145] fix CI for older elixir --- test/style/deprecations_test.exs | 4 ++++ test/style/single_node_test.exs | 4 ---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/test/style/deprecations_test.exs b/test/style/deprecations_test.exs index d0633f91..0fbd13b7 100644 --- a/test/style/deprecations_test.exs +++ b/test/style/deprecations_test.exs @@ -148,5 +148,9 @@ defmodule Styler.Style.DeprecationsTest do assert_style "a |> x() |> :timer.minutes()" assert_style "a |> x() |> :timer.seconds()" end + + test "combined with to_timeout improvements" do + assert_style ":timer.minutes(60 * 4)", "to_timeout(hour: 4)" + end end end diff --git a/test/style/single_node_test.exs b/test/style/single_node_test.exs index 46de5f94..762049ca 100644 --- a/test/style/single_node_test.exs +++ b/test/style/single_node_test.exs @@ -361,10 +361,6 @@ defmodule Styler.Style.SingleNodeTest do assert_style "to_timeout(second: 60 * 60)", "to_timeout(hour: 1)" end - test "combined with :timer.x deprecation rewrite" do - assert_style ":timer.minutes(60 * 4)", "to_timeout(hour: 4)" - end - test "doesnt mess with" do assert_style "to_timeout(hour: n * m)" assert_style "to_timeout(whatever)" From be4dceca7e9fc89e804f611ebf1e53bca65c8d8d Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Sun, 16 Mar 2025 20:13:23 -0600 Subject: [PATCH 076/145] v1.4.1 --- CHANGELOG.md | 4 +++- mix.exs | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 063917a4..7daa8203 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,11 @@ they can and will change without that change being reflected in Styler's semanti ## main +## 1.4.1 + ### Improvements -- `to_timeout/1` use the next largest unit in some simple instances +- `to_timeout/1` rewrites to use the next largest unit in some simple instances ```elixir # before diff --git a/mix.exs b/mix.exs index 344cd357..9891e240 100644 --- a/mix.exs +++ b/mix.exs @@ -12,7 +12,7 @@ defmodule Styler.MixProject do use Mix.Project # Don't forget to bump the README when doing non-patch version changes - @version "1.4.0" + @version "1.4.1" @url "https://github.com/adobe/elixir-styler" def project do From 6b42462f49eee3d6c60049ec9e3b109ea31ccc69 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Tue, 1 Apr 2025 11:23:28 -0600 Subject: [PATCH 077/145] if: drop empty do bodies. Closes #227 --- CHANGELOG.md | 4 ++++ docs/control_flow_macros.md | 28 +++++++++++----------------- lib/style/blocks.ex | 11 ++++++----- test/style/blocks_test.exs | 26 ++++++++++++++++++++++++++ 4 files changed, 47 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7daa8203..74797496 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ they can and will change without that change being reflected in Styler's semanti ## main +### Improvements + +- `if`: drop empty `do` bodies like `if a, do: nil, else: b` => `if !a, do: b` (#227) + ## 1.4.1 ### Improvements diff --git a/docs/control_flow_macros.md b/docs/control_flow_macros.md index 70a85460..4fa6e05d 100644 --- a/docs/control_flow_macros.md +++ b/docs/control_flow_macros.md @@ -21,6 +21,8 @@ We advocate for `case` and `if` as the first tools to be considered for any cont Never! `unless` [is being deprecated](https://github.com/elixir-lang/elixir/pull/13769#issuecomment-2334878315) and so should not be used. +Styler replaces `unless` statements with their `if` equivalent similar to using the `mix format --migrate_unless` flag. + ### use `with` when... > `with` great power comes great responsibility @@ -56,34 +58,26 @@ if a, do: b, else: nil if a, do: b ``` +It also removes `do: nil` when an `else` is present, inverting the head to maintain semantics + +```elixir +if a, do: nil, else: b +# styled: +if !a, do: b +``` + ### Negation Inversion -Styler removes negators in the head of `if` and `unless` statements by "inverting" the statement. +Styler removes negators in the head of `if` statements by "inverting" the statement. The following operators are considered "negators": `!`, `not`, `!=`, `!==` - Examples: ```elixir -# negated `if` statement with no `else` clause are rewritten to `unless` -if not x, do: y -# Styled: -unless x, do: y - # negated `if` statements with an `else` clause have their clauses inverted and negation removed if !x, do: y, else: z # Styled: if x, do: z, else: y - -# negated `unless` statements are rewritten to `if` -unless x != y, do: z -# B styled: -if x == y, do: z - -# `unless` with `else` is verboten; these are always rewritten to `if` statements -unless x, do: y, else: z -# styled: -if x, do: z, else: y ``` Because elixir relies on truthy/falsey values for its `if` statements, boolean casting is unnecessary and so double negation is simply removed. diff --git a/lib/style/blocks.ex b/lib/style/blocks.ex index 7a74378c..322bca97 100644 --- a/lib/style/blocks.ex +++ b/lib/style/blocks.ex @@ -31,6 +31,7 @@ defmodule Styler.Style.Blocks do alias Styler.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], []] # case statement with exactly 2 `->` cases # rewrite to `if` if it's any of 3 trivial cases @@ -139,13 +140,13 @@ defmodule Styler.Style.Blocks do [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` - [head, [do_block, {_, {:__block__, _, []}}]] -> + # 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} - # drop `else: nil` - [head, [do_block, {_, {:__block__, _, [nil]}}]] -> - {: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 diff --git a/test/style/blocks_test.exs b/test/style/blocks_test.exs index e8f4626e..56efd186 100644 --- a/test/style/blocks_test.exs +++ b/test/style/blocks_test.exs @@ -988,6 +988,32 @@ defmodule Styler.Style.BlocksTest do ) end + test "inverts do nil" do + assert_style("if a, do: b, else: nil", "if a, do: b") + + assert_style("if a do nil else b end", """ + if !a do + b + end + """) + + assert_style( + """ + if a == b do + # comment + else + :ok + end + """, + """ + if a != b do + # comment + :ok + end + """ + ) + end + test "double negator rewrites" do for a <- ~w(not !), block <- ["do: z", "do: z, else: zz"] do assert_style "if #{a} (x != y), #{block}", "if x == y, #{block}" From 5b1c94631bd4748cb427c09562c11af46f452b64 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Tue, 29 Apr 2025 18:15:14 +0300 Subject: [PATCH 078/145] Fix large comment block mangling bug when ordering sibling AST (#232) Closes #230 --- CHANGELOG.md | 4 ++ lib/style.ex | 94 ++++++++++++++----------------------- lib/style/configs.ex | 2 +- test/style/configs_test.exs | 43 ++++++++++++++++- 4 files changed, 80 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74797496..4da3d281 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ they can and will change without that change being reflected in Styler's semanti - `if`: drop empty `do` bodies like `if a, do: nil, else: b` => `if !a, do: b` (#227) +### Fixes + +- fix bug that mangled large blocks of comments when sorting in configs or with `# styler:sort` (#230, h/t @cschmatzler) + ## 1.4.1 ### Improvements diff --git a/lib/style.ex b/lib/style.ex index f1a2dcf8..03b404fa 100644 --- a/lib/style.ex +++ b/lib/style.ex @@ -173,67 +173,47 @@ defmodule Styler.Style do def max_line([_ | _] = list), do: list |> List.last() |> max_line() def max_line(ast) do - meta = - case ast do - {_, meta, _} -> - meta + meta = meta(ast) - _ -> - [] - end + cond do + line = meta[:end_of_expression][:line] -> + line - if max_line = meta[:closing][:line] do - max_line - else - {_, max_line} = - Macro.prewalk(ast, 0, fn - {_, meta, _} = ast, max -> {ast, max(meta[:line] || max, max)} - ast, max -> {ast, max} - end) + line = meta[:closing][:line] -> + line + + true -> + {_, max_line} = + Macro.prewalk(ast, 0, fn + {_, meta, _} = ast, max -> {ast, max(meta[:line] || max, max)} + ast, max -> {ast, max} + end) - max_line + max_line end end + @doc "Sets the nodes' meta line and comments' line numbers to fit the ordering of the nodes list." + # TODO this doesn't grab comments which are floating as their own paragrpah, unconnected to a node + # they'll just be left floating where they were, then mangled with the re-ordered comments.. def order_line_meta_and_comments(nodes, comments, first_line) do - {nodes, comments, node_comments} = fix_lines(nodes, comments, first_line, [], []) - {nodes, Enum.sort_by(comments ++ node_comments, & &1.line)} - end + {nodes, shifted_comments, comments, _line} = + Enum.reduce(nodes, {[], [], comments, first_line}, fn node, {n_acc, c_acc, comments, move_to_line} -> + meta = meta(node) + line = meta[:line] + last_line = max_line(node) + {mine, comments} = comments_for_lines(comments, line, last_line) - defp fix_lines([], comments, _, node_acc, c_acc), do: {Enum.reverse(node_acc), comments, c_acc} + shift = move_to_line - (List.first(mine)[:line] || line) + 1 + shifted_node = shift_line(node, shift) + shifted_comments = Enum.map(mine, &%{&1 | line: &1.line + shift}) - defp fix_lines([node | nodes], comments, start_line, n_acc, c_acc) do - meta = meta(node) - line = meta[:line] - last_line = meta[:end_of_expression][:line] || max_line(node) + move_to_line = last_line + shift + (meta[:end_of_expression][:newlines] || 0) - {node, node_comments, comments} = - if start_line == line do - {node, [], comments} - else - {mine, comments} = comments_for_lines(comments, line, last_line) - line_with_comments = (List.first(mine)[:line] || line) - (List.first(mine)[:previous_eol_count] || 1) + 1 - - if line_with_comments == start_line do - {node, mine, comments} - else - shift = start_line - line_with_comments - # fix the node's line - node = shift_line(node, shift) - # fix the comment's line - mine = Enum.map(mine, &%{&1 | line: &1.line + shift}) - {node, mine, comments} - end - end + {[shifted_node | n_acc], shifted_comments ++ c_acc, comments, move_to_line} + end) - meta = meta(node) - # @TODO what about comments that were free floating between blocks? i'm just ignoring them and maybe always will... - # kind of just want to shove them to the end though, so that they don't interrupt existing stanzas. - # i think that's accomplishable by doing a final call above that finds all comments in the comments list that weren't moved - # and which are in the range of start..finish and sets their lines to finish! - last_line = meta[:end_of_expression][:line] || max_line(node) - last_line = (meta[:end_of_expression][:newlines] || 1) + last_line - fix_lines(nodes, comments, last_line, [node | n_acc], node_comments ++ c_acc) + {Enum.reverse(nodes), Enum.sort_by(comments ++ shifted_comments, & &1.line)} end # typical node @@ -243,13 +223,9 @@ defmodule Styler.Style do def meta(_), do: nil @doc """ - Returns all comments "for" a node, including on the line before it. - see `comments_for_lines` for more + Returns all comments "for" a node, including on the line before it. see `comments_for_lines` for more """ - def comments_for_node({_, m, _} = node, comments) do - last_line = m[:end_of_expression][:line] || max_line(node) - comments_for_lines(comments, m[:line], last_line) - end + def comments_for_node({_, m, _} = node, comments), do: comments_for_lines(comments, m[:line], max_line(node)) @doc """ Gets all comments in range start_line..last_line, and any comments immediately before start_line.s @@ -268,10 +244,6 @@ defmodule Styler.Style do comments |> Enum.reverse() |> comments_for_lines(start_line, last_line, [], []) end - defp comments_for_lines(reversed_comments, start, last, match, acc) - - defp comments_for_lines([], _, _, match, acc), do: {Enum.reverse(match), acc} - defp comments_for_lines([%{line: line} = comment | rev_comments], start, last, match, acc) do cond do # after our block - no match @@ -285,4 +257,6 @@ defmodule Styler.Style do true -> {match, Enum.reverse(rev_comments, [comment | acc])} end end + + defp comments_for_lines([], _, _, match, acc), do: {match, acc} end diff --git a/lib/style/configs.ex b/lib/style/configs.ex index 75a0d750..d83b4474 100644 --- a/lib/style/configs.ex +++ b/lib/style/configs.ex @@ -86,7 +86,7 @@ defmodule Styler.Style.Configs do # the first node of `rest` is greater than the highest line in configs, assignments # config line is the first line to be used as part of this block {node_comments, _} = Style.comments_for_node(config, comments) - first_line = min(List.last(node_comments)[:line] || cfm[:line], cfm[:line]) + first_line = min(List.first(node_comments)[:line] || cfm[:line], cfm[:line]) Style.order_line_meta_and_comments(nodes, comments, first_line) else {nodes, comments} diff --git a/test/style/configs_test.exs b/test/style/configs_test.exs index 3f3db06f..0bb4966e 100644 --- a/test/style/configs_test.exs +++ b/test/style/configs_test.exs @@ -166,6 +166,7 @@ defmodule Styler.Style.ConfigsTest do config :a, 2 config :a, 3 config :a, 4 + # comment # b comment config :b, 1 @@ -334,9 +335,9 @@ defmodule Styler.Style.ConfigsTest do c: :d, e: :f - config :c, - # some junk after b, idk + # some junk after b, idk + config :c, # ca ca: :ca, # cb 1 @@ -350,5 +351,43 @@ defmodule Styler.Style.ConfigsTest do """ ) end + + test "big block regression #230" do + # The nodes are in reverse order + assert_style( + """ + import Config + + # z-a + # z-b + # z-c + # z-d + # z-e + config :z, z + + # y + config :y, y + + # x + config :x, x + """, + """ + import Config + + # x + config :x, x + + # y + config :y, y + + # z-a + # z-b + # z-c + # z-d + # z-e + config :z, z + """ + ) + end end end From 50ae386e7cde130e53a411f8fd6cb66824005e84 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Tue, 29 Apr 2025 09:39:37 -0600 Subject: [PATCH 079/145] if: treat is_nil as a negator --- CHANGELOG.md | 7 +++++++ docs/control_flow_macros.md | 9 +++++++-- lib/style/blocks.ex | 7 ++++--- test/style/blocks_test.exs | 6 ++++++ 4 files changed, 24 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4da3d281..bf005eda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ they can and will change without that change being reflected in Styler's semanti ### Improvements - `if`: drop empty `do` bodies like `if a, do: nil, else: b` => `if !a, do: b` (#227) +- `if`: treat `is_nil` as a negator. + + this means `if is_nil(x), do: a, else: b` will be inverted to `if x, do: b, else: a` + + or `if !is_nil(x), do: y` will be rewritten as `if x, do: y` + + This could cause problems where x is `false` =) ### Fixes diff --git a/docs/control_flow_macros.md b/docs/control_flow_macros.md index 4fa6e05d..803fb9e2 100644 --- a/docs/control_flow_macros.md +++ b/docs/control_flow_macros.md @@ -69,7 +69,7 @@ if !a, do: b ### Negation Inversion Styler removes negators in the head of `if` statements by "inverting" the statement. -The following operators are considered "negators": `!`, `not`, `!=`, `!==` +The following operators are considered "negators": `!`, `not`, `!=`, `!==`, `is_nil` Examples: @@ -83,7 +83,12 @@ if x, do: z, else: y Because elixir relies on truthy/falsey values for its `if` statements, boolean casting is unnecessary and so double negation is simply removed. ```elixir -if !!x, do: y +if !x, do: y +# styled: +if x, do: y + +# similarly +if !is_nil(x), do: y # styled: if x, do: y ``` diff --git a/lib/style/blocks.ex b/lib/style/blocks.ex index 322bca97..3c1abeb8 100644 --- a/lib/style/blocks.ex +++ b/lib/style/blocks.ex @@ -30,7 +30,7 @@ defmodule Styler.Style.Blocks do alias Styler.Style alias Styler.Zipper - defguardp is_negator(n) when elem(n, 0) in [:!, :not, :!=, :!==] + defguardp is_if_negator(n) when elem(n, 0) in [:!, :not, :!=, :!==, :is_nil] defguardp is_empty_body(n) when elem(n, 0) == :__block__ and elem(n, 2) in [[nil], []] # case statement with exactly 2 `->` cases @@ -132,12 +132,12 @@ defmodule Styler.Style.Blocks 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) -> + [{_, _, [nb]} = na, do_else] when is_if_negator(na) and is_if_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) -> + [negator, [{do_, do_body}, {else_, else_body}]] when is_if_negator(negator) -> zipper |> Zipper.replace({:if, m, [invert(negator), [{do_, else_body}, {else_, do_body}]]}) |> run(ctx) # drop `else end` and `else: nil` @@ -368,5 +368,6 @@ defmodule Styler.Style.Blocks do defp invert({:!, _, [condition]}), do: condition defp invert({:not, _, [condition]}), do: condition defp invert({:in, m, [_, _]} = ast), do: {:not, m, [ast]} + defp invert({:is_nil, _, [a]}), do: a defp invert({_, m, _} = ast), do: {:!, [line: m[:line]], [ast]} end diff --git a/test/style/blocks_test.exs b/test/style/blocks_test.exs index 56efd186..99faf996 100644 --- a/test/style/blocks_test.exs +++ b/test/style/blocks_test.exs @@ -1012,6 +1012,12 @@ defmodule Styler.Style.BlocksTest do end """ ) + + assert_style "if is_nil(a.b), do: nil, else: a.c", "if a.b, do: a.c" + end + + test "if not is_nil" do + assert_style "if is_nil(a), do: b, else: c", "if a, do: c, else: b" end test "double negator rewrites" do From 17434b65fd41588afd9c649b4a2542042477b40e Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Tue, 29 Apr 2025 10:22:31 -0600 Subject: [PATCH 080/145] Revert "if: treat is_nil as a negator" This reverts commit 50ae386e7cde130e53a411f8fd6cb66824005e84. --- CHANGELOG.md | 7 ------- docs/control_flow_macros.md | 9 ++------- lib/style/blocks.ex | 7 +++---- test/style/blocks_test.exs | 6 ------ 4 files changed, 5 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf005eda..4da3d281 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,13 +8,6 @@ they can and will change without that change being reflected in Styler's semanti ### Improvements - `if`: drop empty `do` bodies like `if a, do: nil, else: b` => `if !a, do: b` (#227) -- `if`: treat `is_nil` as a negator. - - this means `if is_nil(x), do: a, else: b` will be inverted to `if x, do: b, else: a` - - or `if !is_nil(x), do: y` will be rewritten as `if x, do: y` - - This could cause problems where x is `false` =) ### Fixes diff --git a/docs/control_flow_macros.md b/docs/control_flow_macros.md index 803fb9e2..4fa6e05d 100644 --- a/docs/control_flow_macros.md +++ b/docs/control_flow_macros.md @@ -69,7 +69,7 @@ if !a, do: b ### Negation Inversion Styler removes negators in the head of `if` statements by "inverting" the statement. -The following operators are considered "negators": `!`, `not`, `!=`, `!==`, `is_nil` +The following operators are considered "negators": `!`, `not`, `!=`, `!==` Examples: @@ -83,12 +83,7 @@ if x, do: z, else: y Because elixir relies on truthy/falsey values for its `if` statements, boolean casting is unnecessary and so double negation is simply removed. ```elixir -if !x, do: y -# styled: -if x, do: y - -# similarly -if !is_nil(x), do: y +if !!x, do: y # styled: if x, do: y ``` diff --git a/lib/style/blocks.ex b/lib/style/blocks.ex index 3c1abeb8..322bca97 100644 --- a/lib/style/blocks.ex +++ b/lib/style/blocks.ex @@ -30,7 +30,7 @@ defmodule Styler.Style.Blocks do alias Styler.Style alias Styler.Zipper - defguardp is_if_negator(n) when elem(n, 0) in [:!, :not, :!=, :!==, :is_nil] + 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], []] # case statement with exactly 2 `->` cases @@ -132,12 +132,12 @@ defmodule Styler.Style.Blocks do case children do # double negator # if !!x, do: y[, else: ...] => if x, do: y[, else: ...] - [{_, _, [nb]} = na, do_else] when is_if_negator(na) and is_if_negator(nb) -> + [{_, _, [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_if_negator(negator) -> + [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` @@ -368,6 +368,5 @@ defmodule Styler.Style.Blocks do defp invert({:!, _, [condition]}), do: condition defp invert({:not, _, [condition]}), do: condition defp invert({:in, m, [_, _]} = ast), do: {:not, m, [ast]} - defp invert({:is_nil, _, [a]}), do: a defp invert({_, m, _} = ast), do: {:!, [line: m[:line]], [ast]} end diff --git a/test/style/blocks_test.exs b/test/style/blocks_test.exs index 99faf996..56efd186 100644 --- a/test/style/blocks_test.exs +++ b/test/style/blocks_test.exs @@ -1012,12 +1012,6 @@ defmodule Styler.Style.BlocksTest do end """ ) - - assert_style "if is_nil(a.b), do: nil, else: a.c", "if a.b, do: a.c" - end - - test "if not is_nil" do - assert_style "if is_nil(a), do: b, else: c", "if a, do: c, else: b" end test "double negator rewrites" do From aa3e7ce7157085c637785f3f7ff9d8208845fb6d Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Tue, 29 Apr 2025 10:23:04 -0600 Subject: [PATCH 081/145] Revert "if: drop empty do bodies. Closes #227" This reverts commit 6b42462f49eee3d6c60049ec9e3b109ea31ccc69. --- CHANGELOG.md | 4 ---- docs/control_flow_macros.md | 28 +++++++++++++++++----------- lib/style/blocks.ex | 11 +++++------ test/style/blocks_test.exs | 26 -------------------------- 4 files changed, 22 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4da3d281..552e3945 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,6 @@ they can and will change without that change being reflected in Styler's semanti ## main -### Improvements - -- `if`: drop empty `do` bodies like `if a, do: nil, else: b` => `if !a, do: b` (#227) - ### Fixes - fix bug that mangled large blocks of comments when sorting in configs or with `# styler:sort` (#230, h/t @cschmatzler) diff --git a/docs/control_flow_macros.md b/docs/control_flow_macros.md index 4fa6e05d..70a85460 100644 --- a/docs/control_flow_macros.md +++ b/docs/control_flow_macros.md @@ -21,8 +21,6 @@ We advocate for `case` and `if` as the first tools to be considered for any cont Never! `unless` [is being deprecated](https://github.com/elixir-lang/elixir/pull/13769#issuecomment-2334878315) and so should not be used. -Styler replaces `unless` statements with their `if` equivalent similar to using the `mix format --migrate_unless` flag. - ### use `with` when... > `with` great power comes great responsibility @@ -58,26 +56,34 @@ if a, do: b, else: nil if a, do: b ``` -It also removes `do: nil` when an `else` is present, inverting the head to maintain semantics - -```elixir -if a, do: nil, else: b -# styled: -if !a, do: b -``` - ### Negation Inversion -Styler removes negators in the head of `if` statements by "inverting" the statement. +Styler removes negators in the head of `if` and `unless` statements by "inverting" the statement. The following operators are considered "negators": `!`, `not`, `!=`, `!==` + Examples: ```elixir +# negated `if` statement with no `else` clause are rewritten to `unless` +if not x, do: y +# Styled: +unless x, do: y + # negated `if` statements with an `else` clause have their clauses inverted and negation removed if !x, do: y, else: z # Styled: if x, do: z, else: y + +# negated `unless` statements are rewritten to `if` +unless x != y, do: z +# B styled: +if x == y, do: z + +# `unless` with `else` is verboten; these are always rewritten to `if` statements +unless x, do: y, else: z +# styled: +if x, do: z, else: y ``` Because elixir relies on truthy/falsey values for its `if` statements, boolean casting is unnecessary and so double negation is simply removed. diff --git a/lib/style/blocks.ex b/lib/style/blocks.ex index 322bca97..7a74378c 100644 --- a/lib/style/blocks.ex +++ b/lib/style/blocks.ex @@ -31,7 +31,6 @@ defmodule Styler.Style.Blocks do alias Styler.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], []] # case statement with exactly 2 `->` cases # rewrite to `if` if it's any of 3 trivial cases @@ -140,13 +139,13 @@ defmodule Styler.Style.Blocks do [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) -> + # drop `else end` + [head, [do_block, {_, {:__block__, _, []}}]] -> {: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} + # drop `else: nil` + [head, [do_block, {_, {:__block__, _, [nil]}}]] -> + {:cont, Zipper.replace(zipper, {:if, m, [head, [do_block]]}), ctx} [head, [do_, else_]] -> if Style.max_line(do_) > Style.max_line(else_) do diff --git a/test/style/blocks_test.exs b/test/style/blocks_test.exs index 56efd186..e8f4626e 100644 --- a/test/style/blocks_test.exs +++ b/test/style/blocks_test.exs @@ -988,32 +988,6 @@ defmodule Styler.Style.BlocksTest do ) end - test "inverts do nil" do - assert_style("if a, do: b, else: nil", "if a, do: b") - - assert_style("if a do nil else b end", """ - if !a do - b - end - """) - - assert_style( - """ - if a == b do - # comment - else - :ok - end - """, - """ - if a != b do - # comment - :ok - end - """ - ) - end - test "double negator rewrites" do for a <- ~w(not !), block <- ["do: z", "do: z, else: zz"] do assert_style "if #{a} (x != y), #{block}", "if x == y, #{block}" From c511610f9aabebfcb978c42eb2eaa9ed43b74213 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Thu, 1 May 2025 15:42:12 +0300 Subject: [PATCH 082/145] v1.4.2 --- CHANGELOG.md | 6 ++++-- mix.exs | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 552e3945..997797db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,11 @@ they can and will change without that change being reflected in Styler's semanti ## main +## 1.4.2 + ### Fixes -- fix bug that mangled large blocks of comments when sorting in configs or with `# styler:sort` (#230, h/t @cschmatzler) +- fix comment misplacement for large comment blocks in config files and `# styler:sort` (#230, h/t @cschmatzler) ## 1.4.1 @@ -28,7 +30,7 @@ they can and will change without that change being reflected in Styler's semanti - fixed styler raising when encountering invalid function definition ast -## 1.4 +## 1.4.0 - A very nice change in alias lifting means Styler will make sure that your code is _using_ the aliases that it's specified. - Shoutout to the smartrent folks for finding pipifying recursion issues diff --git a/mix.exs b/mix.exs index 9891e240..305656f2 100644 --- a/mix.exs +++ b/mix.exs @@ -12,7 +12,7 @@ defmodule Styler.MixProject do use Mix.Project # Don't forget to bump the README when doing non-patch version changes - @version "1.4.1" + @version "1.4.2" @url "https://github.com/adobe/elixir-styler" def project do From f66fc54c479ac6065f5ba46e6b9513a1d489b972 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Thu, 8 May 2025 08:18:12 +0300 Subject: [PATCH 083/145] changelog: fix GH md formatting issues --- CHANGELOG.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 997797db..4b591991 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ This release taught Styler to try just that little bit harder when doing alias l - use knowledge of existing aliases to shorten invocations (#201, h/t me) example: + alias A.B.C A.B.C.foo() @@ -56,6 +57,7 @@ This release taught Styler to try just that little bit harder when doing alias l A.B.C.baz() becomes: + alias A.B.C C.foo() @@ -93,6 +95,7 @@ This release taught Styler to try just that little bit harder when doing alias l - `# styler:sort` will sort arbitrary ast nodes within a `do end` block: Given: + # styler:sort my_macro "some arg" do another_macro :q @@ -104,6 +107,7 @@ This release taught Styler to try just that little bit harder when doing alias l end We get + # styler:sort my_macro "some arg" do another_macro :e @@ -134,8 +138,6 @@ This release taught Styler to try just that little bit harder when doing alias l - `# styler:sort` no longer blows up on keyword lists :X -### Fixes - ## 1.3.0 ### Improvements From e2d4e112dc11a038677501834c0502f148f74b2c Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Thu, 22 May 2025 13:24:48 -0600 Subject: [PATCH 084/145] optimize zipper performance --- lib/style/configs.ex | 7 +++--- lib/style/module_directives.ex | 8 +++---- lib/zipper.ex | 39 ++++++++++++++-------------------- test/zipper_test.exs | 12 +++++------ 4 files changed, 30 insertions(+), 36 deletions(-) diff --git a/lib/style/configs.ex b/lib/style/configs.ex index d83b4474..a5379897 100644 --- a/lib/style/configs.ex +++ b/lib/style/configs.ex @@ -48,8 +48,9 @@ defmodule Styler.Style.Configs do end def run({{:config, cfm, [_, _ | _]} = config, zm}, %{mix_config?: true, comments: comments} = ctx) do + {l, p, r} = zm # all of these list are reversed due to the reduce - {configs, assignments, rest} = accumulate(zm.r, [], []) + {configs, assignments, rest} = accumulate(r, [], []) # @TODO # okay so comments between nodes that we moved....... # lets just push them out of the way (???). so @@ -92,9 +93,9 @@ defmodule Styler.Style.Configs do {nodes, comments} end - [config | left_siblings] = Enum.reverse(nodes, zm.l) + [config | left_siblings] = Enum.reverse(nodes, l) - {:skip, {config, %{zm | l: left_siblings, r: rest}}, %{ctx | comments: comments}} + {:skip, {config, {left_siblings, p, rest}}, %{ctx | comments: comments}} end def run(zipper, %{config?: true} = ctx) do diff --git a/lib/style/module_directives.ex b/lib/style/module_directives.ex index b88f3c62..16a372e1 100644 --- a/lib/style/module_directives.ex +++ b/lib/style/module_directives.ex @@ -107,9 +107,9 @@ defmodule Styler.Style.ModuleDirectives do # puts `@derive` before `defstruct` etc, fixing compiler warnings def run({{:@, _, [{:derive, _, _}]}, _} = zipper, ctx) do case Style.ensure_block_parent(zipper) do - {:ok, {derive, %{l: left_siblings} = z_meta}} -> + {:ok, {derive, {l, p, r}}} -> previous_defstruct = - left_siblings + l |> Stream.with_index() |> Enum.find_value(fn {{struct_def, meta, _}, index} when struct_def in @defstruct -> {meta[:line], index} @@ -119,8 +119,8 @@ defmodule Styler.Style.ModuleDirectives do if previous_defstruct do {defstruct_line, defstruct_index} = previous_defstruct derive = Style.set_line(derive, defstruct_line - 1) - left_siblings = List.insert_at(left_siblings, defstruct_index + 1, derive) - {:skip, Zipper.remove({derive, %{z_meta | l: left_siblings}}), ctx} + left_siblings = List.insert_at(l, defstruct_index + 1, derive) + {:skip, Zipper.remove({derive, {left_siblings, p, r}}), ctx} else {:cont, zipper, ctx} end diff --git a/lib/zipper.ex b/lib/zipper.ex index 9b43d1e1..c7a4487b 100644 --- a/lib/zipper.ex +++ b/lib/zipper.ex @@ -26,14 +26,8 @@ defmodule Styler.Zipper do import Kernel, except: [node: 1] @type tree :: Macro.t() - - @opaque path :: %{ - l: [tree], - ptree: zipper, - r: [tree] - } - @type zipper :: {tree, path | nil} + @type path :: {left :: [tree], parent :: zipper, right :: [tree]} @type t :: zipper @type command :: :cont | :skip | :halt @@ -91,7 +85,7 @@ defmodule Styler.Zipper do def down(zipper) do case children(zipper) do [] -> nil - [first | rest] -> {first, %{ptree: zipper, l: [], r: rest}} + [first | rest] -> {first, {[], zipper, rest}} end end @@ -102,9 +96,8 @@ defmodule Styler.Zipper do @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 + def up({tree, {l, {parent, parent_meta}, r}}) do + children = Enum.reverse(l, [tree | r]) {do_replace_children(parent, children), parent_meta} end @@ -112,16 +105,16 @@ defmodule Styler.Zipper do 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({tree, {[ltree | l], p, r}}), do: {ltree, {l, p, [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 - [leftmost | r] = Enum.reverse(l, [tree | meta.r]) - {leftmost, %{meta | l: [], r: r}} + def leftmost({tree, {[_ | _] = l, p, r}}) do + [leftmost | r] = Enum.reverse(l, [tree | r]) + {leftmost, {[], p, r}} end def leftmost({_, _} = zipper), do: zipper @@ -130,16 +123,16 @@ defmodule Styler.Zipper do 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({tree, {l, p, [rtree | r]}}), do: {rtree, {[tree | l], p, r}} 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 - [rightmost | l] = Enum.reverse(r, [tree | meta.l]) - {rightmost, %{meta | l: l, r: []}} + def rightmost({tree, {l, p, [_ | _] = r}}) do + [rightmost | l] = Enum.reverse(r, [tree | l]) + {rightmost, {l, p, []}} end def rightmost({_, _} = zipper), do: zipper @@ -163,8 +156,8 @@ defmodule Styler.Zipper do """ @spec remove(zipper) :: zipper def remove({_, nil}), do: raise(ArgumentError, message: "Cannot remove the top level node.") - def remove({_, %{l: [left | rest]} = meta}), do: prev_down({left, %{meta | l: rest}}) - def remove({_, %{ptree: {parent, parent_meta}, r: children}}), do: {do_replace_children(parent, children), parent_meta} + def remove({_, {[left | rest], p, r}}), do: prev_down({left, {rest, p, r}}) + def remove({_, {_, {parent, parent_meta}, children}}), do: {do_replace_children(parent, children), parent_meta} @doc """ Inserts the item as the left sibling of the node at this zipper, without @@ -184,7 +177,7 @@ defmodule Styler.Zipper do """ @spec prepend_siblings(zipper, [tree]) :: zipper def prepend_siblings({node, nil}, siblings), do: {:__block__, [], siblings ++ [node]} |> zip() |> down() |> rightmost() - def prepend_siblings({tree, meta}, siblings), do: {tree, %{meta | l: Enum.reverse(siblings, meta.l)}} + def prepend_siblings({tree, {l, p, r}}, siblings), do: {tree, {Enum.reverse(siblings, l), p , r}} @doc """ Inserts the item as the right sibling of the node at this zipper, without @@ -204,7 +197,7 @@ defmodule Styler.Zipper do """ @spec insert_siblings(zipper, [tree]) :: zipper def insert_siblings({node, nil}, siblings), do: {:__block__, [], [node | siblings]} |> zip() |> down() - def insert_siblings({tree, meta}, siblings), do: {tree, %{meta | r: siblings ++ meta.r}} + def insert_siblings({tree, {l, p, r}}, siblings), do: {tree, {l, p, siblings ++ r}} @doc """ Inserts the item as the leftmost child of the node at this zipper, diff --git a/test/zipper_test.exs b/test/zipper_test.exs index 2359d481..81dcf6e3 100644 --- a/test/zipper_test.exs +++ b/test/zipper_test.exs @@ -63,14 +63,14 @@ defmodule StylerTest.ZipperTest do describe "down/1" do test "rips and tears the parent node" do - assert [1, 2] |> Zipper.zip() |> Zipper.down() == {1, %{l: [], r: [2], ptree: {[1, 2], nil}}} - assert {1, 2} |> Zipper.zip() |> Zipper.down() == {1, %{l: [], r: [2], ptree: {{1, 2}, nil}}} + assert [1, 2] |> Zipper.zip() |> Zipper.down() == {1, {[], {[1, 2], nil}, [2]}} + assert {1, 2} |> Zipper.zip() |> Zipper.down() == {1, {[], {{1, 2}, nil}, [2]}} assert {:foo, [], [1, 2]} |> Zipper.zip() |> Zipper.down() == - {1, %{l: [], r: [2], ptree: {{:foo, [], [1, 2]}, nil}}} + {1, {[], {{:foo, [], [1, 2]}, nil}, [2]}} assert {{:., [], [:a, :b]}, [], [1, 2]} |> Zipper.zip() |> Zipper.down() == - {{:., [], [:a, :b]}, %{l: [], r: [1, 2], ptree: {{{:., [], [:a, :b]}, [], [1, 2]}, nil}}} + {{:., [], [:a, :b]}, {[],{{{:., [], [:a, :b]}, [], [1, 2]}, nil}, [1, 2]}} end end @@ -471,8 +471,8 @@ defmodule StylerTest.ZipperTest do end test "builds a new root node made of a block" do - assert {42, %{l: [:nope], ptree: {{:__block__, _, _}, nil}}} = 42 |> Zipper.zip() |> Zipper.insert_left(:nope) - assert {42, %{r: [:nope], ptree: {{:__block__, _, _}, nil}}} = 42 |> Zipper.zip() |> Zipper.insert_right(:nope) + assert {42, {[:nope], {{:__block__, _, _}, nil}, []}} = 42 |> Zipper.zip() |> Zipper.insert_left(:nope) + assert {42, {[], {{:__block__, _, _}, nil}, [:nope]}} = 42 |> Zipper.zip() |> Zipper.insert_right(:nope) end end From 0fcfcdccdf0850db7751d2b6583510f380b9d9de Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Thu, 12 Jun 2025 13:25:15 -0600 Subject: [PATCH 085/145] Revert "optimize zipper performance" This reverts commit e2d4e112dc11a038677501834c0502f148f74b2c. --- lib/style/configs.ex | 7 +++--- lib/style/module_directives.ex | 8 +++---- lib/zipper.ex | 39 ++++++++++++++++++++-------------- test/zipper_test.exs | 12 +++++------ 4 files changed, 36 insertions(+), 30 deletions(-) diff --git a/lib/style/configs.ex b/lib/style/configs.ex index a5379897..d83b4474 100644 --- a/lib/style/configs.ex +++ b/lib/style/configs.ex @@ -48,9 +48,8 @@ defmodule Styler.Style.Configs do end def run({{:config, cfm, [_, _ | _]} = config, zm}, %{mix_config?: true, comments: comments} = ctx) do - {l, p, r} = zm # all of these list are reversed due to the reduce - {configs, assignments, rest} = accumulate(r, [], []) + {configs, assignments, rest} = accumulate(zm.r, [], []) # @TODO # okay so comments between nodes that we moved....... # lets just push them out of the way (???). so @@ -93,9 +92,9 @@ defmodule Styler.Style.Configs do {nodes, comments} end - [config | left_siblings] = Enum.reverse(nodes, l) + [config | left_siblings] = Enum.reverse(nodes, zm.l) - {:skip, {config, {left_siblings, p, rest}}, %{ctx | comments: comments}} + {:skip, {config, %{zm | l: left_siblings, r: rest}}, %{ctx | comments: comments}} end def run(zipper, %{config?: true} = ctx) do diff --git a/lib/style/module_directives.ex b/lib/style/module_directives.ex index 16a372e1..b88f3c62 100644 --- a/lib/style/module_directives.ex +++ b/lib/style/module_directives.ex @@ -107,9 +107,9 @@ defmodule Styler.Style.ModuleDirectives do # puts `@derive` before `defstruct` etc, fixing compiler warnings def run({{:@, _, [{:derive, _, _}]}, _} = zipper, ctx) do case Style.ensure_block_parent(zipper) do - {:ok, {derive, {l, p, r}}} -> + {:ok, {derive, %{l: left_siblings} = z_meta}} -> previous_defstruct = - l + left_siblings |> Stream.with_index() |> Enum.find_value(fn {{struct_def, meta, _}, index} when struct_def in @defstruct -> {meta[:line], index} @@ -119,8 +119,8 @@ defmodule Styler.Style.ModuleDirectives do if previous_defstruct do {defstruct_line, defstruct_index} = previous_defstruct derive = Style.set_line(derive, defstruct_line - 1) - left_siblings = List.insert_at(l, defstruct_index + 1, derive) - {:skip, Zipper.remove({derive, {left_siblings, p, r}}), ctx} + left_siblings = List.insert_at(left_siblings, defstruct_index + 1, derive) + {:skip, Zipper.remove({derive, %{z_meta | l: left_siblings}}), ctx} else {:cont, zipper, ctx} end diff --git a/lib/zipper.ex b/lib/zipper.ex index c7a4487b..9b43d1e1 100644 --- a/lib/zipper.ex +++ b/lib/zipper.ex @@ -26,8 +26,14 @@ defmodule Styler.Zipper do import Kernel, except: [node: 1] @type tree :: Macro.t() + + @opaque path :: %{ + l: [tree], + ptree: zipper, + r: [tree] + } + @type zipper :: {tree, path | nil} - @type path :: {left :: [tree], parent :: zipper, right :: [tree]} @type t :: zipper @type command :: :cont | :skip | :halt @@ -85,7 +91,7 @@ defmodule Styler.Zipper do def down(zipper) do case children(zipper) do [] -> nil - [first | rest] -> {first, {[], zipper, rest}} + [first | rest] -> {first, %{ptree: zipper, l: [], r: rest}} end end @@ -96,8 +102,9 @@ defmodule Styler.Zipper do @spec up(zipper) :: zipper | nil def up({_, nil}), do: nil - def up({tree, {l, {parent, parent_meta}, r}}) do - children = Enum.reverse(l, [tree | r]) + def up({tree, meta}) do + children = Enum.reverse(meta.l, [tree | meta.r]) + {parent, parent_meta} = meta.ptree {do_replace_children(parent, children), parent_meta} end @@ -105,16 +112,16 @@ defmodule Styler.Zipper do Returns the zipper of the left sibling of the node at this zipper, or nil. """ @spec left(zipper) :: zipper | nil - def left({tree, {[ltree | l], p, r}}), do: {ltree, {l, p, [tree | r]}} + 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, p, r}}) do - [leftmost | r] = Enum.reverse(l, [tree | r]) - {leftmost, {[], p, r}} + def leftmost({tree, %{l: [_ | _] = l} = meta}) do + [leftmost | r] = Enum.reverse(l, [tree | meta.r]) + {leftmost, %{meta | l: [], r: r}} end def leftmost({_, _} = zipper), do: zipper @@ -123,16 +130,16 @@ defmodule Styler.Zipper do Returns the zipper of the right sibling of the node at this zipper, or nil. """ @spec right(zipper) :: zipper | nil - def right({tree, {l, p, [rtree | r]}}), do: {rtree, {[tree | l], p, r}} + 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, {l, p, [_ | _] = r}}) do - [rightmost | l] = Enum.reverse(r, [tree | l]) - {rightmost, {l, p, []}} + def rightmost({tree, %{r: [_ | _] = r} = meta}) do + [rightmost | l] = Enum.reverse(r, [tree | meta.l]) + {rightmost, %{meta | l: l, r: []}} end def rightmost({_, _} = zipper), do: zipper @@ -156,8 +163,8 @@ defmodule Styler.Zipper do """ @spec remove(zipper) :: zipper def remove({_, nil}), do: raise(ArgumentError, message: "Cannot remove the top level node.") - def remove({_, {[left | rest], p, r}}), do: prev_down({left, {rest, p, r}}) - def remove({_, {_, {parent, parent_meta}, children}}), do: {do_replace_children(parent, children), parent_meta} + def remove({_, %{l: [left | rest]} = meta}), do: prev_down({left, %{meta | l: rest}}) + def remove({_, %{ptree: {parent, parent_meta}, r: children}}), do: {do_replace_children(parent, children), parent_meta} @doc """ Inserts the item as the left sibling of the node at this zipper, without @@ -177,7 +184,7 @@ defmodule Styler.Zipper do """ @spec prepend_siblings(zipper, [tree]) :: zipper def prepend_siblings({node, nil}, siblings), do: {:__block__, [], siblings ++ [node]} |> zip() |> down() |> rightmost() - def prepend_siblings({tree, {l, p, r}}, siblings), do: {tree, {Enum.reverse(siblings, l), p , r}} + def prepend_siblings({tree, meta}, siblings), do: {tree, %{meta | l: Enum.reverse(siblings, meta.l)}} @doc """ Inserts the item as the right sibling of the node at this zipper, without @@ -197,7 +204,7 @@ defmodule Styler.Zipper do """ @spec insert_siblings(zipper, [tree]) :: zipper def insert_siblings({node, nil}, siblings), do: {:__block__, [], [node | siblings]} |> zip() |> down() - def insert_siblings({tree, {l, p, r}}, siblings), do: {tree, {l, p, siblings ++ r}} + def insert_siblings({tree, meta}, siblings), do: {tree, %{meta | r: siblings ++ meta.r}} @doc """ Inserts the item as the leftmost child of the node at this zipper, diff --git a/test/zipper_test.exs b/test/zipper_test.exs index 81dcf6e3..2359d481 100644 --- a/test/zipper_test.exs +++ b/test/zipper_test.exs @@ -63,14 +63,14 @@ defmodule StylerTest.ZipperTest do describe "down/1" do test "rips and tears the parent node" do - assert [1, 2] |> Zipper.zip() |> Zipper.down() == {1, {[], {[1, 2], nil}, [2]}} - assert {1, 2} |> Zipper.zip() |> Zipper.down() == {1, {[], {{1, 2}, nil}, [2]}} + assert [1, 2] |> Zipper.zip() |> Zipper.down() == {1, %{l: [], r: [2], ptree: {[1, 2], nil}}} + assert {1, 2} |> Zipper.zip() |> Zipper.down() == {1, %{l: [], r: [2], ptree: {{1, 2}, nil}}} assert {:foo, [], [1, 2]} |> Zipper.zip() |> Zipper.down() == - {1, {[], {{:foo, [], [1, 2]}, nil}, [2]}} + {1, %{l: [], r: [2], ptree: {{:foo, [], [1, 2]}, nil}}} assert {{:., [], [:a, :b]}, [], [1, 2]} |> Zipper.zip() |> Zipper.down() == - {{:., [], [:a, :b]}, {[],{{{:., [], [:a, :b]}, [], [1, 2]}, nil}, [1, 2]}} + {{:., [], [:a, :b]}, %{l: [], r: [1, 2], ptree: {{{:., [], [:a, :b]}, [], [1, 2]}, nil}}} end end @@ -471,8 +471,8 @@ defmodule StylerTest.ZipperTest do end test "builds a new root node made of a block" do - assert {42, {[:nope], {{:__block__, _, _}, nil}, []}} = 42 |> Zipper.zip() |> Zipper.insert_left(:nope) - assert {42, {[], {{:__block__, _, _}, nil}, [:nope]}} = 42 |> Zipper.zip() |> Zipper.insert_right(:nope) + assert {42, %{l: [:nope], ptree: {{:__block__, _, _}, nil}}} = 42 |> Zipper.zip() |> Zipper.insert_left(:nope) + assert {42, %{r: [:nope], ptree: {{:__block__, _, _}, nil}}} = 42 |> Zipper.zip() |> Zipper.insert_right(:nope) end end From 9d596b30dec12d63d09118ca207cdeff7b182b76 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Mon, 14 Jul 2025 09:01:58 -0600 Subject: [PATCH 086/145] fix bug in unpiping syntax-sugared non-atom-valued keyword lists closes #236 --- CHANGELOG.md | 4 ++++ lib/style/pipes.ex | 18 ++++++------------ test/style/pipes_test.exs | 4 ++-- 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b591991..5322131f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ they can and will change without that change being reflected in Styler's semanti ## main +### Fixes + +- fix de-sugaring of syntax-sugared keyword lists whose values weren't atoms in map values (#236, h/t @RisPNG) + ## 1.4.2 ### Fixes diff --git a/lib/style/pipes.ex b/lib/style/pipes.ex index dc56ad27..45b35bd8 100644 --- a/lib/style/pipes.ex +++ b/lib/style/pipes.ex @@ -241,19 +241,13 @@ defmodule Styler.Style.Pipes do else # looks like it's just a normal function, so lift the first arg up into a new pipe # `foo(a, ...) |> ...` => `a |> foo(...) |> ...` + # + # If the first arg is a syntax-sugared kwl, we need to manually desugar it arg = - case arg do - # If the first arg is a syntax-sugared kwl, we need to manually desugar it to cover all scenarios - [{{:__block__, bm, _}, {:__block__, _, _}} | _] -> - if bm[:format] == :keyword do - {:__block__, [line: line, closing: [line: line]], [arg]} - else - arg - end - - arg -> - arg - end + with [{{:__block__, bm, _}, _} | _] <- arg, + :keyword <- bm[:format], + do: {:__block__, [line: line, closing: [line: line]], [arg]}, + else: (_ -> arg) {{:|>, [line: line], [arg, {fun, meta, args}]}, nil} end diff --git a/test/style/pipes_test.exs b/test/style/pipes_test.exs index 50284537..98555a7d 100644 --- a/test/style/pipes_test.exs +++ b/test/style/pipes_test.exs @@ -455,8 +455,8 @@ defmodule Styler.Style.PipesTest do end test "writes brackets for unpiped kwl" do - assert_style("foo(kwl: :arg) |> bar()", "[kwl: :arg] |> foo() |> bar()") - assert_style("%{a: foo(a: :b, c: :d) |> bar()}", "%{a: [a: :b, c: :d] |> foo() |> bar()}") + assert_style("foo(kwl: arg) |> bar()", "[kwl: arg] |> foo() |> bar()") + assert_style("%{a: foo(a: b, c: :d) |> bar()}", "%{a: [a: b, c: :d] |> foo() |> bar()}") assert_style("%{a: foo([a: :b, c: :d]) |> bar()}", "%{a: [a: :b, c: :d] |> foo() |> bar()}") end From d0f0c3c4a11645c339747050f13755660c86ee4c Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Mon, 14 Jul 2025 10:29:57 -0600 Subject: [PATCH 087/145] add `minimum_supported_elixir_version` configuration. closes #231 --- .formatter.exs | 2 +- CHANGELOG.md | 4 ++ README.md | 6 +- docs/deprecations.md | 63 +++++++++++++++---- lib/style/deprecations.ex | 8 ++- lib/styler.ex | 2 +- lib/styler/config.ex | 25 ++++++-- test/config_test.exs | 47 ++++++++++++-- test/style/deprecations_test.exs | 9 ++- .../module_directives/alias_lifting_test.exs | 4 +- 10 files changed, 138 insertions(+), 32 deletions(-) diff --git a/.formatter.exs b/.formatter.exs index 77c8bc58..ff4f5be8 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -8,6 +8,6 @@ assert_style: 2 ], plugins: [Styler], - styler: [alias_lifting_exclude: []], + styler: [alias_lifting_exclude: [], minimum_supported_elixir_version: nil], line_length: 122 ] diff --git a/CHANGELOG.md b/CHANGELOG.md index 5322131f..e505fe7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ they can and will change without that change being reflected in Styler's semanti ## main +### Improvements + +- added `:minimum_supported_elixir_version` configuration to better support libraries using Styler (#231, h/t @maennchen) + ### Fixes - fix de-sugaring of syntax-sugared keyword lists whose values weren't atoms in map values (#236, h/t @RisPNG) diff --git a/README.md b/README.md index 78a1ed84..49aeed43 100644 --- a/README.md +++ b/README.md @@ -67,12 +67,14 @@ Styler can be configured in your `.formatter.exs` file [ plugins: [Styler], styler: [ - alias_lifting_exclude: [...] + alias_lifting_exclude: [...], + minimum_supported_elixir_version: "..." ] ] ``` -Styler's only current configuration option is `:alias_lifting_exclude`, which accepts a list of atoms to _not_ lift. See the [Module Directive documentation](docs/module_directives.md#alias-lifting) for more. +* `alias_lifting_exclude`: a list of module names to _not_ lift. See the [Module Directive documentation](docs/module_directives.md#alias-lifting) for more. +* `minimum_supported_elixir_version`: intended for library authors; overrides the Elixir version Styler relies on with respect to some deprecation rewrites. See [Deprecations documentation](docs/deprecations.md#version-configuration) for more. #### No Credo-Style Enable/Disable diff --git a/docs/deprecations.md b/docs/deprecations.md index 45bdbe24..37719f70 100644 --- a/docs/deprecations.md +++ b/docs/deprecations.md @@ -4,26 +4,50 @@ Elixir's built-in formatter now does its own rewrites via the `--migrate` flag, Styler will rewrite deprecations so long as their alternative is available on the given elixir version. In other words, Styler doesn't care what version of Elixir you're using when it applies the ex-1.18 rewrites - all it cares about is that the alternative is valid in your version of elixir. -### elixir `main` +### Version Configuration -https://github.com/elixir-lang/elixir/blob/main/CHANGELOG.md#4-hard-deprecations +While most deprecation rewrites rely on the system's Elixir version, that version can be overridden for some rewrites with the `minimum_supported_elixir_version` configuration. For example, to keep Styler from using rewrites that would be incompatible with Elixir 1.15: -These deprecations will be released with Elixir 1.18 +```elixir +# .formatter.exs +[ + plugins: [Styler], + styler: [ + minimum_supported_elixir_version: "1.15.0" + ] +] +``` -#### `List.zip/1` +Libraries using Styler may be running on a more modern version of Elixir while intending to support older versions. Styler can therefore break a library's minimum supported Elixir version contract when rewriting deprecated code to use more recently added standard library APIs. + +For example, the `to_timeout` rewrite is only valid when running on Elixir 1.17 and greater. If a library supports older versions of Elixir it cannot use that function, and Styler automatically adding that function breaks them. This can be remedied by setting an earlier `minimum_supported_elixir_version`. + +If you want to keep this configuration in sync with your project's mix.exs, consider something like the following: ```elixir -# Before -List.zip(list) -# Styled -Enum.zip(list) +# .formatter.exs +# Parse SemVer minor elixir version from project configuration +# eg `"~> 1.15"` version requirement will yield `"1.15"` +elixir_minor_version = Regex.run(~r/([\d\.]+)/, Mix.Project.config()[:elixir]) + +[ + plugins: [Styler], + styler: [ + # appending `.0` to the minor version gives us a valid SemVer version string. + minimum_supported_elixir_version: "#{elixir_minor_version}.0" + ] +] ``` -#### `unless` +### 1.20 -This is covered by the Elixir Formatter with the `--migrate` flag, but Styler brings the same transformation to codebases on earlier versions of Elixir. +[1.20 Deprecations](https://github.com/elixir-lang/elixir/blob/main/CHANGELOG.md#4-hard-deprecations) -Rewrite `unless x` to `if !x` +No deprecation rewrites have been added to Styler for 1.20 + +### `1.19` + +[1.19 Deprecations](https://github.com/elixir-lang/elixir/blob/v1.19/CHANGELOG.md#4-hard-deprecations) ### Change Struct Updates to Map Updates @@ -40,13 +64,26 @@ Rewrite `unless x` to `if !x` ### 1.18 -None? +#### `List.zip/1` + +```elixir +# Before +List.zip(list) +# Styled +Enum.zip(list) +``` + +#### `unless` + +This is covered by the Elixir Formatter with the `--migrate` flag, but Styler brings the same transformation to codebases on earlier versions of Elixir, and insures future uses are automatically rewritten without relying on the flag. + +Rewrite `unless x` to `if !x` ### 1.17 [1.17 Deprecations](https://hexdocs.pm/elixir/1.17.0/changelog.html#4-hard-deprecations) -- Replace `:timer.units(x)` with the new `to_timeout(unit: x)` for `hours|minutes|seconds` +- Replace `:timer.units(x)` with the new `to_timeout(unit: x)` for `hours|minutes|seconds` (relies on `minimum_supported_elixir_version`) #### Range Matching Without Step diff --git a/lib/style/deprecations.ex b/lib/style/deprecations.ex index 824a88e6..5563db14 100644 --- a/lib/style/deprecations.ex +++ b/lib/style/deprecations.ex @@ -59,9 +59,13 @@ defmodule Styler.Style.Deprecations do end if Version.match?(System.version(), ">= 1.17.0-dev") do + @to_timeout_vsn Version.parse!("1.17.0-dev") for {erl, ex} <- [hours: :hour, minutes: :minute, seconds: :second] do - defp style({{:., _, [{:__block__, _, [:timer]}, unquote(erl)]}, fm, [x]}), - do: {:to_timeout, fm, [[{{:__block__, [format: :keyword, line: fm[:line]], [unquote(ex)]}, x}]]} + defp style({{:., _, [{:__block__, _, [:timer]}, unquote(erl)]}, fm, [x]} = node) do + if Styler.Config.version_compatible?(@to_timeout_vsn), + do: {:to_timeout, fm, [[{{:__block__, [format: :keyword, line: fm[:line]], [unquote(ex)]}, x}]]}, + else: node + end end end diff --git a/lib/styler.ex b/lib/styler.ex index e8c8c749..dc984ba4 100644 --- a/lib/styler.ex +++ b/lib/styler.ex @@ -32,7 +32,7 @@ defmodule Styler do @doc false def style({ast, comments}, file, opts) do on_error = opts[:on_error] || :log - Styler.Config.set(opts) + Styler.Config.initialize(opts) zipper = Zipper.zip(ast) {{ast, _}, comments} = diff --git a/lib/styler/config.ex b/lib/styler/config.ex index 07f3336c..256cc81a 100644 --- a/lib/styler/config.ex +++ b/lib/styler/config.ex @@ -19,14 +19,15 @@ defmodule Styler.Config do Range Record Regex Registry Set Stream String StringIO Supervisor System Task Time Tuple URI Version )a) - def set(config) do + def initialize(config) do :persistent_term.get(@key) :ok rescue - ArgumentError -> set!(config) + ArgumentError -> set(config) end - def set!(config) do + # Public for tests + def set(config) do excludes = config[:alias_lifting_exclude] |> List.wrap() @@ -42,8 +43,16 @@ defmodule Styler.Config do end) |> MapSet.union(@stdlib) + elixir_version = + case config[:minimum_supported_elixir_version] do + vsn when is_binary(vsn) -> Version.parse!(vsn) + nil -> nil + other -> raise ArgumentError, "`:minimum_supported_elixir_version` must be a string, got: #{inspect(other)}" + end + :persistent_term.put(@key, %{ - lifting_excludes: excludes + lifting_excludes: excludes, + minimum_supported_elixir_version: elixir_version }) end @@ -52,4 +61,12 @@ defmodule Styler.Config do |> :persistent_term.get() |> Map.fetch!(key) end + + def version_compatible?(%Version{} = version) do + if minimum_supported_elixir_version = get(:minimum_supported_elixir_version) do + Version.compare(version, minimum_supported_elixir_version) != :gt + else + true + end + end end diff --git a/test/config_test.exs b/test/config_test.exs index 314e72b2..d8110d75 100644 --- a/test/config_test.exs +++ b/test/config_test.exs @@ -3,24 +3,28 @@ defmodule Styler.ConfigTest do import Styler.Config - test "no config is good times" do - assert :ok = set!([]) + setup do + on_exit(fn -> set([]) end) + end + + test "initialize" do + assert :ok = initialize([]) end describe "alias_lifting_exclude" do test "takes singletons atom" do - set!(alias_lifting_exclude: Foo) + set(alias_lifting_exclude: Foo) assert %MapSet{} = excludes = get(:lifting_excludes) assert :Foo in excludes refute Foo in excludes - set!(alias_lifting_exclude: :Foo) + set(alias_lifting_exclude: :Foo) assert %MapSet{} = excludes = get(:lifting_excludes) assert :Foo in excludes end test "list of atoms" do - set!(alias_lifting_exclude: [Foo, :Bar]) + set(alias_lifting_exclude: [Foo, :Bar]) assert %MapSet{} = excludes = get(:lifting_excludes) assert :Foo in excludes refute Foo in excludes @@ -29,8 +33,39 @@ defmodule Styler.ConfigTest do test "raises on non-atom inputs" do assert_raise RuntimeError, ~r"Expected an atom", fn -> - set!(alias_lifting_exclude: ["Bar"]) + set(alias_lifting_exclude: ["Bar"]) end end end + + describe "minimum_supported_elixir_version" do + test "can be nil/unset" do + set(minimum_supported_elixir_version: nil) + assert is_nil(get(:minimum_supported_elixir_version)) + set([]) + assert is_nil(get(:minimum_supported_elixir_version)) + end + + test "parses semvers" do + set(minimum_supported_elixir_version: "1.15.0") + assert get(:minimum_supported_elixir_version) == Version.parse!("1.15.0") + end + + test "kabooms for UX" do + for weird <- ["1.15", "wee"] do + assert_raise Version.InvalidVersionError, fn -> set(minimum_supported_elixir_version: weird) end + end + + assert_raise ArgumentError, ~r/must be a string/, fn -> set(minimum_supported_elixir_version: 1.15) end + end + end + + test "version_compatible?" do + set(minimum_supported_elixir_version: nil) + assert version_compatible?(Version.parse!("100.0.0")) + set(minimum_supported_elixir_version: "1.15.0") + assert version_compatible?(Version.parse!("1.14.0")) + assert version_compatible?(Version.parse!("1.15.0")) + refute version_compatible?(Version.parse!("1.16.0")) + end end diff --git a/test/style/deprecations_test.exs b/test/style/deprecations_test.exs index 0fbd13b7..ce393e3a 100644 --- a/test/style/deprecations_test.exs +++ b/test/style/deprecations_test.exs @@ -138,7 +138,6 @@ defmodule Styler.Style.DeprecationsTest do describe "1.17+" do @describetag skip: Version.match?(System.version(), "< 1.17.0-dev") - test "to_timeout/1 vs :timer.units(x)" do assert_style ":timer.hours(x)", "to_timeout(hour: x)" assert_style ":timer.minutes(x)", "to_timeout(minute: x)" @@ -152,5 +151,13 @@ defmodule Styler.Style.DeprecationsTest do test "combined with to_timeout improvements" do assert_style ":timer.minutes(60 * 4)", "to_timeout(hour: 4)" end + + test "respects :minimum_supported_elixir_version config @ 1.17-dev" do + on_exit(fn -> Styler.Config.set([]) end) + Styler.Config.set(minimum_supported_elixir_version: "1.16.0") + assert_style ":timer.minutes(60 * 4)" + Styler.Config.set(minimum_supported_elixir_version: "1.17.0-dev") + assert_style ":timer.hours(x)", "to_timeout(hour: x)" + end end end diff --git a/test/style/module_directives/alias_lifting_test.exs b/test/style/module_directives/alias_lifting_test.exs index b08befe7..4557876f 100644 --- a/test/style/module_directives/alias_lifting_test.exs +++ b/test/style/module_directives/alias_lifting_test.exs @@ -369,7 +369,7 @@ defmodule Styler.Style.ModuleDirectives.AliasLiftingTest do describe "it doesn't lift" do test "collisions with configured modules" do - Styler.Config.set!(alias_lifting_exclude: ~w(C)a) + Styler.Config.set(alias_lifting_exclude: ~w(C)a) assert_style """ alias Foo.Bar @@ -378,7 +378,7 @@ defmodule Styler.Style.ModuleDirectives.AliasLiftingTest do A.B.C """ - Styler.Config.set!([]) + Styler.Config.set([]) end test "collisions with std lib" do From 54dd92de78c6b1caaea43fbeab38bf770c70c791 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Mon, 14 Jul 2025 10:38:14 -0600 Subject: [PATCH 088/145] Add license preambles to four files --- .formatter.exs | 10 ++++++++++ lib/alias_env.ex | 10 ++++++++++ test/config_test.exs | 10 ++++++++++ test/style_test.exs | 10 ++++++++++ 4 files changed, 40 insertions(+) diff --git a/.formatter.exs b/.formatter.exs index ff4f5be8..4885f08c 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,3 +1,13 @@ +# Copyright 2024 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. You may obtain a copy +# of the License at http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +# OF ANY KIND, either express or implied. See the License for the specific language +# governing permissions and limitations under the License. + [ inputs: [ "{mix,.formatter}.exs", diff --git a/lib/alias_env.ex b/lib/alias_env.ex index c16aea85..6da3615a 100644 --- a/lib/alias_env.ex +++ b/lib/alias_env.ex @@ -1,3 +1,13 @@ +# Copyright 2024 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. You may obtain a copy +# of the License at http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +# OF ANY KIND, either express or implied. See the License for the specific language +# governing permissions and limitations under the License. + defmodule Styler.AliasEnv do @moduledoc """ A datastructure for maintaining something like compiler alias state when traversing AST. diff --git a/test/config_test.exs b/test/config_test.exs index d8110d75..522d972c 100644 --- a/test/config_test.exs +++ b/test/config_test.exs @@ -1,3 +1,13 @@ +# Copyright 2024 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. You may obtain a copy +# of the License at http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +# OF ANY KIND, either express or implied. See the License for the specific language +# governing permissions and limitations under the License. + defmodule Styler.ConfigTest do use ExUnit.Case, async: false diff --git a/test/style_test.exs b/test/style_test.exs index 43bc8b27..e7d074ac 100644 --- a/test/style_test.exs +++ b/test/style_test.exs @@ -1,3 +1,13 @@ +# Copyright 2024 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. You may obtain a copy +# of the License at http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +# OF ANY KIND, either express or implied. See the License for the specific language +# governing permissions and limitations under the License. + defmodule Styler.StyleTest do use ExUnit.Case, async: true From 501ec6d5a9ae4b48594bdbb70512170530f776a9 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Tue, 15 Jul 2025 13:10:52 -0600 Subject: [PATCH 089/145] feat: Apply aliases (#237) Closes #235 --- CHANGELOG.md | 1 + docs/module_directives.md | 26 ++++ lib/alias_env.ex | 87 ++++++++---- lib/style/module_directives.ex | 134 ++++++++++++------ test/alias_env_test.exs | 75 ++++++++++ .../module_directives/alias_lifting_test.exs | 19 --- test/style/module_directives_test.exs | 95 ++++++++++++- 7 files changed, 351 insertions(+), 86 deletions(-) create mode 100644 test/alias_env_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index e505fe7d..16502841 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ they can and will change without that change being reflected in Styler's semanti ### Improvements +- apply aliases to code. if a module is aliased, and then later referenced with its full name, Styler will now shorten it to its alias. (#235, h/t me) - added `:minimum_supported_elixir_version` configuration to better support libraries using Styler (#231, h/t @maennchen) ### Fixes diff --git a/docs/module_directives.md b/docs/module_directives.md index 4bfcd590..cf32f1b9 100644 --- a/docs/module_directives.md +++ b/docs/module_directives.md @@ -189,3 +189,29 @@ You can specify additional modules to exclude from lifting via the `:alias_lifti styler: [alias_lifting_exclude: [:C]], ] ``` + +## Alias Application + +Styler applies aliases in those cases where a developer wrote out a full module name without realizing that the module is already aliased. + +```elixir +# Given +alias A.B +alias A.B.C +alias A.B.C.D, as: X + +A.B.foo() +A.B.C.foo() +A.B.C.D.woo() +C.D.woo() + +# Styled +alias A.B +alias A.B.C +alias A.B.C.D, as: X + +B.foo() +C.foo() +X.woo() +X.woo() +``` diff --git a/lib/alias_env.ex b/lib/alias_env.ex index 6da3615a..23357ab0 100644 --- a/lib/alias_env.ex +++ b/lib/alias_env.ex @@ -14,7 +14,7 @@ defmodule Styler.AliasEnv do Not anywhere as correct as what the compiler gives us, but close enough for open source work. - A alias env is a map from an alias's `as` to its resolution in a context. + An alias env is a map from an alias's `as` to its resolution in a context. Given the ast for @@ -25,40 +25,79 @@ defmodule Styler.AliasEnv do %{:Bar => [:Foo, :Bar]} """ def define(env \\ %{}, ast) - def define(env, asts) when is_list(asts), do: Enum.reduce(asts, env, &define(&2, &1)) + def define(env, {:alias, _, [{:__aliases__, _, aliases}]}), do: define(env, aliases, List.last(aliases)) + def define(env, {:alias, _, [{:__aliases__, _, aliases}, [{_, {:__aliases__, _, [as]}}]]}), do: define(env, aliases, as) + # `alias __MODULE__` or other oddities i'm not bothering to get right + def define(env, {:alias, _, _}), do: env - def define(env, {:alias, _, aliases}) do - case aliases do - [{:__aliases__, _, aliases}] -> define(env, aliases, List.last(aliases)) - [{:__aliases__, _, aliases}, [{_as, {:__aliases__, _, [as]}}]] -> define(env, aliases, as) - # `alias __MODULE__` or other oddities i'm not bothering to get right - _ -> env - end - end + defp define(env, modules, as), do: Map.put(env, as, expand(env, modules)) + + @doc """ + Lengthens an alias to its full name, if its first name is defined in the environment" + + Useful for transforming the ast for code like: - defp define(env, modules, as), do: Map.put(env, as, do_expand(env, modules)) + alias Bar.Baz.Foo #<- given the env with this alias + Foo.Woo.Cool # <- ast + to the ast for code like: + + alias Bar.Baz.Foo + Bar.Baz.Foo.Woo.Cool + """ # no need to traverse ast if there are no aliases - def expand(env, ast) when map_size(env) == 0, do: ast + def expand_ast(env, ast) when map_size(env) == 0, do: ast - def expand(env, ast) do + def expand_ast(env, ast) do Macro.prewalk(ast, fn - {:__aliases__, meta, modules} -> {:__aliases__, meta, do_expand(env, modules)} + {:__aliases__, meta, modules} -> {:__aliases__, meta, expand(env, modules)} ast -> ast end) end - # if the list of modules is itself already aliased, dealias it with the compound alias - # given: - # alias Foo.Bar - # Bar.Baz.Bop.baz() - # - # lifting Bar.Baz.Bop should result in: - # alias Foo.Bar - # alias Foo.Bar.Baz.Bop - # Bop.baz() - defp do_expand(env, [first | rest] = modules) do + @doc """ + Expands modules from env (wow that was helpful). + + Using the examples from `expand_ast`, this works roughly like so: + + > expand(%{Foo: [Bar, Baz, Foo]}, [Foo, Woo, Cool]) + => [Bar, Baz, Foo, Woo, Cool] + > expand(%{}, [No, Alias, For, Me]) + => [No, Alias, For, Me] + """ + def expand(env, [first | rest] = modules) do if dealias = env[first], do: dealias ++ rest, else: modules end + + @doc """ + An inverted AliasEnv is useful for translating a module to its alias, if one existed in the env + + In the case that a module is aliased multiple times, the inverted env will only keep the final alias as lexically sorted + """ + def invert(env) do + # It's a bit of a bummer to do the extra group_by out of caution that this 1-off mistake happens, + # but ultimately we're usually working with a small list so performance costs are negligible + env + |> Enum.group_by(fn {_, v} -> v end, fn {k, _} -> k end) + |> Map.new(fn + {modules, [as]} -> + {modules, as} + + # someone has something goofy going on, aliasing the same module with multiple names + # alias A.B.C + # alias A.B.C, as: Bar + # alias A.B.C, as: Foo + # we'll choose the one that comes last lexically, which will be the alpha-sorted last entry that isn't the default as + # "bug": if the last happens to be `alias A.B.C, as: C`, well, they shouldn't've written such crazy code + {modules, multiple_as} -> + default_as = List.last(modules) + # being clever - rather than rejecting the default up front and doing an extra list-traversal, + # just sort things and if the default comes first, grab the second element + case Enum.sort(multiple_as, :desc) do + [^default_as, last_as | _] -> {modules, last_as} + [last_as | _] -> {modules, last_as} + end + end) + end end diff --git a/lib/style/module_directives.ex b/lib/style/module_directives.ex index b88f3c62..10725310 100644 --- a/lib/style/module_directives.ex +++ b/lib/style/module_directives.ex @@ -152,13 +152,13 @@ defmodule Styler.Style.ModuleDirectives do alias: [], require: [], nondirectives: [], - dealiases: %{}, + alias_env: %{}, attrs: MapSet.new(), attr_lifts: [] } defp lift_module_attrs({node, _, _} = ast, %{attrs: attrs} = acc) do - if Enum.empty?(attrs) do + if MapSet.size(attrs) == 0 do {ast, acc} else use? = node == :use @@ -188,8 +188,8 @@ defmodule Styler.Style.ModuleDirectives do |> Zipper.children() |> Enum.reduce(@acc, fn {:@, _, [{attr_directive, _, _}]} = ast, acc when attr_directive in @attr_directives -> - # attr_directives are moved above aliases, so we need to dealias them - {ast, acc} = acc.dealiases |> AliasEnv.expand(ast) |> lift_module_attrs(acc) + # attr_directives are moved above aliases, so we need to expand them + {ast, acc} = acc.alias_env |> AliasEnv.expand_ast(ast) |> lift_module_attrs(acc) %{acc | attr_directive => [ast | acc[attr_directive]]} {:@, _, [{attr, _, _}]} = ast, acc -> @@ -198,12 +198,12 @@ defmodule Styler.Style.ModuleDirectives do {directive, _, _} = ast, acc when directive in @directives -> {ast, acc} = lift_module_attrs(ast, acc) ast = expand(ast) - # import and used get hoisted above aliases, so need to dealias - ast = if directive in ~w(import use)a, do: AliasEnv.expand(acc.dealiases, ast), else: ast - dealiases = if directive == :alias, do: AliasEnv.define(acc.dealiases, ast), else: acc.dealiases + # import and used get hoisted above aliases, so need to expand them + ast = if directive in ~w(import use)a, do: AliasEnv.expand_ast(acc.alias_env, ast), else: ast + alias_env = if directive == :alias, do: AliasEnv.define(acc.alias_env, ast), else: acc.alias_env # the reverse accounts for `expand` putting things in reading order, whereas we're accumulating in reverse - %{acc | directive => Enum.reverse(ast, acc[directive]), dealiases: dealiases} + %{acc | directive => Enum.reverse(ast, acc[directive]), alias_env: alias_env} ast, acc -> %{acc | nondirectives: [ast | acc.nondirectives]} @@ -213,10 +213,12 @@ defmodule Styler.Style.ModuleDirectives do {:moduledoc, []} -> {:moduledoc, List.wrap(moduledoc)} {:use, uses} -> {:use, uses |> Enum.reverse() |> Style.reset_newlines()} {directive, to_sort} when directive in ~w(behaviour import alias require)a -> {directive, sort(to_sort)} - {:dealiases, d} -> {:dealiases, d} + {:alias_env, d} -> {:alias_env, d} {k, v} -> {k, Enum.reverse(v)} end) + |> redefine_alias_env() |> lift_aliases() + |> apply_aliases() # Not happy with it, but this does the work to move module attribute assignments above the module or quote or whatever # Given that it'll only be run once and not again, i'm okay with it being inefficient @@ -278,42 +280,45 @@ defmodule Styler.Style.ModuleDirectives do end end - defp lift_aliases(%{alias: aliases, require: requires, nondirectives: nondirectives} = acc) do - # we can't use the dealias map built into state as that's what things look like before sorting - # now that we've sorted, it could be different! - dealiases = AliasEnv.define(aliases) - liftable = find_liftable_aliases(requires ++ nondirectives, dealiases) + # alias_env have to be recomputed after we've sorted our `alias` nodes + defp redefine_alias_env(%{alias: aliases} = acc), do: %{acc | alias_env: AliasEnv.define(aliases)} + + defp lift_aliases(%{alias: aliases, require: requires, nondirectives: nondirectives, alias_env: alias_env} = acc) do + liftable = find_liftable_aliases(requires ++ nondirectives, alias_env) if Enum.any?(liftable) do # This is a silly hack that helps comments stay put. # The `cap_line` algo was designed to handle high-line stuff moving up into low line territory, so we set our # new node to have an arbitrarily high line annnnd comments behave! i think. m = [line: 999_999] + aliases_m = [last: m] ++ m aliases = liftable - |> Enum.map(&AliasEnv.expand(dealiases, {:alias, m, [{:__aliases__, [{:last, m} | m], &1}]})) + |> Enum.map(&{:alias, m, [{:__aliases__, aliases_m, AliasEnv.expand(alias_env, &1)}]}) |> Enum.concat(aliases) |> sort() - # lifting could've given us a new order - requires = requires |> do_lift_aliases(liftable) |> sort() - nondirectives = do_lift_aliases(nondirectives, liftable) - %{acc | alias: aliases, require: requires, nondirectives: nondirectives} + # aliases to be lifted have to be applied immediately; yes, these means we're doing multiple apply_alias traversals, + # but we're only doing the duplicate traversals when we're updating the file, so the extra cost of walking + # is negligible vs doing disk I/O + %{acc | alias: aliases} + |> apply_aliases(Map.new(liftable, fn modules -> {modules, List.last(modules)} end)) + |> redefine_alias_env() else acc end end - defp find_liftable_aliases(ast, dealiases) do - excluded = dealiases |> Map.keys() |> Enum.into(Styler.Config.get(:lifting_excludes)) + defp find_liftable_aliases(ast, alias_env) do + excluded = alias_env |> Map.keys() |> Enum.into(Styler.Config.get(:lifting_excludes)) - firsts = MapSet.new(dealiases, fn {_last, [first | _]} -> first end) + firsts = MapSet.new(alias_env, fn {_last, [first | _]} -> first end) ast |> Zipper.zip() # we're reducing a datastructure that looks like - # %{last => {aliases, seen_before?} | :some_collision_probelm} + # %{last => {aliases, seen_before?} | :some_collision_problem} |> Zipper.reduce_while(%{}, fn # we don't want to rewrite alias name `defx Aliases ... do` of these three keywords {{defx, _, args}, _} = zipper, lifts when defx in ~w(defmodule defimpl defprotocol)a -> @@ -328,7 +333,7 @@ defmodule Styler.Style.ModuleDirectives do lifts end - # move the focus to the body block, zkipping over the alias (and the `for` keyword for `defimpl`) + # move the focus to the body block, skipping over the alias (and the `for` keyword for `defimpl`) {:skip, zipper |> Zipper.down() |> Zipper.rightmost() |> Zipper.down() |> Zipper.down(), lifts} {{:quote, _, _}, _} = zipper, lifts -> @@ -340,8 +345,9 @@ defmodule Styler.Style.ModuleDirectives do lifts = cond do # this alias already exists, they just wrote it out fully and are leaving it up to us to shorten it down! - dealiases[last] == aliases -> - Map.put(lifts, last, {aliases, true}) + # we'll get it when we do the apply-aliases scan + alias_env[last] == aliases -> + lifts last in excluded or Enum.any?(aliases, &(not is_atom(&1))) -> lifts @@ -433,27 +439,72 @@ defmodule Styler.Style.ModuleDirectives do |> MapSet.new(fn {_, {aliases, true}} -> aliases end) end - defp do_lift_aliases(ast, to_alias) do + defp apply_aliases(acc) do + apply_aliases(acc, AliasEnv.invert(acc.alias_env)) + end + + defp apply_aliases(acc, inverted_env) when map_size(inverted_env) == 0, do: acc + + defp apply_aliases(%{require: requires, nondirectives: nondirectives, alias_env: alias_env} = acc, inverted_env) do + # applying aliases to requires can change their ordering again + requires = requires |> apply_aliases(inverted_env, alias_env) |> sort() + nondirectives = apply_aliases(nondirectives, inverted_env, alias_env) + %{acc | require: requires, nondirectives: nondirectives} + end + + # applies the aliases withi `to_as` across the given ast + # alias_env is used to expand partial aliases + defp apply_aliases(ast, to_as, alias_env) do ast |> Zipper.zip() - |> Zipper.traverse(fn + |> Zipper.traverse_while(fn {{defx, _, [{:__aliases__, _, _} | _]}, _} = zipper when defx in ~w(defmodule defimpl defprotocol)a -> - # move the focus to the body block, zkipping over the alias (and the `for` keyword for `defimpl`) - zipper |> Zipper.down() |> Zipper.rightmost() |> Zipper.down() |> Zipper.down() |> Zipper.right() - - {{:alias, _, [{:__aliases__, _, [_, _, _ | _] = aliases}]}, _} = zipper -> - # the alias was aliased deeper down. we've lifted that alias to a root, so delete this alias - if aliases in to_alias, - do: Zipper.remove(zipper), - else: zipper + # move the focus to the body block, skipping over the alias (and the `for` keyword for `defimpl`) + zipper = zipper |> Zipper.down() |> Zipper.rightmost() |> Zipper.down() |> Zipper.down() |> Zipper.right() + {:cont, zipper} + + {{:quote, _, _}, _} = zipper -> + {:skip, zipper} + + # apply_aliases is only called on nondirectives (+requires), so this alias must exist within a child node like a def + # if it's within `to_as` then it's an alias that's already defined further up and is thus a duplicate + # either because we lifted and so created a duplicate, or it was just plain ol' user user error. + # either way, we can nix it + {{:alias, _, [{:__aliases__, _, [_ | _] = modules}]}, _} = zipper -> + zipper = if to_as[modules], do: Zipper.remove(zipper), else: zipper + {:cont, zipper} + + # We check even modules of 1 length to catch silly situations like + # alias A.B.C + # alias A.B.C, as: X + # That'll then rename all C to X and C will become unused + {{:__aliases__, meta, [_ | _] = modules}, _} = zipper -> + # if modules |> List.last|> to_string() |> String.ends_with?("Mock") do + # dbg(to_as) + # dbg(alias_env) + # dbg(modules) + # dbg(to_as[modules]) + # dbg(AliasEnv.expand(alias_env, modules)) + # dbg(to_as[AliasEnv.expand(alias_env, modules)]) + # end + zipper = + cond do + # There's an alias for this module - replace it with its `as` + as = to_as[modules] -> Zipper.replace(zipper, {:__aliases__, meta, [as]}) + # There's an alias for this modules expansion - replace it with its `as` + # + # This addresses the following: + # given modules=`C.D`, to_as=`A.B.C => C, A.B.C.D => X`, + # should yield -> `X` because `C.D -> A.B.C.D -> X` + as = to_as[AliasEnv.expand(alias_env, modules)] -> Zipper.replace(zipper, {:__aliases__, meta, [as]}) + true -> zipper + end - {{:__aliases__, meta, [_, _, _ | _] = aliases}, _} = zipper -> - if aliases in to_alias, - do: Zipper.replace(zipper, {:__aliases__, meta, [List.last(aliases)]}), - else: zipper + # minor optimization - don't need to walk the module list! + {:skip, zipper} zipper -> - zipper + {:cont, zipper} end) |> Zipper.node() end @@ -490,7 +541,6 @@ defmodule Styler.Style.ModuleDirectives do |> Style.reset_newlines() end - # TODO investigate removing this in favor of the Style.post_sort_cleanup(node, comments) # "Fixes" the line numbers of nodes who have had their orders changed via sorting or other methods. # This "fix" simply ensures that comments don't get wrecked as part of us moving AST nodes willy-nilly. # diff --git a/test/alias_env_test.exs b/test/alias_env_test.exs new file mode 100644 index 00000000..40218499 --- /dev/null +++ b/test/alias_env_test.exs @@ -0,0 +1,75 @@ +# Copyright 2025 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.AliasEnvTest do + use ExUnit.Case, async: true + + import Styler.AliasEnv + + test "define" do + {:__block__, [], ast} = + quote do + alias A.B + alias C.D, as: X + end + + env = define(ast) + + assert %{B: [:A, :B], X: [:C, :D]} == env + assert %{B: [:M, :N, :B], X: [:C, :D]} == define(env, quote(do: alias(M.N.B))) + end + + test "expand" do + assert expand(%{B: [:A, :B], X: [:C, :D]}, [:B]) == [:A, :B] + assert expand(%{B: [:A, :B], X: [:C, :D]}, [:B, :C, :D]) == [:A, :B, :C, :D] + assert expand(%{B: [:A, :B], X: [:C, :D]}, [:Not, :Present]) == [:Not, :Present] + assert expand(%{}, [:Hi]) == [:Hi] + end + + test "expand_ast" do + {_, _, aliases} = + quote do + alias A.B + alias A.B.C + alias A.B.C.D, as: X + end + + ast = + quote do + A + B + C + X + end + + expected = + quote do + A + A.B + A.B.C + A.B.C.D + end + + assert aliases |> define() |> expand_ast(ast) == expected + end + + test "invert" do + {_, _, aliases} = + quote do + alias A.B + alias A.B.C + alias A.B.C, as: X + alias A.B.C, as: Y + end + + env = define(aliases) + assert invert(env) == %{[:A, :B, :C] => :Y, [:A, :B] => :B} + end +end diff --git a/test/style/module_directives/alias_lifting_test.exs b/test/style/module_directives/alias_lifting_test.exs index 4557876f..2de0724e 100644 --- a/test/style/module_directives/alias_lifting_test.exs +++ b/test/style/module_directives/alias_lifting_test.exs @@ -202,25 +202,6 @@ defmodule Styler.Style.ModuleDirectives.AliasLiftingTest do ) end - test "replaces known aliases" do - assert_style( - """ - alias A.B.C - - A.B.C.foo() - A.B.C.foo() - A.B.C.foo() - """, - """ - alias A.B.C - - C.foo() - C.foo() - C.foo() - """ - ) - end - test "two modules that seem to conflict but don't!" do assert_style( """ diff --git a/test/style/module_directives_test.exs b/test/style/module_directives_test.exs index b65518a5..d8e618f0 100644 --- a/test/style/module_directives_test.exs +++ b/test/style/module_directives_test.exs @@ -407,7 +407,7 @@ defmodule Styler.Style.ModuleDirectivesTest do alias B.B alias D.D - require A.A + require A require A.C @type foo :: :ok @@ -619,4 +619,97 @@ defmodule Styler.Style.ModuleDirectivesTest do ) end end + + describe "apply aliases" do + test "replaces known aliases" do + assert_style( + """ + alias A.B + alias A.B.C + alias A.B.C.D, as: X + + A.B.foo() + A.B.C.foo() + A.B.C.D.woo() + C.D.woo() + """, + """ + alias A.B + alias A.B.C + alias A.B.C.D, as: X + + B.foo() + C.foo() + X.woo() + X.woo() + """ + ) + end + + test "ignores quotes" do + assert_style( + """ + alias A.B.C + + A.B.C + + quote do + A.B.C + end + """, + """ + alias A.B.C + + C + + quote do + A.B.C + end + """ + ) + end + + test "removes embedded duplicate aliases" do + assert_style( + """ + alias A.B + + def foo do + alias A.B + A.B.bar() + end + """, + """ + alias A.B + + def foo do + B.bar() + end + """ + ) + end + + test "forces a single alias" do + assert_style( + """ + alias A.B.C.D.E, as: B + alias A.B.C.D.E, as: C + alias A.B.C.D.E + + B + C + E + """, + """ + alias A.B.C.D.E + alias A.B.C.D.E, as: B + alias A.B.C.D.E, as: C + + C + C + C + """ + ) + end + end end From 0a756d6cf77aea6f95e9fec216e7f3de160aba43 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Tue, 15 Jul 2025 14:09:48 -0600 Subject: [PATCH 090/145] fix: config sorting mangling floating comment blocks. Closes #230 --- CHANGELOG.md | 1 + lib/style.ex | 12 +++++------ test/style/configs_test.exs | 40 +++++++++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16502841..c846a832 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ they can and will change without that change being reflected in Styler's semanti ### Fixes - fix de-sugaring of syntax-sugared keyword lists whose values weren't atoms in map values (#236, h/t @RisPNG) +- fix mix config sorting mangling floating comment blocks in some cases (#230 again, h/t @ryoung786) ## 1.4.2 diff --git a/lib/style.ex b/lib/style.ex index 03b404fa..3f81b196 100644 --- a/lib/style.ex +++ b/lib/style.ex @@ -244,19 +244,19 @@ defmodule Styler.Style do comments |> Enum.reverse() |> comments_for_lines(start_line, last_line, [], []) end - defp comments_for_lines([%{line: line} = comment | rev_comments], start, last, match, acc) do + defp comments_for_lines([%{line: line, previous_eol_count: eol} = comment | rev_comments], start, last, matches, misses) do cond do # after our block - no match - line > last -> comments_for_lines(rev_comments, start, last, match, [comment | acc]) + line > last -> comments_for_lines(rev_comments, start, last, matches, [comment | misses]) # after start, before last -- it's a match! - line >= start -> comments_for_lines(rev_comments, start, last, [comment | match], acc) + line >= start -> comments_for_lines(rev_comments, start, last, [comment | matches], misses) # this is a comment immediately before start, which means it's modifying this block... # we count that as a match, and look above it to see if it's a multiline comment - line == start - 1 -> comments_for_lines(rev_comments, start - 1, last, [comment | match], acc) + line == start - 1 -> comments_for_lines(rev_comments, start - eol, last, [comment | matches], misses) # comment before start - we've thus iterated through all comments which could be in our range - true -> {match, Enum.reverse(rev_comments, [comment | acc])} + true -> {matches, Enum.reverse(rev_comments, [comment | misses])} end end - defp comments_for_lines([], _, _, match, acc), do: {match, acc} + defp comments_for_lines([], _, _, matches, misses), do: {matches, misses} end diff --git a/test/style/configs_test.exs b/test/style/configs_test.exs index 0bb4966e..05704acc 100644 --- a/test/style/configs_test.exs +++ b/test/style/configs_test.exs @@ -389,5 +389,45 @@ defmodule Styler.Style.ConfigsTest do """ ) end + + test "phx config" do + assert_style( + """ + import Config + + config :demo, DemoWeb.Endpoint, + http: [ip: {127, 0, 0, 1}, port: 4000] + + # In order to use HTTPS in development, a self-signed + # + # mix phx.gen.cert + # + # If desired, both `http:` and `https:` keys can be + + # Set a higher stacktrace during development. Avoid configuring such + config :phoenix, :stacktrace_depth, 20 + + # Initialize plugs at runtime for faster development compilation + config :phoenix, :plug_init_mode, :runtime + """, + """ + import Config + + config :demo, DemoWeb.Endpoint, http: [ip: {127, 0, 0, 1}, port: 4000] + + # Initialize plugs at runtime for faster development compilation + config :phoenix, :plug_init_mode, :runtime + + # In order to use HTTPS in development, a self-signed + # + # mix phx.gen.cert + # + # If desired, both `http:` and `https:` keys can be + + # Set a higher stacktrace during development. Avoid configuring such + config :phoenix, :stacktrace_depth, 20 + """ + ) + end end end From e9246fba875b47287562d0cd8530779c415bfaf6 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Tue, 15 Jul 2025 14:18:37 -0600 Subject: [PATCH 091/145] styler:sort sorts struct/map based typespec keys. Closes #213 --- CHANGELOG.md | 1 + lib/style/comment_directives.ex | 5 ++++ test/style/comment_directives_test.exs | 33 ++++++++++++++++++++++++++ 3 files changed, 39 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c846a832..a36598bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ they can and will change without that change being reflected in Styler's semanti - apply aliases to code. if a module is aliased, and then later referenced with its full name, Styler will now shorten it to its alias. (#235, h/t me) - added `:minimum_supported_elixir_version` configuration to better support libraries using Styler (#231, h/t @maennchen) +- `# styler:sort` will now sort keys for struct/map typespecs (#213, h/t @rojnwa) ### Fixes diff --git a/lib/style/comment_directives.ex b/lib/style/comment_directives.ex index 95edec58..333f9a9e 100644 --- a/lib/style/comment_directives.ex +++ b/lib/style/comment_directives.ex @@ -101,6 +101,11 @@ defmodule Styler.Style.CommentDirectives do {{:@, m, [{a, am, [assignment]}]}, comments} end + defp sort({:"::", m, [name, typespec]}, comments) do + {typespec, comments} = sort(typespec, comments) + {{:"::", m, [name, typespec]}, comments} + end + defp sort({key, value}, comments) do {value, comments} = sort(value, comments) {{key, value}, comments} diff --git a/test/style/comment_directives_test.exs b/test/style/comment_directives_test.exs index 68b6f4f9..80cb4348 100644 --- a/test/style/comment_directives_test.exs +++ b/test/style/comment_directives_test.exs @@ -219,6 +219,39 @@ defmodule Styler.Style.CommentDirectivesTest do ) end + test "typespecs" do + assert_style( + """ + # styler:sort + @type t :: %__MODULE__{ + id: Ecto.UUID.t(), + street_address: String.t(), + postal_code: String.t(), + locality: String.t(), + region: String.t(), + country_iso: String.t(), + deleted_at: DateTime.t() | nil, + inserted_at: DateTime.t(), + updated_at: DateTime.t() + } + """, + """ + # styler:sort + @type t :: %__MODULE__{ + country_iso: String.t(), + deleted_at: DateTime.t() | nil, + id: Ecto.UUID.t(), + inserted_at: DateTime.t(), + locality: String.t(), + postal_code: String.t(), + region: String.t(), + street_address: String.t(), + updated_at: DateTime.t() + } + """ + ) + end + test "assignments" do assert_style( """ From 5bcd66687cd86c0575be7ae4ad7887ad60c9f4a7 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Tue, 15 Jul 2025 14:48:38 -0600 Subject: [PATCH 092/145] lift aliases in snippets. closes #189 --- CHANGELOG.md | 1 + lib/style/module_directives.ex | 62 +++++++++++++------ .../module_directives/alias_lifting_test.exs | 15 +++++ test/style/module_directives_test.exs | 2 +- 4 files changed, 59 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a36598bb..51696715 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ they can and will change without that change being reflected in Styler's semanti ### Fixes +- apply alias lifting to snippets with no modules or module directives in them. (#189, @h/t @halfdan) - fix de-sugaring of syntax-sugared keyword lists whose values weren't atoms in map values (#236, h/t @RisPNG) - fix mix config sorting mangling floating comment blocks in some cases (#230 again, h/t @ryoung786) diff --git a/lib/style/module_directives.ex b/lib/style/module_directives.ex index 10725310..8ecf1db6 100644 --- a/lib/style/module_directives.ex +++ b/lib/style/module_directives.ex @@ -50,9 +50,45 @@ defmodule Styler.Style.ModuleDirectives do @attr_directives ~w(moduledoc shortdoc behaviour)a @defstruct ~w(schema embedded_schema defstruct)a + @env %{ + shortdoc: [], + moduledoc: [], + behaviour: [], + use: [], + import: [], + alias: [], + require: [], + nondirectives: [], + alias_env: %{}, + attrs: MapSet.new(), + attr_lifts: [] + } + @moduledoc_false {:@, [line: nil], [{:moduledoc, [line: nil], [{:__block__, [line: nil], [false]}]}]} + # module directives typically doesn't do anything until it sees a module (typical .ex file) or a directive (like a snippet) + # however, if we're in a snippet with no directives we'll never do any work! + # so, we fast-forward the traversal looking for an interesting node. + # if we find one, we'll mark that this file is interesting and proceed as normal from there. + # if we _dont_ find one, then we're likely in a snippet with no typical directives. + # in that case, all that's left is to apply alias lifting and halt. + def run(zipper, %{__MODULE__ => true} = ctx), do: do_run(zipper, ctx) + + def run(zipper, ctx) do + if interesting_zipper = Zipper.find(zipper, &interesting?/1) do + do_run(interesting_zipper, Map.put(ctx, __MODULE__, true)) + else + # there's no defmodules or aliasy things - see if we can do some alias lifting? + case lift_aliases(%{@env | nondirectives: Zipper.children(zipper)}) do + %{alias: []} -> {:halt, zipper, ctx} + %{alias: lifts, nondirectives: children} -> {:halt, Zipper.replace_children(zipper, lifts ++ children), ctx} + end + end + end + + defp interesting?({x, _, _}) when x in [:defmodule, :a | @directives], do: true + defp interesting?(_), do: false - def run({{:defmodule, _, children}, _} = zipper, ctx) do + defp do_run({{:defmodule, _, children}, _} = zipper, ctx) do [name, [{{:__block__, do_meta, [:do]}, _body}]] = children if do_meta[:format] == :keyword do @@ -88,14 +124,14 @@ defmodule Styler.Style.ModuleDirectives do {:skip, zipper, ctx} else - run(body_zipper, ctx) + do_run(body_zipper, ctx) end end end end # Style directives inside of snippets or function defs. - def run({{directive, _, children}, _} = zipper, ctx) when directive in @directives and is_list(children) do + defp do_run({{directive, _, children}, _} = zipper, ctx) when directive in @directives and is_list(children) do # Need to be careful that we aren't getting false positives on variables or fns like `def import(foo)` or `alias = 1` case Style.ensure_block_parent(zipper) do {:ok, zipper} -> {:skip, zipper |> Zipper.up() |> organize_directives(), ctx} @@ -105,7 +141,7 @@ defmodule Styler.Style.ModuleDirectives do end # puts `@derive` before `defstruct` etc, fixing compiler warnings - def run({{:@, _, [{:derive, _, _}]}, _} = zipper, ctx) do + defp do_run({{:@, _, [{:derive, _, _}]}, _} = zipper, ctx) do case Style.ensure_block_parent(zipper) do {:ok, {derive, %{l: left_siblings} = z_meta}} -> previous_defstruct = @@ -130,7 +166,7 @@ defmodule Styler.Style.ModuleDirectives do end end - def run(zipper, ctx), do: {:cont, zipper, ctx} + defp do_run(zipper, ctx), do: {:cont, zipper, ctx} defp moduledoc({:__aliases__, m, aliases}) do name = aliases |> List.last() |> to_string() @@ -143,20 +179,6 @@ defmodule Styler.Style.ModuleDirectives do # a dynamic module name, like `defmodule my_variable do ... end` defp moduledoc(_), do: nil - @acc %{ - shortdoc: [], - moduledoc: [], - behaviour: [], - use: [], - import: [], - alias: [], - require: [], - nondirectives: [], - alias_env: %{}, - attrs: MapSet.new(), - attr_lifts: [] - } - defp lift_module_attrs({node, _, _} = ast, %{attrs: attrs} = acc) do if MapSet.size(attrs) == 0 do {ast, acc} @@ -186,7 +208,7 @@ defmodule Styler.Style.ModuleDirectives do acc = parent |> Zipper.children() - |> Enum.reduce(@acc, fn + |> Enum.reduce(@env, fn {:@, _, [{attr_directive, _, _}]} = ast, acc when attr_directive in @attr_directives -> # attr_directives are moved above aliases, so we need to expand them {ast, acc} = acc.alias_env |> AliasEnv.expand_ast(ast) |> lift_module_attrs(acc) diff --git a/test/style/module_directives/alias_lifting_test.exs b/test/style/module_directives/alias_lifting_test.exs index 2de0724e..e796d47d 100644 --- a/test/style/module_directives/alias_lifting_test.exs +++ b/test/style/module_directives/alias_lifting_test.exs @@ -12,6 +12,21 @@ defmodule Styler.Style.ModuleDirectives.AliasLiftingTest do @moduledoc false use Styler.StyleCase, async: true + test "lifts aliases in snippets" do + assert_style( + """ + A.B.C + A.B.C + """, + """ + alias A.B.C + + C + C + """ + ) + end + test "lifts aliases repeated >=2 times from 3 deep" do assert_style( """ diff --git a/test/style/module_directives_test.exs b/test/style/module_directives_test.exs index d8e618f0..bd18c753 100644 --- a/test/style/module_directives_test.exs +++ b/test/style/module_directives_test.exs @@ -538,7 +538,7 @@ defmodule Styler.Style.ModuleDirectivesTest do assert_style "@derive Inspect" end - test "de-aliases use/behaviour/import/moduledoc" do + test "expands use/behaviour/import/moduledoc aliases" do assert_style( """ defmodule MyModule do From 529d9fe523a26ff122bb61ca49e20e4968ffa96b Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Tue, 15 Jul 2025 14:52:38 -0600 Subject: [PATCH 093/145] inline module attribute --- lib/style/module_directives.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/style/module_directives.ex b/lib/style/module_directives.ex index 8ecf1db6..4a31d1be 100644 --- a/lib/style/module_directives.ex +++ b/lib/style/module_directives.ex @@ -64,7 +64,6 @@ defmodule Styler.Style.ModuleDirectives do attr_lifts: [] } - @moduledoc_false {:@, [line: nil], [{:moduledoc, [line: nil], [{:__block__, [line: nil], [false]}]}]} # module directives typically doesn't do anything until it sees a module (typical .ex file) or a directive (like a snippet) # however, if we're in a snippet with no directives we'll never do any work! # so, we fast-forward the traversal looking for an interesting node. @@ -172,7 +171,8 @@ defmodule Styler.Style.ModuleDirectives do name = aliases |> List.last() |> to_string() # module names ending with these suffixes will not have a default moduledoc appended if !String.ends_with?(name, ~w(Test Mixfile MixProject Controller Endpoint Repo Router Socket View HTML JSON)) do - Style.set_line(@moduledoc_false, m[:line] + 1) + meta = [line: m[:line] + 1] + {:@, meta, [{:moduledoc, meta, [{:__block__, meta, [false]}]}]} end end From 710b5bf8b8ce0c4fd39189b8c47fcff9e7287afa Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Tue, 15 Jul 2025 14:53:45 -0600 Subject: [PATCH 094/145] v1.5.0 --- CHANGELOG.md | 2 ++ README.md | 2 +- mix.exs | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51696715..55064f60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ they can and will change without that change being reflected in Styler's semanti ## main +## 1.5.0 + ### Improvements - apply aliases to code. if a module is aliased, and then later referenced with its full name, Styler will now shorten it to its alias. (#235, h/t me) diff --git a/README.md b/README.md index 49aeed43..c1befff9 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ Add `:styler` as a dependency to your project's `mix.exs`: ```elixir def deps do [ - {:styler, "~> 1.4", only: [:dev, :test], runtime: false}, + {:styler, "~> 1.5", only: [:dev, :test], runtime: false}, ] end ``` diff --git a/mix.exs b/mix.exs index 305656f2..c2cf96df 100644 --- a/mix.exs +++ b/mix.exs @@ -12,7 +12,7 @@ defmodule Styler.MixProject do use Mix.Project # Don't forget to bump the README when doing non-patch version changes - @version "1.4.2" + @version "1.5.0" @url "https://github.com/adobe/elixir-styler" def project do From 1d67a0096ae2b328b005100172e5bdd00c20c730 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Wed, 16 Jul 2025 13:23:13 -0600 Subject: [PATCH 095/145] docs updates --- README.md | 16 ++- docs/comment_directives.md | 6 +- docs/control_flow_macros.md | 4 +- docs/deprecations.md | 24 ++-- docs/general_styles.md | 243 +++++++++++++++++++++++++++++++++++ docs/module_directives.md | 72 ++++++----- docs/pipes.md | 16 +-- docs/styles.md | 248 ++---------------------------------- lib/style.ex | 2 - mix.exs | 5 +- 10 files changed, 326 insertions(+), 310 deletions(-) create mode 100644 docs/general_styles.md diff --git a/README.md b/README.md index c1befff9..c2d4f676 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,9 @@ You can learn more about the history, purpose and implementation of Styler from ## Features -Styler fixes a plethora of elixir style and optimization issues automatically as part of mix format. +Styler fixes a plethora of Elixir style and optimization issues automatically as part of mix format. -[See Styler's documentation on Hex](https://hexdocs.pm/styler/styles.html) for the comprehensive list of its features. +[See Styler's documentation on Hex](https://hexdocs.pm/styler/index.html) for the comprehensive list of its features. The fastest way to see what all it can do you for you is to just try it out in your codebase... but here's a list of a few features to help you decide if you're interested in Styler: @@ -26,9 +26,13 @@ The fastest way to see what all it can do you for you is to just try it out in y ## Who is Styler for? -Styler was designed for a **large team (40+ engineers) working in a single codebase. It helps remove fiddly code review comments and removes failed linter CI slowdowns, helping teams get things done faster. Teams in similar situations might appreciate Styler. +> I'm just excited to be on a team that uses Styler and moves on +> +>\- [Amos King](https://github.com/adkron) -Its automations are also extremely valuable for taming legacy elixir codebases or just refactoring in general. Some of its rewrites have inspired code actions in elixir language servers. +Styler was designed for a large team working in a single codebase (140+ contributors). It helps remove fiddly code review comments and linter CI slowdowns, helping our team get things done faster. Teams in similar situations might appreciate Styler. + +Styler has also been extremely valuable for taming legacy Elixir codebases and general refactoring. Some of its rewrites have inspired code actions in Elixir language servers. Conversely, Styler probably _isn't_ a good match for: @@ -57,7 +61,7 @@ Then add `Styler` as a plugin to your `.formatter.exs` file And that's it! Now when you run `mix format` you'll also get the benefits of Styler's Stylish Stylings. -**Speed**: Expect the first run to take some time as `Styler` rewrites violations of styles and bottlenecks on disk I/O. Subsequent formats formats won't take noticeably more time. +**Speed**: Expect the first run to take some time as `Styler` rewrites violations of styles and bottlenecks on disk I/O. Subsequent formats won't take noticeably more time. ### Configuration @@ -74,7 +78,7 @@ Styler can be configured in your `.formatter.exs` file ``` * `alias_lifting_exclude`: a list of module names to _not_ lift. See the [Module Directive documentation](docs/module_directives.md#alias-lifting) for more. -* `minimum_supported_elixir_version`: intended for library authors; overrides the Elixir version Styler relies on with respect to some deprecation rewrites. See [Deprecations documentation](docs/deprecations.md#version-configuration) for more. +* `minimum_supported_elixir_version`: intended for library authors; overrides the Elixir version Styler relies on with respect to some deprecation rewrites. See [Deprecations documentation](docs/deprecations.md#configuration) for more. #### No Credo-Style Enable/Disable diff --git a/docs/comment_directives.md b/docs/comment_directives.md index 41dc0a63..830c1acc 100644 --- a/docs/comment_directives.md +++ b/docs/comment_directives.md @@ -1,10 +1,8 @@ -## Comment Directives - Comment Directives are a Styler feature that let you instruct Styler to do maintain additional formatting via comments. The plural in the name is optimistic as there's currently only one, but who knows -### `# styler:sort` +## `# styler:sort` Styler can keep static values sorted for your team as part of its formatting pass. To instruct it to do so, replace any `# Please keep this list sorted!` notes you wrote to your teammates with `# styler:sort` @@ -24,7 +22,7 @@ Styler will apply those sorts when they're on the righthand side fo the followin - assignments (eg `x = ~w(a list again)`) - `defstruct` -#### Examples +### Examples **Before** diff --git a/docs/control_flow_macros.md b/docs/control_flow_macros.md index 70a85460..2aa7e249 100644 --- a/docs/control_flow_macros.md +++ b/docs/control_flow_macros.md @@ -46,7 +46,9 @@ case some_http_call() do end ``` -## `if` and `unless` +--------------------------------- + +## `if`/`unless` Styler removes `else: nil` clauses: diff --git a/docs/deprecations.md b/docs/deprecations.md index 37719f70..2741a863 100644 --- a/docs/deprecations.md +++ b/docs/deprecations.md @@ -1,10 +1,8 @@ -## Elixir Deprecation Rewrites - Elixir's built-in formatter now does its own rewrites via the `--migrate` flag, but doesn't quite cover every possible automated rewrite on the hard deprecations list. Styler tries to cover the rest! Styler will rewrite deprecations so long as their alternative is available on the given elixir version. In other words, Styler doesn't care what version of Elixir you're using when it applies the ex-1.18 rewrites - all it cares about is that the alternative is valid in your version of elixir. -### Version Configuration +## Configuration While most deprecation rewrites rely on the system's Elixir version, that version can be overridden for some rewrites with the `minimum_supported_elixir_version` configuration. For example, to keep Styler from using rewrites that would be incompatible with Elixir 1.15: @@ -39,13 +37,13 @@ elixir_minor_version = Regex.run(~r/([\d\.]+)/, Mix.Project.config()[:elixir]) ] ``` -### 1.20 +## 1.20 [1.20 Deprecations](https://github.com/elixir-lang/elixir/blob/main/CHANGELOG.md#4-hard-deprecations) No deprecation rewrites have been added to Styler for 1.20 -### `1.19` +## 1.19 [1.19 Deprecations](https://github.com/elixir-lang/elixir/blob/v1.19/CHANGELOG.md#4-hard-deprecations) @@ -62,9 +60,9 @@ No deprecation rewrites have been added to Styler for 1.20 **WARNING** Double check your diffs to make sure your variable is pattern matching against the same struct if you want to harness 1.19's type checking features. -### 1.18 +## 1.18 -#### `List.zip/1` +### `List.zip/1` ```elixir # Before @@ -73,19 +71,19 @@ List.zip(list) Enum.zip(list) ``` -#### `unless` +### `unless` This is covered by the Elixir Formatter with the `--migrate` flag, but Styler brings the same transformation to codebases on earlier versions of Elixir, and insures future uses are automatically rewritten without relying on the flag. Rewrite `unless x` to `if !x` -### 1.17 +## 1.17 [1.17 Deprecations](https://hexdocs.pm/elixir/1.17.0/changelog.html#4-hard-deprecations) - Replace `:timer.units(x)` with the new `to_timeout(unit: x)` for `hours|minutes|seconds` (relies on `minimum_supported_elixir_version`) -#### Range Matching Without Step +### Range Matching Without Step ```elixir # Before @@ -99,11 +97,11 @@ def foo(x..y), do: :ok def foo(x..y//_), do: :ok ``` -### 1.16 +## 1.16 [1.16 Deprecations](https://hexdocs.pm/elixir/1.16.0/changelog.html#4-hard-deprecations) -#### `File.stream!/3` `:line` and `:bytes` deprecation +### `File.stream!/3` `:line` and `:bytes` deprecation ```elixir # Before @@ -128,7 +126,7 @@ Date.range(~D[2000-01-01], ~D[1999-01-01]) Date.range(~D[2000-01-01], ~D[1999-01-01], -1) ``` -### 1.15 +## 1.15 [1.15 Deprecations](https://hexdocs.pm/elixir/1.15.0/changelog.html#4-hard-deprecations) diff --git a/docs/general_styles.md b/docs/general_styles.md new file mode 100644 index 00000000..e8fde393 --- /dev/null +++ b/docs/general_styles.md @@ -0,0 +1,243 @@ +# General Styles + +_(a.k.a. 1-1 or "Single Node" styles)_ + +Function Performance & Readability Optimizations + +Optimizing for either performance or readability, probably both! +These apply to the piped versions as well + +## Strings to Sigils + +Rewrites strings with 4 or more escaped quotes to string sigils with an alternative delimiter. +The delimiter will be one of `" ( { | [ ' < /`, chosen by which would require the fewest escapes, and otherwise preferred in the order listed. + +```elixir +# Before +"{\"errors\":[\"Not Authorized\"]}" +# Styled +~s({"errors":["Not Authorized"]}) +``` + +## Large Base 10 Numbers + +Style base 10 numbers with 5 or more digits to have a `_` every three digits. +Formatter already does this except it doesn't rewrite "typos" like `100_000_0`. + +If you're concerned that this breaks your team's formatting for things like "cents" (like "$100" being written as `100_00`), +consider using a library made for denoting currencies rather than raw elixir integers. + +| Before | After | +|--------|-------| +| `10000 ` | `10_000`| +| `1_0_0_0_0` | `10_000` (elixir's formatter leaves the former as-is)| +| `-543213 ` | `-543_213`| +| `123456789 ` | `123_456_789`| +| `55333.22 ` | `55_333.22`| +| `-123456728.0001 ` | `-123_456_728.0001`| + +## `Enum.into` -> `X.new` + +This rewrite is applied when the collectable is a new map, keyword list, or mapset via `Enum.into/2,3`. + +This is an improvement for the reader, who gets a more natural language expression: "make a new map from enum" vs "enumerate enum and collect its elements into a new map" + +Note that all of the examples below also apply to pipes (`enum |> Enum.into(...)`) + +| Before | After | +|--------|-------| +| `Enum.into(enum, %{})` | `Map.new(enum)`| +| `Enum.into(enum, Map.new())` | `Map.new(enum)`| +| `Enum.into(enum, Keyword.new())` | `Keyword.new(enum)`| +| `Enum.into(enum, MapSet.new())` | `MapSet.new(enum)`| +| `Enum.into(enum, %{}, fn x -> {x, x} end)` | `Map.new(enum, fn x -> {x, x} end)`| +| `Enum.into(enum, [])` | `Enum.to_list(enum)` | +| `Enum.into(enum, [], mapper)` | `Enum.map(enum, mapper)`| + +## Map/Keyword.merge w/ single key literal -> X.put + +`Keyword.merge` and `Map.merge` called with a literal map or keyword argument with a single key are rewritten to the equivalent `put`, a cognitively simpler function. + +```elixir +# Before +Keyword.merge(kw, [key: :value]) +# Styled +Keyword.put(kw, :key, :value) + +# Before +Map.merge(map, %{key: :value}) +# Styled +Map.put(map, :key, :value) + +# Before +Map.merge(map, %{key => value}) +# Styled +Map.put(map, key, value) + +# Before +map |> Map.merge(%{key: value}) |> foo() +# Styled +map |> Map.put(:key, value) |> foo() +``` + +## Map/Keyword.drop w/ single key -> X.delete + +In the same vein as the `merge` style above, `[Map|Keyword].drop/2` with a single key to drop are rewritten to use `delete/2` +```elixir +# Before +Map.drop(map, [key]) +# Styled +Map.delete(map, key) + +# Before +Keyword.drop(kw, [key]) +# Styled +Keyword.delete(kw, key) +``` + +## `Enum.reverse/1` and concatenation -> `Enum.reverse/2` + +`Enum.reverse/2` optimizes a two-step reverse and concatenation into a single step. + +```elixir +# Before +Enum.reverse(foo) ++ bar +# Styled +Enum.reverse(foo, bar) + +# Before +baz |> Enum.reverse() |> Enum.concat(bop) +# Styled +Enum.reverse(baz, bop) +``` + +## `Timex.now/0` -> `DateTime.utc_now/0` + +Timex certainly has its uses, but knowing what stdlib date/time struct is returned by `now/0` is a bit difficult! + +We prefer calling the actual function rather than its rename in Timex, helping the reader by being more explicit. + +This also hews to our internal styleguide's "Don't make one-line helper functions" guidance. + +## `DateModule.compare/2` -> `DateModule.[before?|after?]` + +Again, the goal is readability and maintainability. `before?/2` and `after?/2` were implemented long after `compare/2`, +so it's not unusual that a codebase needs a lot of refactoring to be brought up to date with these new functions. +That's where Styler comes in! + +The examples below use `DateTime.compare/2`, but the same is also done for `NaiveDateTime|Time|Date.compare/2` + +```elixir +# Before +DateTime.compare(start, end_date) == :gt +# Styled +DateTime.after?(start, end_date) + +# Before +DateTime.compare(start, end_date) == :lt +# Styled +DateTime.before?(start, end_date) +``` + +## Implicit `try` + +Styler will rewrite functions whose entire body is a try/do to instead use the implicit try syntax, per Credo's `Credo.Check.Readability.PreferImplicitTry` + +```elixir +# before +def foo do + try do + throw_ball() + catch + :ball -> :caught + end +end + +# Styled: +def foo do + throw_ball() +catch + :ball -> :caught +end +``` + +## Remove parenthesis from 0-arity function & macro definitions + +The author of the library disagrees with this style convention :) BUT, the wonderful thing about Styler is it lets you write code how _you_ want to, while normalizing it for reading for your entire team. The most important thing is not having to think about the style, and instead focus on what you're trying to achieve. + +```elixir +# Before +def foo() do +defp foo() do +defmacro foo() do +defmacrop foo() do + +# Styled +def foo do +defp foo do +defmacro foo do +defmacrop foo do +``` + +## Variable matching on the right + +```elixir +# Before +case foo do + bar = %{baz: baz? = true} -> :baz? + opts = [[a = %{}] | _] -> a +end +# Styled: +case foo do + %{baz: true = baz?} = bar -> :baz? + [[%{} = a] | _] = opts -> a +end + +# Before +with {:ok, result = %{}} <- foo, do: result +# Styled +with {:ok, %{} = result} <- foo, do: result + +# Before +def foo(bar = %{baz: baz? = true}, opts = [[a = %{}] | _]), do: :ok +# Styled +def foo(%{baz: true = baz?} = bar, [[%{} = a] | _] = opts), do: :ok +``` + +## Drops superfluous `= _` in pattern matching + +```elixir +# Before +def foo(_ = bar), do: bar +# Styled +def foo(bar), do: bar + +# Before +case foo do + _ = bar -> :ok +end +# Styled +case foo do + bar -> :ok +end +``` + +## Shrink Function Definitions to One Line When Possible + +```elixir +# Before + +def save( + # Socket comment + %Socket{assigns: %{user: user, live_action: :new}} = initial_socket, + # Params comment + params + ), + do: :ok + +# Styled + +# Socket comment +# Params comment +def save(%Socket{assigns: %{user: user, live_action: :new}} = initial_socket, params), do: :ok +``` diff --git a/docs/module_directives.md b/docs/module_directives.md index cf32f1b9..150b6692 100644 --- a/docs/module_directives.md +++ b/docs/module_directives.md @@ -1,38 +1,3 @@ -## Adds Moduledoc - -Adds `@moduledoc false` to modules without a moduledoc unless the module's name ends with one of the following: - -* `Test` -* `Mixfile` -* `MixProject` -* `Controller` -* `Endpoint` -* `Repo` -* `Router` -* `Socket` -* `View` -* `HTML` -* `JSON` - -## Directive Expansion - -Expands `Module.{SubmoduleA, SubmoduleB}` to their explicit forms for ease of searching. - -```elixir -# Before -import Foo.{Bar, Baz, Bop} -alias Foo.{Bar, Baz.A, Bop} - -# After -import Foo.Bar -import Foo.Baz -import Foo.Bop - -alias Foo.Bar -alias Foo.Baz.A -alias Foo.Bop -``` - ## Directive Organization Modules directives are sorted into the following order: @@ -142,6 +107,25 @@ import Foo.Bar alias Foo.Bar ``` +## Directive Expansion + +Expands `Module.{SubmoduleA, SubmoduleB}` to their explicit forms for ease of searching. + +```elixir +# Before +import Foo.{Bar, Baz, Bop} +alias Foo.{Bar, Baz.A, Bop} + +# After +import Foo.Bar +import Foo.Baz +import Foo.Bop + +alias Foo.Bar +alias Foo.Baz.A +alias Foo.Bop +``` + ## Alias Lifting When a module with three parts is referenced two or more times, styler creates a new alias for that module and uses it. @@ -215,3 +199,21 @@ C.foo() X.woo() X.woo() ``` + +## Adds Moduledoc false + +Adds `@moduledoc false` to modules without a moduledoc. This can be a helpful callout to developers doing self review or code review that they failed to provide a moduledoc, something that's otherwise easily forgotten. + +This Style is not applied if the module's name ends with one of the following (this list was inherited from Credo): + +* `Test` +* `Mixfile` +* `MixProject` +* `Controller` +* `Endpoint` +* `Repo` +* `Router` +* `Socket` +* `View` +* `HTML` +* `JSON` diff --git a/docs/pipes.md b/docs/pipes.md index 8d800984..d00e83e5 100644 --- a/docs/pipes.md +++ b/docs/pipes.md @@ -1,6 +1,4 @@ -## Pipe Chains - -### Pipe Start +## Pipe Start Styler will ensure that the start of a pipechain is a 0-arity function, a raw value, or a variable. @@ -38,7 +36,7 @@ if_result |> IO.inspect() ``` -### Add parenthesis to function calls in pipes +## Add parenthesis to function calls in pipes ```elixir a |> b |> c |> d @@ -46,7 +44,7 @@ a |> b |> c |> d a |> b() |> c() |> d() ``` -### Remove Unnecessary `then/2` +## Remove Unnecessary `then/2` When the piped argument is being passed as the first argument to the inner function, there's no need for `then/2`. @@ -58,7 +56,7 @@ a |> f(...) |> b() - add parens to function calls `|> fun |>` => `|> fun() |>` -### Add `then/2` when defining and calling anonymous functions in pipes +## Add `then/2` when defining and calling anonymous functions in pipes ```elixir a |> (fn x -> x end).() |> c() @@ -66,7 +64,7 @@ a |> (fn x -> x end).() |> c() a |> then(fn x -> x end) |> c() ``` -### Piped function optimizations +## Piped function optimizations Two function calls into one! Fewer steps is always nice. @@ -107,7 +105,7 @@ a |> b() |> Enum.each(fun) a |> b() |> Enum.each(fun) ``` -### Unpiping Single Pipes +## Unpiping Single Pipes Styler rewrites pipechains with a single pipe to be function calls. Notably, this rule combined with the optimizations rewrites above means some chains with more than one pipe will also become function calls. @@ -121,7 +119,7 @@ map = a |> Enum.map(mapper) |> Map.new() map = Map.new(a, mapper) ``` -### Pipe-ify +## Pipe-ify If the first argument to a function call is a pipe, Styler makes the function call the final pipe of the chain. diff --git a/docs/styles.md b/docs/styles.md index 10769d3e..1f5338e8 100644 --- a/docs/styles.md +++ b/docs/styles.md @@ -1,241 +1,13 @@ -# Simple (Single Node) Styles +# Style Table of Contents -Function Performance & Readability Optimizations +Styler performs myriad rewrites, logically broken apart into the following groups: -Optimizing for either performance or readability, probably both! -These apply to the piped versions as well +- [Comment Directives](./comment_directives.md): leave comments to Styler instructing it to perform a task +- [Control Flow Macros](./control_flow_macros.md): styles modifying `case`, `if`, `unless`, `cond` and `with` statements +- [Elixir Deprecations](./deprecations.md): Styles which automate the replacement or updating of code deprecated by new Elixir releases +- [General Styles](./styles.md): general simple 1-1 rewrites that require a minimum amount of awareness of the AST +- [Mix Configs](./mix_configs.md): Styler applies order to chaos by organizing mix `config ...` stanzas +- [Module Directives](./module_directives.md): Styles for `alias`, `use`, `import`, `require`, as well as alias lifting and alias application. +- [Pipes](./pipes.md) styles for the famous Elixir pipe `|>`, including optimizations for piping standard library functions -## Strings to Sigils - -Rewrites strings with 4 or more escaped quotes to string sigils with an alternative delimiter. -The delimiter will be one of `" ( { | [ ' < /`, chosen by which would require the fewest escapes, and otherwise preferred in the order listed. - -```elixir -# Before -"{\"errors\":[\"Not Authorized\"]}" -# Styled -~s({"errors":["Not Authorized"]}) -``` - -## Large Base 10 Numbers - -Style base 10 numbers with 5 or more digits to have a `_` every three digits. -Formatter already does this except it doesn't rewrite "typos" like `100_000_0`. - -If you're concerned that this breaks your team's formatting for things like "cents" (like "$100" being written as `100_00`), -consider using a library made for denoting currencies rather than raw elixir integers. - -| Before | After | -|--------|-------| -| `10000 ` | `10_000`| -| `1_0_0_0_0` | `10_000` (elixir's formatter leaves the former as-is)| -| `-543213 ` | `-543_213`| -| `123456789 ` | `123_456_789`| -| `55333.22 ` | `55_333.22`| -| `-123456728.0001 ` | `-123_456_728.0001`| - -## `Enum.into` -> `X.new` - -This rewrite is applied when the collectable is a new map, keyword list, or mapset via `Enum.into/2,3`. - -This is an improvement for the reader, who gets a more natural language expression: "make a new map from enum" vs "enumerate enum and collect its elements into a new map" - -Note that all of the examples below also apply to pipes (`enum |> Enum.into(...)`) - -| Before | After | -|--------|-------| -| `Enum.into(enum, %{})` | `Map.new(enum)`| -| `Enum.into(enum, Map.new())` | `Map.new(enum)`| -| `Enum.into(enum, Keyword.new())` | `Keyword.new(enum)`| -| `Enum.into(enum, MapSet.new())` | `MapSet.new(enum)`| -| `Enum.into(enum, %{}, fn x -> {x, x} end)` | `Map.new(enum, fn x -> {x, x} end)`| -| `Enum.into(enum, [])` | `Enum.to_list(enum)` | -| `Enum.into(enum, [], mapper)` | `Enum.map(enum, mapper)`| - -## Map/Keyword.merge w/ single key literal -> X.put - -`Keyword.merge` and `Map.merge` called with a literal map or keyword argument with a single key are rewritten to the equivalent `put`, a cognitively simpler function. - -```elixir -# Before -Keyword.merge(kw, [key: :value]) -# Styled -Keyword.put(kw, :key, :value) - -# Before -Map.merge(map, %{key: :value}) -# Styled -Map.put(map, :key, :value) - -# Before -Map.merge(map, %{key => value}) -# Styled -Map.put(map, key, value) - -# Before -map |> Map.merge(%{key: value}) |> foo() -# Styled -map |> Map.put(:key, value) |> foo() -``` - -## Map/Keyword.drop w/ single key -> X.delete - -In the same vein as the `merge` style above, `[Map|Keyword].drop/2` with a single key to drop are rewritten to use `delete/2` -```elixir -# Before -Map.drop(map, [key]) -# Styled -Map.delete(map, key) - -# Before -Keyword.drop(kw, [key]) -# Styled -Keyword.delete(kw, key) -``` - -## `Enum.reverse/1` and concatenation -> `Enum.reverse/2` - -`Enum.reverse/2` optimizes a two-step reverse and concatenation into a single step. - -```elixir -# Before -Enum.reverse(foo) ++ bar -# Styled -Enum.reverse(foo, bar) - -# Before -baz |> Enum.reverse() |> Enum.concat(bop) -# Styled -Enum.reverse(baz, bop) -``` - -## `Timex.now/0` -> `DateTime.utc_now/0` - -Timex certainly has its uses, but knowing what stdlib date/time struct is returned by `now/0` is a bit difficult! - -We prefer calling the actual function rather than its rename in Timex, helping the reader by being more explicit. - -This also hews to our internal styleguide's "Don't make one-line helper functions" guidance. - -## `DateModule.compare/2` -> `DateModule.[before?|after?]` - -Again, the goal is readability and maintainability. `before?/2` and `after?/2` were implemented long after `compare/2`, -so it's not unusual that a codebase needs a lot of refactoring to be brought up to date with these new functions. -That's where Styler comes in! - -The examples below use `DateTime.compare/2`, but the same is also done for `NaiveDateTime|Time|Date.compare/2` - -```elixir -# Before -DateTime.compare(start, end_date) == :gt -# Styled -DateTime.after?(start, end_date) - -# Before -DateTime.compare(start, end_date) == :lt -# Styled -DateTime.before?(start, end_date) -``` - -## Implicit `try` - -Styler will rewrite functions whose entire body is a try/do to instead use the implicit try syntax, per Credo's `Credo.Check.Readability.PreferImplicitTry` - -```elixir -# before -def foo do - try do - throw_ball() - catch - :ball -> :caught - end -end - -# Styled: -def foo do - throw_ball() -catch - :ball -> :caught -end -``` - -## Remove parenthesis from 0-arity function & macro definitions - -The author of the library disagrees with this style convention :) BUT, the wonderful thing about Styler is it lets you write code how _you_ want to, while normalizing it for reading for your entire team. The most important thing is not having to think about the style, and instead focus on what you're trying to achieve. - -```elixir -# Before -def foo() do -defp foo() do -defmacro foo() do -defmacrop foo() do - -# Styled -def foo do -defp foo do -defmacro foo do -defmacrop foo do -``` - -## Variable matching on the right - -```elixir -# Before -case foo do - bar = %{baz: baz? = true} -> :baz? - opts = [[a = %{}] | _] -> a -end -# Styled: -case foo do - %{baz: true = baz?} = bar -> :baz? - [[%{} = a] | _] = opts -> a -end - -# Before -with {:ok, result = %{}} <- foo, do: result -# Styled -with {:ok, %{} = result} <- foo, do: result - -# Before -def foo(bar = %{baz: baz? = true}, opts = [[a = %{}] | _]), do: :ok -# Styled -def foo(%{baz: true = baz?} = bar, [[%{} = a] | _] = opts), do: :ok -``` - -## Drops superfluous `= _` in pattern matching - -```elixir -# Before -def foo(_ = bar), do: bar -# Styled -def foo(bar), do: bar - -# Before -case foo do - _ = bar -> :ok -end -# Styled -case foo do - bar -> :ok -end -``` - -## Shrink Function Definitions to One Line When Possible - -```elixir -# Before - -def save( - # Socket comment - %Socket{assigns: %{user: user, live_action: :new}} = initial_socket, - # Params comment - params - ), - do: :ok - -# Styled - -# Socket comment -# Params comment -def save(%Socket{assigns: %{user: user, live_action: :new}} = initial_socket, params), do: :ok -``` +Finally, if you're using Credo [see our documentation](./credo.md) about rules that can be disabled in Credo because Styler automatically enforces them for you, saving a modicum of CI time. diff --git a/lib/style.ex b/lib/style.ex index 3f81b196..63e5d815 100644 --- a/lib/style.ex +++ b/lib/style.ex @@ -194,8 +194,6 @@ defmodule Styler.Style do end @doc "Sets the nodes' meta line and comments' line numbers to fit the ordering of the nodes list." - # TODO this doesn't grab comments which are floating as their own paragrpah, unconnected to a node - # they'll just be left floating where they were, then mangled with the re-ordered comments.. def order_line_meta_and_comments(nodes, comments, first_line) do {nodes, shifted_comments, comments, _line} = Enum.reduce(nodes, {[], [], comments, first_line}, fn node, {n_acc, c_acc, comments, move_to_line} -> diff --git a/mix.exs b/mix.exs index c2cf96df..5184c853 100644 --- a/mix.exs +++ b/mix.exs @@ -59,12 +59,13 @@ defmodule Styler.MixProject do source_ref: "v#{@version}", source_url: @url, groups_for_extras: [ - Rewrites: ~r/docs/ + Features: ~r/docs/ ], extra_section: "Docs", extras: [ "CHANGELOG.md": [title: "Changelog"], - "docs/styles.md": [title: "Basic Styles"], + "docs/styles.md": [title: "Styles/Features Table of Contents"], + "docs/general_styles.md": [title: "General Styles"], "docs/deprecations.md": [title: "Deprecated Elixirisms"], "docs/pipes.md": [title: "Pipe Chains"], "docs/control_flow_macros.md": [title: "Control Flow Macros (if, case, ...)"], From 45fec17731d6fbf71f199c4acae26d17abfd2f88 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Wed, 16 Jul 2025 14:03:42 -0600 Subject: [PATCH 096/145] more docs --- README.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index c2d4f676..13f7db2f 100644 --- a/README.md +++ b/README.md @@ -13,16 +13,18 @@ You can learn more about the history, purpose and implementation of Styler from Styler fixes a plethora of Elixir style and optimization issues automatically as part of mix format. -[See Styler's documentation on Hex](https://hexdocs.pm/styler/index.html) for the comprehensive list of its features. +[See Styler's documentation on Hex](https://hexdocs.pm/styler/styles.html) for the comprehensive list of its features. The fastest way to see what all it can do you for you is to just try it out in your codebase... but here's a list of a few features to help you decide if you're interested in Styler: -- sorts and organizes `import`/`alias`/`require` and other [module directives](docs/module_directives.md) -- keeps lists, sigils, and even arbitrary code sorted with the `# styler:sort` [comment directive](docs/comment_directives.md) -- automatically creates aliases for repeatedly referenced modules names ([_"alias lifting"_](docs/module_directives.md#alias-lifting)) -- optimizes pipe chains for [readability and performance](docs/pipes.md) -- rewrites strings as sigils when it results in fewer escapes -- auto-fixes [many credo rules](docs/credo.md), meaning you can spend less time fighting with CI +- sorts and organizes `import`,`alias`,`require` and other module directives +- automatically creates aliases for repeatedly referenced modules names (_"alias lifting"_) and makes sure aliases you've defined are being used +- keeps lists, sigils, and even arbitrary code sorted with the `# styler:sort` comment directive +- optimizes pipe chains for readability and performance +- rewrites deprecated Elixir standard library code, speeding adoption of new releases +- auto-fixes many credo rules, meaning you can spend less time fighting with CI + +[Here's another link to features Table of Contents for you](https://hexdocs.pm/styler/styles.html) ## Who is Styler for? From 20954a400c1cea962d258a9442406c436e2c9007 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Thu, 17 Jul 2025 10:10:39 -0600 Subject: [PATCH 097/145] ahem. nothing to see here. --- lib/style/module_directives.ex | 8 -------- 1 file changed, 8 deletions(-) diff --git a/lib/style/module_directives.ex b/lib/style/module_directives.ex index 4a31d1be..c8f1b782 100644 --- a/lib/style/module_directives.ex +++ b/lib/style/module_directives.ex @@ -501,14 +501,6 @@ defmodule Styler.Style.ModuleDirectives do # alias A.B.C, as: X # That'll then rename all C to X and C will become unused {{:__aliases__, meta, [_ | _] = modules}, _} = zipper -> - # if modules |> List.last|> to_string() |> String.ends_with?("Mock") do - # dbg(to_as) - # dbg(alias_env) - # dbg(modules) - # dbg(to_as[modules]) - # dbg(AliasEnv.expand(alias_env, modules)) - # dbg(to_as[AliasEnv.expand(alias_env, modules)]) - # end zipper = cond do # There's an alias for this module - replace it with its `as` From be66c309ecf83bd4e453fdbc14df6ea9521f0ae7 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Fri, 18 Jul 2025 09:00:31 -0600 Subject: [PATCH 098/145] fix a -> @ typo in module directive "interesting?" check --- lib/style/module_directives.ex | 2 +- test/style/module_directives_test.exs | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/lib/style/module_directives.ex b/lib/style/module_directives.ex index c8f1b782..1e237e25 100644 --- a/lib/style/module_directives.ex +++ b/lib/style/module_directives.ex @@ -84,7 +84,7 @@ defmodule Styler.Style.ModuleDirectives do end end - defp interesting?({x, _, _}) when x in [:defmodule, :a | @directives], do: true + defp interesting?({x, _, _}) when x in [:defmodule, :@ | @directives], do: true defp interesting?(_), do: false defp do_run({{:defmodule, _, children}, _} = zipper, ctx) do diff --git a/test/style/module_directives_test.exs b/test/style/module_directives_test.exs index bd18c753..5d2e0b4f 100644 --- a/test/style/module_directives_test.exs +++ b/test/style/module_directives_test.exs @@ -536,6 +536,21 @@ defmodule Styler.Style.ModuleDirectivesTest do ) assert_style "@derive Inspect" + + assert_style(""" + defstruct [:a] + # comment for foo + def foo, do: :ok + @derive Inspect + @derive {Foo, bar: :baz} + """, + """ + @derive Inspect + @derive {Foo, bar: :baz} + defstruct [:a] + # comment for foo + def foo, do: :ok + """) end test "expands use/behaviour/import/moduledoc aliases" do From 5a01051c846469e963144e5131ea450f3abc08d7 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Fri, 18 Jul 2025 09:12:59 -0600 Subject: [PATCH 099/145] fix: comment mangling when lifting aliases in snippets. closes #239 --- CHANGELOG.md | 4 +++ lib/style/module_directives.ex | 8 +++-- .../module_directives/alias_lifting_test.exs | 2 ++ test/style/module_directives_test.exs | 30 ++++++++++--------- 4 files changed, 28 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55064f60..39f69255 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ they can and will change without that change being reflected in Styler's semanti ## main +### Fixes + +- handle comments when lifting aliases in snippets with no prior directives (#239, h/t @kerryb) + ## 1.5.0 ### Improvements diff --git a/lib/style/module_directives.ex b/lib/style/module_directives.ex index 1e237e25..293c57cb 100644 --- a/lib/style/module_directives.ex +++ b/lib/style/module_directives.ex @@ -78,8 +78,12 @@ defmodule Styler.Style.ModuleDirectives do else # there's no defmodules or aliasy things - see if we can do some alias lifting? case lift_aliases(%{@env | nondirectives: Zipper.children(zipper)}) do - %{alias: []} -> {:halt, zipper, ctx} - %{alias: lifts, nondirectives: children} -> {:halt, Zipper.replace_children(zipper, lifts ++ children), ctx} + %{alias: []} -> + {:halt, zipper, ctx} + + %{alias: lifts, nondirectives: children} -> + {nodes, comments} = Style.order_line_meta_and_comments(lifts ++ children, ctx.comments, 1) + {:halt, Zipper.replace_children(zipper, nodes), %{ctx | comments: comments}} end end end diff --git a/test/style/module_directives/alias_lifting_test.exs b/test/style/module_directives/alias_lifting_test.exs index e796d47d..ba074c2a 100644 --- a/test/style/module_directives/alias_lifting_test.exs +++ b/test/style/module_directives/alias_lifting_test.exs @@ -16,12 +16,14 @@ defmodule Styler.Style.ModuleDirectives.AliasLiftingTest do assert_style( """ A.B.C + # z A.B.C """, """ alias A.B.C C + # z C """ ) diff --git a/test/style/module_directives_test.exs b/test/style/module_directives_test.exs index 5d2e0b4f..2dff6d27 100644 --- a/test/style/module_directives_test.exs +++ b/test/style/module_directives_test.exs @@ -537,20 +537,22 @@ defmodule Styler.Style.ModuleDirectivesTest do assert_style "@derive Inspect" - assert_style(""" - defstruct [:a] - # comment for foo - def foo, do: :ok - @derive Inspect - @derive {Foo, bar: :baz} - """, - """ - @derive Inspect - @derive {Foo, bar: :baz} - defstruct [:a] - # comment for foo - def foo, do: :ok - """) + assert_style( + """ + defstruct [:a] + # comment for foo + def foo, do: :ok + @derive Inspect + @derive {Foo, bar: :baz} + """, + """ + @derive Inspect + @derive {Foo, bar: :baz} + defstruct [:a] + # comment for foo + def foo, do: :ok + """ + ) end test "expands use/behaviour/import/moduledoc aliases" do From af740c3fe77e40f545875555389d03088f69cede Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Fri, 18 Jul 2025 09:14:19 -0600 Subject: [PATCH 100/145] clean up comment - that "bug" isnt a bug --- lib/alias_env.ex | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/alias_env.ex b/lib/alias_env.ex index 23357ab0..dea261cc 100644 --- a/lib/alias_env.ex +++ b/lib/alias_env.ex @@ -89,7 +89,6 @@ defmodule Styler.AliasEnv do # alias A.B.C, as: Bar # alias A.B.C, as: Foo # we'll choose the one that comes last lexically, which will be the alpha-sorted last entry that isn't the default as - # "bug": if the last happens to be `alias A.B.C, as: C`, well, they shouldn't've written such crazy code {modules, multiple_as} -> default_as = List.last(modules) # being clever - rather than rejecting the default up front and doing an extra list-traversal, From e7a3b81a19e5cf98dd6eb75ee14e2a8b566ff340 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Sun, 20 Jul 2025 11:14:54 -0600 Subject: [PATCH 101/145] v1.5.1 --- CHANGELOG.md | 4 +++- mix.exs | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39f69255..70d2114d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,11 @@ they can and will change without that change being reflected in Styler's semanti ## main +## 1.5.1 + ### Fixes -- handle comments when lifting aliases in snippets with no prior directives (#239, h/t @kerryb) +- alias lifting: handle comments in snippets with no existing directives (#239, h/t @kerryb) ## 1.5.0 diff --git a/mix.exs b/mix.exs index 5184c853..a795b050 100644 --- a/mix.exs +++ b/mix.exs @@ -12,7 +12,7 @@ defmodule Styler.MixProject do use Mix.Project # Don't forget to bump the README when doing non-patch version changes - @version "1.5.0" + @version "1.5.1" @url "https://github.com/adobe/elixir-styler" def project do From 51d1cc7d53a951d7b2fe85135863e0d157569e6a Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Wed, 23 Jul 2025 13:14:21 -0600 Subject: [PATCH 102/145] fix: alias lifting snippets with non-block root. closes #240 --- CHANGELOG.md | 4 ++ lib/style/module_directives.ex | 25 ++++++++-- lib/zipper.ex | 9 ++-- .../module_directives/alias_lifting_test.exs | 47 +++++++++++++++++++ 4 files changed, 76 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70d2114d..4f0bded0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ they can and will change without that change being reflected in Styler's semanti ## main +### Fixes + +- alias lifting: fix bug lifting in snippets with a single ast node at the root level (like a credo config file) (#240, h/t @defndaines) + ## 1.5.1 ### Fixes diff --git a/lib/style/module_directives.ex b/lib/style/module_directives.ex index 293c57cb..d81d6c5d 100644 --- a/lib/style/module_directives.ex +++ b/lib/style/module_directives.ex @@ -72,18 +72,33 @@ defmodule Styler.Style.ModuleDirectives do # in that case, all that's left is to apply alias lifting and halt. def run(zipper, %{__MODULE__ => true} = ctx), do: do_run(zipper, ctx) - def run(zipper, ctx) do + def run({node, nil} = zipper, ctx) do if interesting_zipper = Zipper.find(zipper, &interesting?/1) do do_run(interesting_zipper, Map.put(ctx, __MODULE__, true)) else # there's no defmodules or aliasy things - see if we can do some alias lifting? - case lift_aliases(%{@env | nondirectives: Zipper.children(zipper)}) do + case lift_aliases(%{@env | nondirectives: [node]}) do %{alias: []} -> {:halt, zipper, ctx} - %{alias: lifts, nondirectives: children} -> - {nodes, comments} = Style.order_line_meta_and_comments(lifts ++ children, ctx.comments, 1) - {:halt, Zipper.replace_children(zipper, nodes), %{ctx | comments: comments}} + %{alias: aliases, nondirectives: [node]} -> + # This is a line handler unique to this situation. + # Nowhere else do we create nodes that we know will go before all existing code in the document. + # All we need to do is set those aliases to have the appropriate lines, + # then bump the line of everything else in the document by the number of aliases + 1! + aliases = Enum.with_index(aliases, fn node, i -> Style.set_line(node, i + 1) end) + shift = Enum.count(aliases) + 1 + comments = Enum.map(ctx.comments, &%{&1 | line: &1.line + shift}) + node = Style.shift_line(node, shift) + + zipper = + case Zipper.replace(zipper, node) do + {{:__block__, _, _}, _} = block_zipper -> Zipper.insert_children(block_zipper, aliases) + # this snippet was a single element, eg just one big map in a credo config file. + non_block_zipper -> Zipper.prepend_siblings(non_block_zipper, aliases) + end + + {:halt, zipper, %{ctx | comments: comments}} end end end diff --git a/lib/zipper.ex b/lib/zipper.ex index 9b43d1e1..17083110 100644 --- a/lib/zipper.ex +++ b/lib/zipper.ex @@ -206,16 +206,17 @@ defmodule Styler.Zipper do def insert_siblings({node, nil}, siblings), do: {:__block__, [], [node | siblings]} |> zip() |> down() def insert_siblings({tree, meta}, siblings), do: {tree, %{meta | r: siblings ++ meta.r}} - @doc """ - Inserts the item as the leftmost child of the node at this zipper, - without moving. - """ + @doc "Inserts the item as the leftmost child of the node at this zipper." 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 many children, prepending the new list to the existing children of this node" + def insert_children({tree, meta}, children) when is_list(children), + do: replace_children({tree, meta}, children ++ children({tree, meta})) + @doc """ Inserts the item as the rightmost child of the node at this zipper, without moving. diff --git a/test/style/module_directives/alias_lifting_test.exs b/test/style/module_directives/alias_lifting_test.exs index ba074c2a..c81e2f36 100644 --- a/test/style/module_directives/alias_lifting_test.exs +++ b/test/style/module_directives/alias_lifting_test.exs @@ -29,6 +29,53 @@ defmodule Styler.Style.ModuleDirectives.AliasLiftingTest do ) end + test "snippet with a single node" do + assert_style( + """ + # 0 + { + # 1 + A.B.C, + # 2 + A.B.C, + # 3 + + # 4 + "something so long that we keep this silly tuple split across multiple lines to recreate the issue at hand" + } + """, + """ + alias A.B.C + + # 0 + { + # 1 + C, + # 2 + C, + # 3 + + # 4 + "something so long that we keep this silly tuple split across multiple lines to recreate the issue at hand" + } + """ + ) + + assert_style( + """ + { + A.B.C, + A.B.C, + } + """, + """ + alias A.B.C + + {C, C} + """ + ) + end + test "lifts aliases repeated >=2 times from 3 deep" do assert_style( """ From d8ea6205caff7ca12fcee43357e1e54e2fcbeb60 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Mon, 28 Jul 2025 11:59:23 -0400 Subject: [PATCH 103/145] rewrite negated assert/refute --- CHANGELOG.md | 20 ++++++++++++++++++++ docs/general_styles.md | 19 +++++++++++++++++++ lib/style/single_node.ex | 7 +++++++ test/style/single_node_test.exs | 13 +++++++++++++ 4 files changed, 59 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f0bded0..aa215b10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ they can and will change without that change being reflected in Styler's semanti ## main +### Improvements + +- rewrite some negated asserts and refutes + +| before | styled | +|---------------------|-----------------| +| `assert x != nil` | `assert x` | +| `assert !!x` | `assert x` | +| `assert !x` | `refute x` | +| `assert not x` | `refute x` | +| `assert !is_nil(x)` | `assert x` | +| `assert x not in y` | `refute x in y` | +| `refute !x` | `assert x` | +| `refute not x` | `assert x` | +| `refute x not in y` | `assert x in y` | +| `assert x == nil` | _no change_ | +| `assert is_nil(x)` | _no change_ | +| `refute x` | _no change_ | + + ### Fixes - alias lifting: fix bug lifting in snippets with a single ast node at the root level (like a credo config file) (#240, h/t @defndaines) diff --git a/docs/general_styles.md b/docs/general_styles.md index e8fde393..2ffe8221 100644 --- a/docs/general_styles.md +++ b/docs/general_styles.md @@ -241,3 +241,22 @@ def save( # Params comment def save(%Socket{assigns: %{user: user, live_action: :new}} = initial_socket, params), do: :ok ``` + +## Negated Assert/Refute + +Notably there are three ways to write "assert the thing is nil", but Styler doesn't yet feel like coercing codebases to a single style there. + +| before | styled | +|---------------------|-----------------| +| `assert x != nil` | `assert x` | +| `assert !!x` | `assert x` | +| `assert !x` | `refute x` | +| `assert not x` | `refute x` | +| `assert !is_nil(x)` | `assert x` | +| `assert x not in y` | `refute x in y` | +| `refute !x` | `assert x` | +| `refute not x` | `assert x` | +| `refute x not in y` | `assert x in y` | +| `assert x == nil` | _no change_ | +| `assert is_nil(x)` | _no change_ | +| `refute x` | _no change_ | diff --git a/lib/style/single_node.ex b/lib/style/single_node.ex index 9ccb7329..ec4b32c4 100644 --- a/lib/style/single_node.ex +++ b/lib/style/single_node.ex @@ -37,6 +37,13 @@ defmodule Styler.Style.SingleNode do def run({node, meta}, ctx), do: {:cont, {style(node), meta}, ctx} + # reverse negated assert/refute + defp style({:assert, meta, [{:!=, _, [x, {:__block__, _, [nil]}]}]}), do: {:assert, meta, [x]} + defp style({:assert, meta, [{:!=, _, [{:__block__, _, [nil]}, y]}]}), do: {:assert, meta, [y]} + defp style({:assert, meta, [{n, _, [x]}]}) when n in [:!, :not], do: style({:refute, meta, [x]}) + defp style({:refute, meta, [{n, _, [x]}]}) when n in [:!, :not], do: style({:assert, meta, [x]}) + defp style({:refute, meta, [{:is_nil, _, [x]}]}), do: style({:assert, meta, [x]}) + # rewrite double-quote strings with >= 4 escaped double-quotes as sigils defp style({:__block__, [{:delimiter, ~s|"|} | meta], [string]} = node) when is_binary(string) do # running a regex against every double-quote delimited string literal in a codebase doesn't have too much impact diff --git a/test/style/single_node_test.exs b/test/style/single_node_test.exs index 762049ca..de215b19 100644 --- a/test/style/single_node_test.exs +++ b/test/style/single_node_test.exs @@ -11,6 +11,19 @@ defmodule Styler.Style.SingleNodeTest do use Styler.StyleCase, async: true + test "assert/refute negation" do + assert_style "assert x != nil", "assert x" + assert_style "assert !!x", "assert x" + assert_style "assert !x", "refute x" + assert_style "assert not x", "refute x" + assert_style "assert !is_nil(x)", "assert x" + assert_style "assert x not in y", "refute x in y" + + assert_style "refute !x", "assert x" + assert_style "refute not x", "assert x" + assert_style "refute x not in y", "assert x in y" + end + test "string sigil rewrites" do assert_style ~s|""| assert_style ~s|"\\""| From 4d8e05b86a3674b7eb5e30f149893e5cc2e2e502 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Mon, 28 Jul 2025 16:08:38 -0400 Subject: [PATCH 104/145] assert Enum.foo rewrites, and tweaks to the negated rewrites --- CHANGELOG.md | 57 +++++++++++++++++++--------- docs/general_styles.md | 54 +++++++++++++++++--------- lib/style/single_node.ex | 44 +++++++++++++++++++--- test/style/single_node_test.exs | 67 +++++++++++++++++++++++++++------ 4 files changed, 171 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa215b10..4ebd478c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,25 +5,48 @@ they can and will change without that change being reflected in Styler's semanti ## main -### Improvements +## 1.6.0 -- rewrite some negated asserts and refutes - -| before | styled | -|---------------------|-----------------| -| `assert x != nil` | `assert x` | -| `assert !!x` | `assert x` | -| `assert !x` | `refute x` | -| `assert not x` | `refute x` | -| `assert !is_nil(x)` | `assert x` | -| `assert x not in y` | `refute x in y` | -| `refute !x` | `assert x` | -| `refute not x` | `assert x` | -| `refute x not in y` | `assert x in y` | -| `assert x == nil` | _no change_ | -| `assert is_nil(x)` | _no change_ | -| `refute x` | _no change_ | +### Improvements +This version of Styler adds many readability improvements around ExUnit `assert` and `refute`, specifically when working with 1. negations or 2. some `Enum` stdlib functions. + +Some of these rewrites are not semantically equivalent due to `refute` passing for both `nil` and `false`. + +#### ExUnit assert/refute rewrites + +Styler now inverts negated (`!, not`) assert/refute (eg `assert !x` => `refute x`) statements, and further inverts `refute` with boolean comparison operators (`refute x < y` => `assert x >= y`) because non-trivial refutes are harder to reason about \[ _citation needed_ ]. Asserting something is not nil is the same as just asserting that something, so that's gone too now. + +These changes are best summarized by the following table: + +| before | styled | +|---------------------|-------------------| +| `assert !x` | `refute x` | +| `assert not x` | `refute x` | +| `assert !!x` | `assert x` | +| `assert x != nil` | `assert x` | +| `assert x == nil` | _no change_ | +| `assert is_nil(x)` | _no change_ | +| `assert !is_nil(x)` | `assert x` | +| `assert x not in y` | `refute x in y` | +| refute negated | | +| `refute x` | _no change_ | +| `refute !x` | `assert x` | +| `refute not x` | `assert x` | +| `refute x != y` | `assert x == y` | +| `refute x !== y` | `assert x === y` | +| `refute x != nil` | `assert x == nil` | +| `refute x not in y` | `assert x in y` | +| refute comparison | | +| `refute x < y` | `assert x >= y` | +| `refute x <= y` | `assert x > y` | +| `refute x > y` | `assert x <= y` | +| `refute x >= y` | `assert x < y` | + +- `assert Enum.member?(y, x)` -> `assert x in y` +- `assert Enum.find(x, y)` -> `assert Enum.any?(x, y)` (nb. not semantically equivalent in theory, but equivalent in practice) +- `assert Enum.any?(y, & &1 == x)` -> `assert x in y` +- `assert Enum.any?(y, fn var -> var == x end)` -> `assert x in y` ### Fixes diff --git a/docs/general_styles.md b/docs/general_styles.md index 2ffe8221..b7dbd12d 100644 --- a/docs/general_styles.md +++ b/docs/general_styles.md @@ -242,21 +242,39 @@ def save( def save(%Socket{assigns: %{user: user, live_action: :new}} = initial_socket, params), do: :ok ``` -## Negated Assert/Refute - -Notably there are three ways to write "assert the thing is nil", but Styler doesn't yet feel like coercing codebases to a single style there. - -| before | styled | -|---------------------|-----------------| -| `assert x != nil` | `assert x` | -| `assert !!x` | `assert x` | -| `assert !x` | `refute x` | -| `assert not x` | `refute x` | -| `assert !is_nil(x)` | `assert x` | -| `assert x not in y` | `refute x in y` | -| `refute !x` | `assert x` | -| `refute not x` | `assert x` | -| `refute x not in y` | `assert x in y` | -| `assert x == nil` | _no change_ | -| `assert is_nil(x)` | _no change_ | -| `refute x` | _no change_ | +## Assert/Refute Rewrites + +Styler rewrites negated asserts, assertions that things are not nil (that's the same as just asserting the thing!) and refute with binary operators (refute's hard to reason about with anything other than `==` and predicates). + +| before | styled | +|---------------------|-------------------| +| `assert !x` | `refute x` | +| `assert not x` | `refute x` | +| `assert !!x` | `assert x` | +| `assert x != nil` | `assert x` | +| `assert x == nil` | _no change_ | +| `assert is_nil(x)` | _no change_ | +| `assert !is_nil(x)` | `assert x` | +| `assert x not in y` | `refute x in y` | +| **refute negated** | | +| `refute x` | _no change_ | +| `refute !x` | `assert x` | +| `refute not x` | `assert x` | +| `refute x != y` | `assert x == y` | +| `refute x !== y` | `assert x === y` | +| `refute x != nil` | `assert x == nil` | +| `refute x not in y` | `assert x in y` | +| **refute comparison** | | +| `refute x < y` | `assert x >= y` | +| `refute x <= y` | `assert x > y` | +| `refute x > y` | `assert x <= y` | +| `refute x >= y` | `assert x < y` | + +Additionally, styler rewrites some `Enum` functions inside `assert` + +| before | styled | +|-----------------------------------------------|-------------------------------------| +| `assert Enum.find(x, y)` | `assert Enum.any?(x, y)` | +| `assert Enum.member?(y, x)` | `assert x in y` | +| `assert Enum.any?(y, & &1 == x)` | `assert x in y` | +| `assert Enum.any?(y, fn var -> var == x end)` | `assert x in y` | diff --git a/lib/style/single_node.ex b/lib/style/single_node.ex index ec4b32c4..2540a8cb 100644 --- a/lib/style/single_node.ex +++ b/lib/style/single_node.ex @@ -37,12 +37,46 @@ defmodule Styler.Style.SingleNode do def run({node, meta}, ctx), do: {:cont, {style(node), meta}, ctx} - # reverse negated assert/refute - defp style({:assert, meta, [{:!=, _, [x, {:__block__, _, [nil]}]}]}), do: {:assert, meta, [x]} - defp style({:assert, meta, [{:!=, _, [{:__block__, _, [nil]}, y]}]}), do: {:assert, meta, [y]} - defp style({:assert, meta, [{n, _, [x]}]}) when n in [:!, :not], do: style({:refute, meta, [x]}) - defp style({:refute, meta, [{n, _, [x]}]}) when n in [:!, :not], do: style({:assert, meta, [x]}) + defp style({:assert, meta, [{:!=, _, [x, {:__block__, _, [nil]}]}]}), do: style({:assert, meta, [x]}) + # refute nilly -> assert defp style({:refute, meta, [{:is_nil, _, [x]}]}), do: style({:assert, meta, [x]}) + defp style({:refute, meta, [{:==, _, [x, {:__block__, _, [nil]}]}]}), do: style({:assert, meta, [x]}) + # boolean ops and assert hurt my brain. + # the lone exception is `==` (... for now) ((uh, and the exception to the exception is when it's `== nil`, above)) + defp style({:refute, meta, [{:!=, m, xy}]}), do: style({:assert, meta, [{:==, m, xy}]}) + defp style({:refute, meta, [{:!==, m, xy}]}), do: style({:assert, meta, [{:===, m, xy}]}) + defp style({:refute, meta, [{:<, m, xy}]}), do: style({:assert, meta, [{:>=, m, xy}]}) + defp style({:refute, meta, [{:<=, m, xy}]}), do: style({:assert, meta, [{:>, m, xy}]}) + defp style({:refute, meta, [{:>, m, xy}]}), do: style({:assert, meta, [{:<=, m, xy}]}) + defp style({:refute, meta, [{:>=, m, xy}]}), do: style({:assert, meta, [{:<, m, xy}]}) + + for {a, inverted} <- [{:assert, :refute}, {:refute, :assert}] do + # invert negations + defp style({unquote(a), meta, [{n, _, [x]}]}) when n in [:!, :not], do: style({unquote(inverted), meta, [x]}) + + # assert Enum.member? -> assert in + defp style({unquote(a), meta, [{{:., _, [{:__aliases__, _, [:Enum]}, :member?]}, _, [enum, elem]}]}), + do: {unquote(a), meta, [{:in, [line: meta[:line]], [elem, enum]}]} + + # assert Enum.find -> assert Enum.any? + defp style({unquote(a), meta, [{{:., a, [{:__aliases__, b, [:Enum]}, :find]}, c, [enum, fun]}]}), + do: style({unquote(a), meta, [{{:., a, [{:__aliases__, b, [:Enum]}, :any?]}, c, [enum, fun]}]}) + + # Enum.any?(x, & &1 == y) => y in x + defp style({unquote(a) = a, m, [{{:., _, [{:__aliases__, _, [:Enum]}, :any?]}, _, [y, fun]}]} = node) do + case fun do + # & &1 == x + {:&, _, [{:==, _, [{:&, _, [1]}, x]}]} -> {a, m, [{:in, [line: m[:line]], [x, y]}]} + # & x == &1 + {:&, _, [{:==, _, [x, {:&, _, [1]}]}]} -> {a, m, [{:in, [line: m[:line]], [x, y]}]} + # fn var -> var == x + {:fn, _, [{:->, _, [[{var, _, nil}], {:==, _, [{var, _, nil}, x]}]}]} -> {a, m, [{:in, [line: m[:line]], [x, y]}]} + # fn var -> x == var + {:fn, _, [{:->, _, [[{var, _, nil}], {:==, _, [x, {var, _, nil}]}]}]} -> {a, m, [{:in, [line: m[:line]], [x, y]}]} + _ -> node + end + end + end # rewrite double-quote strings with >= 4 escaped double-quotes as sigils defp style({:__block__, [{:delimiter, ~s|"|} | meta], [string]} = node) when is_binary(string) do diff --git a/test/style/single_node_test.exs b/test/style/single_node_test.exs index de215b19..0086e675 100644 --- a/test/style/single_node_test.exs +++ b/test/style/single_node_test.exs @@ -11,17 +11,62 @@ defmodule Styler.Style.SingleNodeTest do use Styler.StyleCase, async: true - test "assert/refute negation" do - assert_style "assert x != nil", "assert x" - assert_style "assert !!x", "assert x" - assert_style "assert !x", "refute x" - assert_style "assert not x", "refute x" - assert_style "assert !is_nil(x)", "assert x" - assert_style "assert x not in y", "refute x in y" - - assert_style "refute !x", "assert x" - assert_style "refute not x", "assert x" - assert_style "refute x not in y", "assert x in y" + describe "assert/refute" do + test "negation" do + assert_style "assert x != nil", "assert x" + assert_style "assert !!x", "assert x" + assert_style "assert !x", "refute x" + assert_style "assert not x", "refute x" + assert_style "assert !is_nil(x)", "assert x" + assert_style "assert x not in y", "refute x in y" + + assert_style "assert nil == nil", "assert nil == nil" + assert_style "assert nil != nil", "assert nil" + + assert_style "refute x != y", "assert x == y" + assert_style "refute x !== y", "assert x === y" + assert_style "refute x != nil", "assert x == nil" + assert_style "refute !x", "assert x" + assert_style "refute not x", "assert x" + assert_style "refute x not in y", "assert x in y" + + assert_style "assert x == nil" + assert_style "assert is_nil(x)" + assert_style "refute x" + end + + test "Enum.member? -> in" do + assert_style "assert Enum.member?(enum, x)", "assert x in enum" + assert_style "refute Enum.member?(enum, x)", "refute x in enum" + assert_style "assert not Enum.member?(enum, x)", "refute x in enum" + assert_style "refute not Enum.member?(enum, x)", "assert x in enum" + end + + test "Enum.find -> any?" do + assert_style "assert Enum.find(x, y)", "assert Enum.any?(x, y)" + assert_style "refute Enum.find(x, y)", "refute Enum.any?(x, y)" + end + + test "Enum.any?(x, & &1 == y) -> y in x" do + assert_style "assert Enum.any?(x, y)" + assert_style "assert Enum.any?(x, &(&1.x == y))" + assert_style "assert Enum.any?(x, &(&1 != y))" + assert_style "assert Enum.any?(y, & &1 == x)", "assert x in y" + assert_style "assert Enum.any?(y, fn q -> q == x end)", "assert x in y" + assert_style "assert Enum.find(y, & &1 == x)", "assert x in y" + assert_style "assert Enum.find(y, fn q -> q == x end)", "assert x in y" + end + + test "boolean comparisons" do + assert_style "assert x < y" + assert_style "assert x <= y" + assert_style "assert x > y" + assert_style "assert x >= y" + assert_style "refute x < y", "assert x >= y" + assert_style "refute x <= y", "assert x > y" + assert_style "refute x > y", "assert x <= y" + assert_style "refute x >= y", "assert x < y" + end end test "string sigil rewrites" do From 0ffe339cbaa1ce4a3000e244675c0f77fc94dfa3 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Mon, 28 Jul 2025 16:10:37 -0400 Subject: [PATCH 105/145] fix link in docs --- docs/styles.md | 2 +- mix.exs | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/styles.md b/docs/styles.md index 1f5338e8..e4710476 100644 --- a/docs/styles.md +++ b/docs/styles.md @@ -5,7 +5,7 @@ Styler performs myriad rewrites, logically broken apart into the following group - [Comment Directives](./comment_directives.md): leave comments to Styler instructing it to perform a task - [Control Flow Macros](./control_flow_macros.md): styles modifying `case`, `if`, `unless`, `cond` and `with` statements - [Elixir Deprecations](./deprecations.md): Styles which automate the replacement or updating of code deprecated by new Elixir releases -- [General Styles](./styles.md): general simple 1-1 rewrites that require a minimum amount of awareness of the AST +- [General Styles](./general_styles.md): general simple 1-1 rewrites that require a minimum amount of awareness of the AST - [Mix Configs](./mix_configs.md): Styler applies order to chaos by organizing mix `config ...` stanzas - [Module Directives](./module_directives.md): Styles for `alias`, `use`, `import`, `require`, as well as alias lifting and alias application. - [Pipes](./pipes.md) styles for the famous Elixir pipe `|>`, including optimizations for piping standard library functions diff --git a/mix.exs b/mix.exs index a795b050..6d507878 100644 --- a/mix.exs +++ b/mix.exs @@ -65,13 +65,13 @@ defmodule Styler.MixProject do extras: [ "CHANGELOG.md": [title: "Changelog"], "docs/styles.md": [title: "Styles/Features Table of Contents"], - "docs/general_styles.md": [title: "General Styles"], - "docs/deprecations.md": [title: "Deprecated Elixirisms"], - "docs/pipes.md": [title: "Pipe Chains"], + "docs/comment_directives.md": [title: "Comment Directives (# styler:sort)"], "docs/control_flow_macros.md": [title: "Control Flow Macros (if, case, ...)"], + "docs/deprecations.md": [title: "Deprecated Elixirisms"], + "docs/general_styles.md": [title: "General Styles"], "docs/mix_configs.md": [title: "Mix Configs (config/*.exs)"], "docs/module_directives.md": [title: "Module Directives (use, alias, ...)"], - "docs/comment_directives.md": [title: "Comment Directives (# styler:sort)"], + "docs/pipes.md": [title: "Pipe Chains"], "docs/credo.md": [title: "Styler & Credo"], "README.md": [title: "Styler"] ] From 4d6fca6f762e93278dbedf7b35a4f22fb3bf6a57 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Mon, 28 Jul 2025 16:13:11 -0400 Subject: [PATCH 106/145] v1.6.0 --- CHANGELOG.md | 2 ++ mix.exs | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ebd478c..efd70c29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ they can and will change without that change being reflected in Styler's semanti ## 1.6.0 +That's right, a feature release again so soon! + ### Improvements This version of Styler adds many readability improvements around ExUnit `assert` and `refute`, specifically when working with 1. negations or 2. some `Enum` stdlib functions. diff --git a/mix.exs b/mix.exs index 6d507878..4cc763b5 100644 --- a/mix.exs +++ b/mix.exs @@ -12,7 +12,7 @@ defmodule Styler.MixProject do use Mix.Project # Don't forget to bump the README when doing non-patch version changes - @version "1.5.1" + @version "1.6.0" @url "https://github.com/adobe/elixir-styler" def project do From 45c7b1a431cf7440fef1ae41e559fcc8dd2106e4 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Mon, 4 Aug 2025 12:22:25 -0400 Subject: [PATCH 107/145] cond: standardize on `true` for the literal in the final clause Co-authored-by: Adam Kittelson --- CHANGELOG.md | 39 ++++++++++++++ docs/control_flow_macros.md | 26 +++++++++- lib/style/blocks.ex | 26 ++++++++-- test/style/blocks_test.exs | 101 ++++++++++++++++++++++++++++-------- 4 files changed, 165 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index efd70c29..82ccc925 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,45 @@ they can and will change without that change being reflected in Styler's semanti ## main +### Improvements + +#### `cond` + +If the last clause's left-hand-side is a truthy atom, map literal, or tuple, rewrite it to be `true` + +```elixir +# before +cond do + a -> b + c -> d + :else -> e +end + +# styled +cond do + a -> b + c -> d + true -> e +end +``` + +This also helps Styler identify 2-clause conds that can be rewritten to `if/else` more readily, like the following: + +```elixir +# before +cond do + a -> b + :else -> c +end + +# styled +if a do + b +else + c +end +``` + ## 1.6.0 That's right, a feature release again so soon! diff --git a/docs/control_flow_macros.md b/docs/control_flow_macros.md index 2aa7e249..671699fc 100644 --- a/docs/control_flow_macros.md +++ b/docs/control_flow_macros.md @@ -132,7 +132,25 @@ end ## `cond` -Styler has only one `cond` statement rewrite: replace 2-clause statements with `if` statements. +Styler ensures that if the final clause of a `cond` statement uses a literal as its lefthandside, that that literal is the atom `true`. + +```elixir +# before +cond do + a -> b + c -> d + :else -> e +end + +# styled +cond do + a -> b + c -> d + true -> e +end +``` + +Styler also replaces 2-clause cond statements with `if` statements when possible ```elixir # Given @@ -146,6 +164,12 @@ if a do else c end + +# This is left unchanged, as its behaviour of raising if `foo` is falsey is assumed to be intentional +cond do + a -> b + foo -> c +end ``` ## `with` diff --git a/lib/style/blocks.ex b/lib/style/blocks.ex index 7a74378c..b37eab74 100644 --- a/lib/style/blocks.ex +++ b/lib/style/blocks.ex @@ -43,10 +43,28 @@ defmodule Styler.Style.Blocks do end end - # 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) + def run({{:cond, _, [[{do_, clauses}]]}, _} = zipper, ctx) do + # ensure all final `atom -> final_clause` use `true` for consistency. + # `:else` is cute but consistency is all. + rewrite_literal_to_true = fn + {:->, am, [[{:__block__, bm, [truthy]}], body]} when truthy not in [nil, false] -> + {:->, am, [[{:__block__, bm, [true]}], body]} + + # Surely this never happens buuuuut? + # %{} ->; {} -> + {:->, am, [[{literal, bm, _}], body]} when literal in [:{}, :%{}] -> + {:->, am, [[{:__block__, bm, [true]}], body]} + + other -> + other + end + + case List.update_at(clauses, -1, rewrite_literal_to_true) do + # # Credo.Check.Refactor.CondStatements + [{:->, _, [[head], a]}, {:->, _, [[{:__block__, _, [true]}], b]}] -> if_ast(zipper, head, a, b, ctx) + clauses -> {:cont, Zipper.replace_children(zipper, [[{do_, clauses}]]), ctx} + end + end # Credo.Check.Readability.WithSingleClause # rewrite `with success <- single_statement do body else ...elses end` diff --git a/test/style/blocks_test.exs b/test/style/blocks_test.exs index e8f4626e..e3271857 100644 --- a/test/style/blocks_test.exs +++ b/test/style/blocks_test.exs @@ -805,41 +805,98 @@ defmodule Styler.Style.BlocksTest do end end - test "Credo.Check.Refactor.CondStatements" do - for truthy <- ~w(true :atom :else) do - assert_style( - """ + describe "cond" do + test "rewrite some two-clause conds as an if statement" do + for truthy <- ~w(true :atom :else) do + assert_style( + """ + cond do + a -> b + #{truthy} -> c + end + """, + """ + if a do + b + else + c + end + """ + ) + end + + for falsey <- ~w(false nil) do + assert_style(""" cond do a -> b - #{truthy} -> c + #{falsey} -> c end - """, - """ - if a do - b - else - c + """) + end + + for ignored <- ["x == y", "foo", "foo()", "foo(b)", "Module.foo(x)"] do + assert_style(""" + cond do + a -> b + #{ignored} -> c end - """ - ) + """) + end end - for falsey <- ~w(false nil) do - assert_style(""" + test "if final clause is an atom or truthy value, change the clause to `true`" do + assert_style """ cond do a -> b - #{falsey} -> c + c -> d + true -> woo end - """) - end + """ - for ignored <- ["x == y", "foo", "foo()", "foo(b)", "Module.foo(x)", ~s("else"), "%{}", "{}"] do - assert_style(""" + assert_style """ cond do a -> b - #{ignored} -> c + c -> d + call() -> woo + end + """ + + for truthy <- [~s("else"), ":else", "%{}", "{}"] do + assert_style( + """ + cond do + a -> b + c -> d + #{truthy} -> woo + end + """, + """ + cond do + a -> b + c -> d + true -> woo + end + """ + ) + end + + for truthy <- [~s("else"), ":else", "%{}", "{}"] do + assert_style( + """ + cond do + a -> b + #{truthy} -> woo + end + """, + """ + if a do + b + else + woo + end + """ + ) end - """) end end From 79d65e9be37ed51f4336e3dd2a3d672f519a2caa Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Mon, 4 Aug 2025 13:14:07 -0400 Subject: [PATCH 108/145] consolidate config integration tests --- test/config_integration_test.exs | 28 +++++++++++++++++++ test/style/deprecations_test.exs | 8 ------ .../module_directives/alias_lifting_test.exs | 13 --------- 3 files changed, 28 insertions(+), 21 deletions(-) create mode 100644 test/config_integration_test.exs diff --git a/test/config_integration_test.exs b/test/config_integration_test.exs new file mode 100644 index 00000000..17d0931a --- /dev/null +++ b/test/config_integration_test.exs @@ -0,0 +1,28 @@ +defmodule Styler.ConfigIntegrationTest do + use Styler.StyleCase, async: false + + alias Styler.Config + + setup do + on_exit(fn -> Config.set([]) end) + end + + test "`:alias_lifting_exclude` - collisions with configured modules" do + Config.set(alias_lifting_exclude: ~w(C)a) + + assert_style """ + alias Foo.Bar + + A.B.C + A.B.C + """ + end + + @tag skip: Version.match?(System.version(), "< 1.17.0-dev") + test "`:minimum_supported_elixir_version` and :timer config @ 1.17-dev" do + Config.set(minimum_supported_elixir_version: "1.16.0") + assert_style ":timer.minutes(60 * 4)" + Config.set(minimum_supported_elixir_version: "1.17.0-dev") + assert_style ":timer.hours(x)", "to_timeout(hour: x)" + end +end diff --git a/test/style/deprecations_test.exs b/test/style/deprecations_test.exs index ce393e3a..4e6863e3 100644 --- a/test/style/deprecations_test.exs +++ b/test/style/deprecations_test.exs @@ -151,13 +151,5 @@ defmodule Styler.Style.DeprecationsTest do test "combined with to_timeout improvements" do assert_style ":timer.minutes(60 * 4)", "to_timeout(hour: 4)" end - - test "respects :minimum_supported_elixir_version config @ 1.17-dev" do - on_exit(fn -> Styler.Config.set([]) end) - Styler.Config.set(minimum_supported_elixir_version: "1.16.0") - assert_style ":timer.minutes(60 * 4)" - Styler.Config.set(minimum_supported_elixir_version: "1.17.0-dev") - assert_style ":timer.hours(x)", "to_timeout(hour: x)" - end end end diff --git a/test/style/module_directives/alias_lifting_test.exs b/test/style/module_directives/alias_lifting_test.exs index c81e2f36..721228b9 100644 --- a/test/style/module_directives/alias_lifting_test.exs +++ b/test/style/module_directives/alias_lifting_test.exs @@ -413,19 +413,6 @@ defmodule Styler.Style.ModuleDirectives.AliasLiftingTest do end describe "it doesn't lift" do - test "collisions with configured modules" do - Styler.Config.set(alias_lifting_exclude: ~w(C)a) - - assert_style """ - alias Foo.Bar - - A.B.C - A.B.C - """ - - Styler.Config.set([]) - end - test "collisions with std lib" do assert_style """ defmodule DontYouDare do From f859949776de68d788a8a0b73c1db78bcb161772 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Sat, 9 Aug 2025 08:28:26 -0600 Subject: [PATCH 109/145] Enum.filter(fun) |> List.first([default]) => Enum.find([default], fun) Closes #242 --- CHANGELOG.md | 2 ++ lib/style/pipes.ex | 13 +++++++++++++ test/style/pipes_test.exs | 35 ++++++++++++++++++++++++++++++++++- 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82ccc925..693c0c04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ they can and will change without that change being reflected in Styler's semanti ### Improvements +- `|> Enum.filter(fun) |> List.first([default])` => `|> Enum.find([default], fun)` (#242, h/t @janpieper) + #### `cond` If the last clause's left-hand-side is a truthy atom, map literal, or tuple, rewrite it to be `true` diff --git a/lib/style/pipes.ex b/lib/style/pipes.ex index 45b35bd8..661d9aaa 100644 --- a/lib/style/pipes.ex +++ b/lib/style/pipes.ex @@ -306,6 +306,19 @@ defmodule Styler.Style.Pipes do {:|>, pm, [lhs, {reverse, [line: meta[:line]], [enum]}]} end + # `lhs |> Enum.filter(fun) |> List.first([default])` => `lhs |> Enum.find([default], fun)` + defp fix_pipe( + pipe_chain( + pm, + lhs, + {{:., dm, [{_, _, [:Enum]} = enum, :filter]}, meta, [fun]}, + {{:., _, [{_, _, [:List]}, :first]}, _, default} + ) + ) do + line = meta[:line] + {:|>, pm, [lhs, {{:., dm, [enum, :find]}, [line: line], Style.set_line(default, line) ++ [fun]}]} + end + # `lhs |> Enum.reverse() |> Kernel.++(enum)` => `lhs |> Enum.reverse(enum)` defp fix_pipe( pipe_chain( diff --git a/test/style/pipes_test.exs b/test/style/pipes_test.exs index 98555a7d..23f2bbe2 100644 --- a/test/style/pipes_test.exs +++ b/test/style/pipes_test.exs @@ -500,7 +500,7 @@ defmodule Styler.Style.PipesTest do end end - describe "simple rewrites" do + describe "optimizations & readability improvements" do test "rewrites anonymous function invocations to use then" do assert_style("a |> (& &1).()", "then(a, & &1)") assert_style("a |> (& {&1, &2}).(b)", "(&{&1, &2}).(a, b)") @@ -542,6 +542,39 @@ defmodule Styler.Style.PipesTest do assert_style("a |> b |> c", "a |> b() |> c()") end + test "filter/first => find" do + assert_style "a |> Enum.filter(fun) |> List.first()", "Enum.find(a, fun)" + assert_style "a |> Enum.filter(fun) |> List.first(default)", "Enum.find(a, default, fun)" + + assert_style( + """ + a + |> Enum.filter(fun) + |> List.first() + |> foo() + """, + """ + a + |> Enum.find(fun) + |> foo() + """ + ) + + assert_style( + """ + a + |> Enum.filter(fun) + |> List.first(default) + |> foo() + """, + """ + a + |> Enum.find(default, fun) + |> foo() + """ + ) + end + test "reverse/concat" do assert_style("a |> Enum.reverse() |> Enum.concat()") assert_style("a |> Enum.reverse(bar) |> Enum.concat()") From 7895461ed3c61f87986ad894301966237ac31cf4 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Sat, 9 Aug 2025 08:40:11 -0600 Subject: [PATCH 110/145] update pipes docs --- docs/pipes.md | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/docs/pipes.md b/docs/pipes.md index d00e83e5..00040b70 100644 --- a/docs/pipes.md +++ b/docs/pipes.md @@ -44,26 +44,28 @@ a |> b |> c |> d a |> b() |> c() |> d() ``` -## Remove Unnecessary `then/2` +## `then/2` improvements -When the piped argument is being passed as the first argument to the inner function, there's no need for `then/2`. +### Add `then/2` when defining and calling anonymous functions in pipes ```elixir -a |> then(&f(&1, ...)) |> b() +a |> (fn x -> x end).() |> c() # Styled: -a |> f(...) |> b() +a |> then(fn x -> x end) |> c() ``` -- add parens to function calls `|> fun |>` => `|> fun() |>` +### Remove Redundant `then/2` -## Add `then/2` when defining and calling anonymous functions in pipes +When the piped argument is being passed as the first argument to the inner function, there's no need for `then/2`. ```elixir -a |> (fn x -> x end).() |> c() +a |> then(&f(&1, ...)) |> b() # Styled: -a |> then(fn x -> x end) |> c() +a |> f(...) |> b() ``` +- add parens to function calls `|> fun |>` => `|> fun() |>` + ## Piped function optimizations Two function calls into one! Fewer steps is always nice. @@ -103,6 +105,13 @@ a |> b() |> Stream.map(fun) |> Stream.run() # Styled: a |> b() |> Enum.each(fun) a |> b() |> Enum.each(fun) + +# Given: +a |> Enum.filter(fun) |> List.first() |> ... +a |> Enum.filter(fun) |> List.first(default) |> ... +# Styled: +a |> Enum.find(fun) |> ... +a |> Enum.find(default, fun) |> ... ``` ## Unpiping Single Pipes @@ -128,3 +137,10 @@ d(a |> b |> c) # Styled a |> b() |> c() |> d() ``` + +Styler does not pipe-ify nested function calls if there are no pipes: + +```elixir +# Styler does not change this +d(c(b(a()))) +``` From b29ac40a91a5681df21baf6cb8b29f6a3e27ca50 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Sun, 10 Aug 2025 07:58:00 -0600 Subject: [PATCH 111/145] more pipes docs --- docs/pipes.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/docs/pipes.md b/docs/pipes.md index 00040b70..1479e5e6 100644 --- a/docs/pipes.md +++ b/docs/pipes.md @@ -64,8 +64,6 @@ a |> then(&f(&1, ...)) |> b() a |> f(...) |> b() ``` -- add parens to function calls `|> fun |>` => `|> fun() |>` - ## Piped function optimizations Two function calls into one! Fewer steps is always nice. @@ -142,5 +140,16 @@ Styler does not pipe-ify nested function calls if there are no pipes: ```elixir # Styler does not change this -d(c(b(a()))) +d(c(b(a)) +``` + +If you want Styler to do the work of transforming nested function calls into a pipe for you, change the innermost function call to a pipe, then format. Styler will take care of the rest. + +```elixir +# To have Styler change this to pipes +d(c(b(a)) +# You will have to add a single pipe to the innermost function call, like so: +d(c(a |> b)) +# At which point Styler will pipe-ify the entire chain +a |> b() |> c() |> d() ``` From 97c6aff5147accddecb75ba762894b68c6f7f63e Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Tue, 12 Aug 2025 10:48:06 -0600 Subject: [PATCH 112/145] add some preaching around why styler does a weird thing --- docs/module_directives.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/module_directives.md b/docs/module_directives.md index 150b6692..906e7b0c 100644 --- a/docs/module_directives.md +++ b/docs/module_directives.md @@ -200,11 +200,18 @@ X.woo() X.woo() ``` -## Adds Moduledoc false +## Adds `@moduledoc false` -Adds `@moduledoc false` to modules without a moduledoc. This can be a helpful callout to developers doing self review or code review that they failed to provide a moduledoc, something that's otherwise easily forgotten. +Adds `@moduledoc false` to modules without a moduledoc. -This Style is not applied if the module's name ends with one of the following (this list was inherited from Credo): +Styler does this for two reasons: + +1. Its appearance during code review is a reminder to the author and reviewer that they may want to document the module. This can otherwise be easily forgotten. +2. To normalize the use of `@moduledoc false`, because it's preferable to docstrings which convey no actual information. + + For example: `@moduledoc "The module for functions for interacting with Widgets"` in the `Widget` module is crueler code to have written than just `@moduledoc false`, because including a string gave the reader hope that the author was going to help them comprehend the module. That false hope causes more harm than just saying "Sorry, you're on your own." (aka `@moduledoc false`) + +In conformance with the precedent set by Credo, this `@moduledoc` is not added if the module's name ends with any of the following: * `Test` * `Mixfile` From b5d7d9d2c8afafc3182d04a5b41c87f938b2e53c Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Tue, 12 Aug 2025 12:32:50 -0600 Subject: [PATCH 113/145] v1.7.0 --- CHANGELOG.md | 6 ++++++ docs/control_flow_macros.md | 10 ++++++++-- mix.exs | 2 +- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 693c0c04..b75a919e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ they can and will change without that change being reflected in Styler's semanti ## main +## 1.7.0 + +Surprising how fast numbers go up when you're following semver. + +Two new features, one being a pipe optimization and the other a style-consistency-enforcer in `cond` statements. + ### Improvements - `|> Enum.filter(fun) |> List.first([default])` => `|> Enum.find([default], fun)` (#242, h/t @janpieper) diff --git a/docs/control_flow_macros.md b/docs/control_flow_macros.md index 671699fc..b9c0593e 100644 --- a/docs/control_flow_macros.md +++ b/docs/control_flow_macros.md @@ -132,7 +132,7 @@ end ## `cond` -Styler ensures that if the final clause of a `cond` statement uses a literal as its lefthandside, that that literal is the atom `true`. +Styler enforces the use of `true` as the final clause of a cond statement when it's equivalent. ```elixir # before @@ -141,13 +141,19 @@ cond do c -> d :else -> e end - # styled cond do a -> b c -> d true -> e end + +# This is left unchanged +cond do + a -> b + c -> d + foo -> e +end ``` Styler also replaces 2-clause cond statements with `if` statements when possible diff --git a/mix.exs b/mix.exs index 4cc763b5..dde179b3 100644 --- a/mix.exs +++ b/mix.exs @@ -12,7 +12,7 @@ defmodule Styler.MixProject do use Mix.Project # Don't forget to bump the README when doing non-patch version changes - @version "1.6.0" + @version "1.7.0" @url "https://github.com/adobe/elixir-styler" def project do From cb5dc635678cfe63a659daf2fbc79f8abd1711d5 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Fri, 5 Sep 2025 12:19:10 -0700 Subject: [PATCH 114/145] rewrite single-clause case statements to assignments --- CHANGELOG.md | 18 ++++++ lib/style/blocks.ex | 28 +++++++++ test/style/blocks_test.exs | 104 ++++++++++++++++++++++++++++++++ test/style/pipes_test.exs | 11 ++-- test/style/single_node_test.exs | 4 ++ 5 files changed, 160 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b75a919e..63cbd13a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ they can and will change without that change being reflected in Styler's semanti ## main +### Improvements + +Rewrite single-clause case statements to be assignments (h/t 🤖) + +```elixir +# before +case foo |> Bar.baz() |> Bop.boop() do + {:ok, widget} -> + x = y + wodget(widget) +end + +# after +{:ok, widget} = foo |> Bar.baz() |> Bop.boop() +x = y +wodget(widget) +``` + ## 1.7.0 Surprising how fast numbers go up when you're following semver. diff --git a/lib/style/blocks.ex b/lib/style/blocks.ex index b37eab74..426becc4 100644 --- a/lib/style/blocks.ex +++ b/lib/style/blocks.ex @@ -43,6 +43,34 @@ defmodule Styler.Style.Blocks do end end + # case statements with 1 clause: thanks 🤖! + # + # ideally rewrite to use `=`, but can't do that when there's a `when` clause + def run({{:case, _, [_, [{_, [{:->, _, [[{:when, _, _} | _] | _]}]}]]}, _} = zipper, ctx), do: {:cont, zipper, ctx} + # + def run({{:case, m, [head, [{_, [{:->, _, [[lhs], rhs]}]}]]}, _} = zipper, ctx) do + rhs = + case rhs do + {:__block__, _, children} -> children + node -> [node] + end + + zipper = + case Zipper.up(zipper) do + {{:=, am, [parent_lhs, _single_clause_case]}, _} = zipper -> + # this was a `x = case head, do: (lhs -> rhs)`. make it `x = lhs = head; rhs` + meta = [line: am[:line]] + Zipper.replace(zipper, {:=, meta, [parent_lhs, {:=, meta, [lhs, head]}]}) + + _ -> + zipper + |> Style.find_nearest_block() + |> Zipper.replace({:=, [line: m[:line]], [lhs, head]}) + end + + {:cont, Zipper.insert_siblings(zipper, rhs), ctx} + end + def run({{:cond, _, [[{do_, clauses}]]}, _} = zipper, ctx) do # ensure all final `atom -> final_clause` use `true` for consistency. # `:else` is cute but consistency is all. diff --git a/test/style/blocks_test.exs b/test/style/blocks_test.exs index e3271857..a951df1f 100644 --- a/test/style/blocks_test.exs +++ b/test/style/blocks_test.exs @@ -11,6 +11,110 @@ defmodule Styler.Style.BlocksTest do use Styler.StyleCase, async: true + describe "case statements with a single clause" do + test "noop for when" do + assert_style """ + case foo do + bar when is_binary(bar) -> baz(bar) + end + """ + end + + test "changes to assignment" do + assert_style( + """ + case foo do + bar -> baz(bar) + end + """, + """ + bar = foo + baz(bar) + """ + ) + + assert_style( + """ + case foo |> Bar.baz() |> Bop.boop() do + {:ok, widget} -> + x = y + wodget(widget) + end + """, + """ + {:ok, widget} = foo |> Bar.baz() |> Bop.boop() + x = y + wodget(widget) + """ + ) + end + + test "with comments" do + assert_style( + """ + # bar + bar + + # head + case foo |> Bar.baz() |> Bop.boop() do + # clause + {:ok, widget} -> + # body + x = y + wodget(widget) + end + """, + """ + # bar + bar + + # head + # clause + {:ok, widget} = foo |> Bar.baz() |> Bop.boop() + # body + x = y + wodget(widget) + """ + ) + end + + test "singleton parent" do + assert_style """ + if foo do + case complex |> head() |> stuff() do + {:ok, whatever} -> + some_body(whatever) + end + end + """, + """ + if foo do + {:ok, whatever} = complex |> head() |> stuff() + some_body(whatever) + end + """ + end + + test "when already an assignment" do + assert_style( + """ + m + + x = + case a do + b -> c + end + """, + """ + m + + x = b = a + c + """ + ) + end + end + describe "case to if" do test "rewrites case true false to if else" do assert_style( diff --git a/test/style/pipes_test.exs b/test/style/pipes_test.exs index 23f2bbe2..eed450ea 100644 --- a/test/style/pipes_test.exs +++ b/test/style/pipes_test.exs @@ -184,6 +184,7 @@ defmodule Styler.Style.PipesTest do x = case y do :ok -> :ok |> IO.puts() + :error -> :error end |> bar() |> baz() @@ -192,6 +193,7 @@ defmodule Styler.Style.PipesTest do case_result = case y do :ok -> IO.puts(:ok) + :error -> :error end x = @@ -286,11 +288,8 @@ defmodule Styler.Style.PipesTest do |> foo() """, """ - case_result = - case x do - x -> x - end - + case_result = x = x + x foo(case_result) """ ) @@ -300,6 +299,7 @@ defmodule Styler.Style.PipesTest do def foo do case x do x -> x + y -> y end |> foo() end @@ -309,6 +309,7 @@ defmodule Styler.Style.PipesTest do case_result = case x do x -> x + y -> y end foo(case_result) diff --git a/test/style/single_node_test.exs b/test/style/single_node_test.exs index 0086e675..0570f0ff 100644 --- a/test/style/single_node_test.exs +++ b/test/style/single_node_test.exs @@ -279,11 +279,13 @@ defmodule Styler.Style.SingleNodeTest do """ case foo do bar = _ -> :ok + _ -> :error end """, """ case foo do bar -> :ok + _ -> :error end """ ) @@ -292,11 +294,13 @@ defmodule Styler.Style.SingleNodeTest do """ case foo do _ = bar -> :ok + _ = second_clause_to_maintain_case -> :ok end """, """ case foo do bar -> :ok + second_clause_to_maintain_case -> :ok end """ ) From be86c5b326465f421e1566efca56dd6c64644b58 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Tue, 9 Sep 2025 12:24:10 -0600 Subject: [PATCH 115/145] v1.8.0 --- CHANGELOG.md | 2 ++ README.md | 2 +- mix.exs | 2 +- test/style/blocks_test.exs | 28 +++++++++++++++------------- 4 files changed, 19 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63cbd13a..48c6d126 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ they can and will change without that change being reflected in Styler's semanti ## main +## 1.8.0 + ### Improvements Rewrite single-clause case statements to be assignments (h/t 🤖) diff --git a/README.md b/README.md index 13f7db2f..3dc76520 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Add `:styler` as a dependency to your project's `mix.exs`: ```elixir def deps do [ - {:styler, "~> 1.5", only: [:dev, :test], runtime: false}, + {:styler, "~> 1.8", only: [:dev, :test], runtime: false}, ] end ``` diff --git a/mix.exs b/mix.exs index dde179b3..c4af689c 100644 --- a/mix.exs +++ b/mix.exs @@ -12,7 +12,7 @@ defmodule Styler.MixProject do use Mix.Project # Don't forget to bump the README when doing non-patch version changes - @version "1.7.0" + @version "1.8.0" @url "https://github.com/adobe/elixir-styler" def project do diff --git a/test/style/blocks_test.exs b/test/style/blocks_test.exs index a951df1f..a4313a71 100644 --- a/test/style/blocks_test.exs +++ b/test/style/blocks_test.exs @@ -79,20 +79,22 @@ defmodule Styler.Style.BlocksTest do end test "singleton parent" do - assert_style """ - if foo do - case complex |> head() |> stuff() do - {:ok, whatever} -> - some_body(whatever) + assert_style( + """ + if foo do + case complex |> head() |> stuff() do + {:ok, whatever} -> + some_body(whatever) + end end - end - """, - """ - if foo do - {:ok, whatever} = complex |> head() |> stuff() - some_body(whatever) - end - """ + """, + """ + if foo do + {:ok, whatever} = complex |> head() |> stuff() + some_body(whatever) + end + """ + ) end test "when already an assignment" do From 1df3405f790df152e9c8e0990ab8a0ef99b095b5 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Fri, 19 Sep 2025 15:01:22 -0600 Subject: [PATCH 116/145] to_timeout: rewrite plurals to singulars --- CHANGELOG.md | 7 +++ docs/general_styles.md | 21 ++++++++ lib/style/deprecations.ex | 11 ++-- lib/style/single_node.ex | 93 ++++++++++++++++++++------------- test/style/single_node_test.exs | 9 +++- 5 files changed, 97 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48c6d126..66be92a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ they can and will change without that change being reflected in Styler's semanti ## main +### Improvements + +`to_timeout` improvements: + +- translate plural units to singular `to_timeout(hours: 1)` -> `to_timeout(hour: 1)` (plurals raise runtime errors) +- run transformations even when there are multiple args `to_timeout(hours: 24 * 1, seconds: 60 * 4)` -> `to_timeout(day: 1, minute: 4)`. this can result in a runtime error due to duplicate keys, as in the following scenario: `to_timeout(minute: 60, hours: 3)` -> `to_timeout(hour: 1, hour: 3)` + ## 1.8.0 ### Improvements diff --git a/docs/general_styles.md b/docs/general_styles.md index b7dbd12d..170c53ea 100644 --- a/docs/general_styles.md +++ b/docs/general_styles.md @@ -278,3 +278,24 @@ Additionally, styler rewrites some `Enum` functions inside `assert` | `assert Enum.member?(y, x)` | `assert x in y` | | `assert Enum.any?(y, & &1 == x)` | `assert x in y` | | `assert Enum.any?(y, fn var -> var == x end)` | `assert x in y` | + +## `to_timeout/1` + +- rewrites plural units to singular (plurals are invalid values and runtime errors, but they're oh-so-natural to write) +- steps units up to the next level + +```elixir +# before +to_timeout(second: 60 * m) +# styled +to_timeout(minute: m) +# before +to_timeout(second: 60) +# styled +to_timeout(minute: 1) + +# before +to_timeout(hours: 24 * 1, seconds: 60 * 4) +# styled +to_timeout(day: 1, minute: 4) +``` diff --git a/lib/style/deprecations.ex b/lib/style/deprecations.ex index 5563db14..32cd752d 100644 --- a/lib/style/deprecations.ex +++ b/lib/style/deprecations.ex @@ -60,12 +60,11 @@ defmodule Styler.Style.Deprecations do if Version.match?(System.version(), ">= 1.17.0-dev") do @to_timeout_vsn Version.parse!("1.17.0-dev") - for {erl, ex} <- [hours: :hour, minutes: :minute, seconds: :second] do - defp style({{:., _, [{:__block__, _, [:timer]}, unquote(erl)]}, fm, [x]} = node) do - if Styler.Config.version_compatible?(@to_timeout_vsn), - do: {:to_timeout, fm, [[{{:__block__, [format: :keyword, line: fm[:line]], [unquote(ex)]}, x}]]}, - else: node - end + # `unit` is plural for erlang, but single_node style rewrites plural to_timeouts into singulars + defp style({{:., _, [{:__block__, _, [:timer]}, unit]}, fm, [x]} = node) do + if Styler.Config.version_compatible?(@to_timeout_vsn), + do: {:to_timeout, fm, [[{{:__block__, [format: :keyword, line: fm[:line]], [unit]}, x}]]}, + else: node end end diff --git a/lib/style/single_node.ex b/lib/style/single_node.ex index 2540a8cb..237f98db 100644 --- a/lib/style/single_node.ex +++ b/lib/style/single_node.ex @@ -227,50 +227,71 @@ defmodule Styler.Style.SingleNode do defp style({:case, cm, [head, [{do_, arrows}]]}), do: {:case, cm, [head, [{do_, rewrite_arrows(arrows)}]]} defp style({:fn, m, arrows}), do: {:fn, m, rewrite_arrows(arrows)} - defp style({:to_timeout, meta, [[{{:__block__, um, [unit]}, {:*, _, [left, right]}}]]} = node) - when unit in ~w(day hour minute second millisecond)a do - [l, r] = - Enum.map([left, right], fn - {_, _, [x]} -> x - _ -> nil - end) + defp style({:to_timeout, m, [[_ | _] = args]}), do: {:to_timeout, m, [Enum.map(args, &style_to_timeout_arg/1)]} + + defp style(node), do: node - {step, next_unit} = + # 1. convert plurals to singulars (`minutes` -> `minute`) + # 2. upgrade values, eg `minute: 5 * 60` -> `hour: 5` and `minute: 60` -> `hour: 1` + defp style_to_timeout_arg({{:__block__, m, [unit]}, value}) do + unit = case unit do - :day -> {7, :week} - :hour -> {24, :day} - :minute -> {60, :hour} - :second -> {60, :minute} - :millisecond -> {1000, :second} + :days -> :day + :hours -> :hour + :milliseconds -> :millisecond + :minutes -> :minute + :seconds -> :second + :weeks -> :week + unit -> unit end - if step in [l, r] do - n = if l == step, do: right, else: left - style({:to_timeout, meta, [[{{:__block__, um, [next_unit]}, n}]]}) - else - node - end - end - - defp style({:to_timeout, meta, [[{{:__block__, um, [unit]}, {:__block__, tm, [n]}}]]} = node) do - step_up = - case {unit, n} do - {:day, 7} -> :week - {:hour, 24} -> :day - {:minute, 60} -> :hour - {:second, 60} -> :minute - {:millisecond, 1000} -> :second - _ -> nil + {unit, value} = + case value do + # minute: 60 -> hours: 1 + {:__block__, tm, [n]} -> + one = {:__block__, [token: "1", line: tm[:line]], [1]} + + case {unit, n} do + {:day, 7} -> {:week, one} + {:hour, 24} -> {:day, one} + {:minute, 60} -> {:hour, one} + {:second, 60} -> {:minute, one} + {:millisecond, 1000} -> {:second, one} + _ -> {unit, value} + end + + # minute: 5 * 60 -> hours: 5 + {:*, _, [left, right]} when unit in ~w(day hour minute second millisecond)a -> + {step, next_unit} = + case unit do + :day -> {7, :week} + :hour -> {24, :day} + :minute -> {60, :hour} + :second -> {60, :minute} + :millisecond -> {1000, :second} + end + + cond do + match?({_, _, [^step]}, left) -> + {{_, _, [unit]}, value} = style_to_timeout_arg({{:__block__, m, [next_unit]}, right}) + {unit, value} + + match?({_, _, [^step]}, right) -> + {{_, _, [unit]}, value} = style_to_timeout_arg({{:__block__, m, [next_unit]}, left}) + {unit, value} + + true -> + {unit, value} + end + + value -> + {unit, value} end - if step_up do - {:to_timeout, meta, [[{{:__block__, um, [step_up]}, {:__block__, [token: "1", line: tm[:line]], [1]}}]]} - else - node - end + {{:__block__, m, [unit]}, value} end - defp style(node), do: node + defp style_to_timeout_arg(other), do: other defp replace_into({:., dm, [{_, am, _} = enum, _]}, collectable, rest) do case collectable do diff --git a/test/style/single_node_test.exs b/test/style/single_node_test.exs index 0570f0ff..9312824e 100644 --- a/test/style/single_node_test.exs +++ b/test/style/single_node_test.exs @@ -423,10 +423,15 @@ defmodule Styler.Style.SingleNodeTest do assert_style "to_timeout(second: 60 * 60)", "to_timeout(hour: 1)" end - test "doesnt mess with" do + test "plurals and multiples oh my" do + assert_style "to_timeout(hours: 24 * 1, seconds: 60 * 4)", "to_timeout(day: 1, minute: 4)" + # nb: this'll raise an argument error after styling. + assert_style "to_timeout(minute: 60, hours: 3)", "to_timeout(hour: 1, hour: 3)" + end + + test "doesnt mess with non-integers" do assert_style "to_timeout(hour: n * m)" assert_style "to_timeout(whatever)" - assert_style "to_timeout(hour: 24 * 1, second: 60 * 4)" end end end From cf55c61064ae5ba95f7c647fe667fe9bb535ecf0 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Fri, 19 Sep 2025 15:23:52 -0600 Subject: [PATCH 117/145] =?UTF-8?q?=F0=9F=98=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/style/single_node.ex | 57 ++++++++++++++++------------------------ 1 file changed, 22 insertions(+), 35 deletions(-) diff --git a/lib/style/single_node.ex b/lib/style/single_node.ex index 237f98db..71572fef 100644 --- a/lib/style/single_node.ex +++ b/lib/style/single_node.ex @@ -245,44 +245,31 @@ defmodule Styler.Style.SingleNode do unit -> unit end + {step, next_unit} = + case unit do + :day -> {7, :week} + :hour -> {24, :day} + :minute -> {60, :hour} + :second -> {60, :minute} + :millisecond -> {1000, :second} + _ -> {:"$no_next_step", nil} + end + {unit, value} = case value do # minute: 60 -> hours: 1 - {:__block__, tm, [n]} -> - one = {:__block__, [token: "1", line: tm[:line]], [1]} - - case {unit, n} do - {:day, 7} -> {:week, one} - {:hour, 24} -> {:day, one} - {:minute, 60} -> {:hour, one} - {:second, 60} -> {:minute, one} - {:millisecond, 1000} -> {:second, one} - _ -> {unit, value} - end - - # minute: 5 * 60 -> hours: 5 - {:*, _, [left, right]} when unit in ~w(day hour minute second millisecond)a -> - {step, next_unit} = - case unit do - :day -> {7, :week} - :hour -> {24, :day} - :minute -> {60, :hour} - :second -> {60, :minute} - :millisecond -> {1000, :second} - end - - cond do - match?({_, _, [^step]}, left) -> - {{_, _, [unit]}, value} = style_to_timeout_arg({{:__block__, m, [next_unit]}, right}) - {unit, value} - - match?({_, _, [^step]}, right) -> - {{_, _, [unit]}, value} = style_to_timeout_arg({{:__block__, m, [next_unit]}, left}) - {unit, value} - - true -> - {unit, value} - end + {:__block__, tm, [^step]} -> + {next_unit, {:__block__, [token: "1", line: tm[:line]], [1]}} + + # minute: 60 * rhs -> hours: rhs + {:*, _, [{_, _, [^step]}, rhs]} -> + {{_, _, [next_unit]}, value} = style_to_timeout_arg({{:__block__, m, [next_unit]}, rhs}) + {next_unit, value} + + # minute: lhs * 60 -> hours: lhs + {:*, _, [lhs, {_, _, [^step]}]} -> + {{_, _, [next_unit]}, value} = style_to_timeout_arg({{:__block__, m, [next_unit]}, lhs}) + {next_unit, value} value -> {unit, value} From f9d5179317272e590c9e6999b694477e9d8bcd56 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Fri, 19 Sep 2025 15:31:50 -0600 Subject: [PATCH 118/145] one last bit of nerd --- lib/style/single_node.ex | 32 ++++++++++++++------------------ 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/lib/style/single_node.ex b/lib/style/single_node.ex index 71572fef..151776de 100644 --- a/lib/style/single_node.ex +++ b/lib/style/single_node.ex @@ -234,25 +234,21 @@ defmodule Styler.Style.SingleNode do # 1. convert plurals to singulars (`minutes` -> `minute`) # 2. upgrade values, eg `minute: 5 * 60` -> `hour: 5` and `minute: 60` -> `hour: 1` defp style_to_timeout_arg({{:__block__, m, [unit]}, value}) do - unit = + {unit, step, next_unit} = case unit do - :days -> :day - :hours -> :hour - :milliseconds -> :millisecond - :minutes -> :minute - :seconds -> :second - :weeks -> :week - unit -> unit - end - - {step, next_unit} = - case unit do - :day -> {7, :week} - :hour -> {24, :day} - :minute -> {60, :hour} - :second -> {60, :minute} - :millisecond -> {1000, :second} - _ -> {:"$no_next_step", nil} + :day -> {:day, 7, :week} + :days -> {:day, 7, :week} + :hour -> {:hour, 24, :day} + :hours -> {:hour, 24, :day} + :millisecond -> {:millisecond, 1000, :second} + :milliseconds -> {:millisecond, 1000, :second} + :minute -> {:minute, 60, :hour} + :minutes -> {:minute, 60, :hour} + :second -> {:second, 60, :minute} + :seconds -> {:second, 60, :minute} + :week -> {:week, :"$no_next_step", nil} + :weeks -> {:week, :"$no_next_step", nil} + unit -> {unit, :"$no_next_step", nil} end {unit, value} = From 0e7b9779b8d922c7ea31c0af7294f2c12f289f06 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Mon, 22 Sep 2025 13:31:01 -0600 Subject: [PATCH 119/145] fix :timer.foo regression --- lib/style/deprecations.ex | 2 +- test/style/deprecations_test.exs | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/style/deprecations.ex b/lib/style/deprecations.ex index 32cd752d..6b75edf0 100644 --- a/lib/style/deprecations.ex +++ b/lib/style/deprecations.ex @@ -61,7 +61,7 @@ defmodule Styler.Style.Deprecations do if Version.match?(System.version(), ">= 1.17.0-dev") do @to_timeout_vsn Version.parse!("1.17.0-dev") # `unit` is plural for erlang, but single_node style rewrites plural to_timeouts into singulars - defp style({{:., _, [{:__block__, _, [:timer]}, unit]}, fm, [x]} = node) do + defp style({{:., _, [{:__block__, _, [:timer]}, unit]}, fm, [x]} = node) when unit in ~w(hours minutes seconds)a do if Styler.Config.version_compatible?(@to_timeout_vsn), do: {:to_timeout, fm, [[{{:__block__, [format: :keyword, line: fm[:line]], [unit]}, x}]]}, else: node diff --git a/test/style/deprecations_test.exs b/test/style/deprecations_test.exs index 4e6863e3..ff6ffab9 100644 --- a/test/style/deprecations_test.exs +++ b/test/style/deprecations_test.exs @@ -151,5 +151,10 @@ defmodule Styler.Style.DeprecationsTest do test "combined with to_timeout improvements" do assert_style ":timer.minutes(60 * 4)", "to_timeout(hour: 4)" end + + test "regression: doesn't touch other timer functions" do + assert_style ":timer.sleep(1000)" + assert_style ":timer.tc(fn -> :ok end)" + end end end From 6722b195cc4e9c58ddc1ea9f7fe8e3aa4348588d Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Mon, 22 Sep 2025 13:32:59 -0600 Subject: [PATCH 120/145] all the work in one spot --- lib/style/deprecations.ex | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/style/deprecations.ex b/lib/style/deprecations.ex index 6b75edf0..7fdd309e 100644 --- a/lib/style/deprecations.ex +++ b/lib/style/deprecations.ex @@ -60,11 +60,13 @@ defmodule Styler.Style.Deprecations do if Version.match?(System.version(), ">= 1.17.0-dev") do @to_timeout_vsn Version.parse!("1.17.0-dev") - # `unit` is plural for erlang, but single_node style rewrites plural to_timeouts into singulars defp style({{:., _, [{:__block__, _, [:timer]}, unit]}, fm, [x]} = node) when unit in ~w(hours minutes seconds)a do - if Styler.Config.version_compatible?(@to_timeout_vsn), - do: {:to_timeout, fm, [[{{:__block__, [format: :keyword, line: fm[:line]], [unit]}, x}]]}, - else: node + if Styler.Config.version_compatible?(@to_timeout_vsn) do + unit = unit |> Atom.to_string() |> String.trim_trailing("s") |> String.to_atom() + {:to_timeout, fm, [[{{:__block__, [format: :keyword, line: fm[:line]], [unit]}, x}]]} + else + node + end end end From 8c5d9193d0fdae8711e5fede867428b40563e25d Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Mon, 22 Sep 2025 13:37:37 -0600 Subject: [PATCH 121/145] v1.9.0 --- CHANGELOG.md | 10 ++++++++-- README.md | 2 +- mix.exs | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 66be92a2..ae54654a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,12 +5,18 @@ they can and will change without that change being reflected in Styler's semanti ## main +## 1.9.0 + +This was a weird one, but I found myself often writing `to_timeout` with plural units and then having to go back and fix +the code to be singular units instead. Polling a few colleagues, it seemed I wasn't alone in that mistake. So for the first time, +Styler will correct code that would otherwise produce a runtime error, saving you from flow-breaking backtracking. + ### Improvements `to_timeout` improvements: -- translate plural units to singular `to_timeout(hours: 1)` -> `to_timeout(hour: 1)` (plurals raise runtime errors) -- run transformations even when there are multiple args `to_timeout(hours: 24 * 1, seconds: 60 * 4)` -> `to_timeout(day: 1, minute: 4)`. this can result in a runtime error due to duplicate keys, as in the following scenario: `to_timeout(minute: 60, hours: 3)` -> `to_timeout(hour: 1, hour: 3)` +- translate plural units to singular `to_timeout(hours: 2)` -> `to_timeout(hour: 2)` (plurals are valid ast, but invalid arguments to this function) +- transform when there are multiple keys: `to_timeout(hours: 24 * 1, seconds: 60 * 4)` -> `to_timeout(day: 1, minute: 4)`. **this can introduce runtime bugs** due to duplicate keys, as in the following scenario: `to_timeout(minute: 60, hours: 3)` -> `to_timeout(hour: 1, hour: 3)` ## 1.8.0 diff --git a/README.md b/README.md index 3dc76520..1fe3bf3c 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Add `:styler` as a dependency to your project's `mix.exs`: ```elixir def deps do [ - {:styler, "~> 1.8", only: [:dev, :test], runtime: false}, + {:styler, "~> 1.9", only: [:dev, :test], runtime: false}, ] end ``` diff --git a/mix.exs b/mix.exs index c4af689c..0a266df8 100644 --- a/mix.exs +++ b/mix.exs @@ -12,7 +12,7 @@ defmodule Styler.MixProject do use Mix.Project # Don't forget to bump the README when doing non-patch version changes - @version "1.8.0" + @version "1.9.0" @url "https://github.com/adobe/elixir-styler" def project do From c96d7220c9129a0352519f3e967cc3c0386d8481 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Wed, 24 Sep 2025 09:20:40 -0600 Subject: [PATCH 122/145] add changelog link to hex package --- README.md | 6 ++---- mix.exs | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 1fe3bf3c..bcb2a915 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,9 @@ You can learn more about the history, purpose and implementation of Styler from ## Features -Styler fixes a plethora of Elixir style and optimization issues automatically as part of mix format. +[Styler's full feature documentation can be found on Hexdocs.](https://hexdocs.pm/styler/styles.html) -[See Styler's documentation on Hex](https://hexdocs.pm/styler/styles.html) for the comprehensive list of its features. +Styler fixes a plethora of Elixir style and optimization issues automatically as part of mix format. The fastest way to see what all it can do you for you is to just try it out in your codebase... but here's a list of a few features to help you decide if you're interested in Styler: @@ -24,8 +24,6 @@ The fastest way to see what all it can do you for you is to just try it out in y - rewrites deprecated Elixir standard library code, speeding adoption of new releases - auto-fixes many credo rules, meaning you can spend less time fighting with CI -[Here's another link to features Table of Contents for you](https://hexdocs.pm/styler/styles.html) - ## Who is Styler for? > I'm just excited to be on a team that uses Styler and moves on diff --git a/mix.exs b/mix.exs index 0a266df8..658b8f75 100644 --- a/mix.exs +++ b/mix.exs @@ -49,7 +49,7 @@ defmodule Styler.MixProject do [ maintainers: ["Matt Enlow", "Greg Mefford"], licenses: ["Apache-2.0"], - links: %{"GitHub" => @url} + links: %{"GitHub" => @url, "Changelog" => "#{@url}/blob/main/CHANGELOG.md"} ] end From dcfaa74ce81a17659e8b42d55fb180e6dd1ef865 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Thu, 25 Sep 2025 11:44:09 -0600 Subject: [PATCH 123/145] readme updates --- README.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index bcb2a915..609f7967 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,8 @@ # Styler -Styler is an Elixir formatter plugin that's combination of `mix format` and `mix credo`, except instead of telling -you what's wrong, it just rewrites the code for you to fit its style rules. - -You can learn more about the history, purpose and implementation of Styler from our talk: [Styler: Elixir Style-Guide Enforcer @ GigCity Elixir 2023](https://www.youtube.com/watch?v=6pF8Hl5EuD4) +Styler is an Elixir formatter plugin that's a combination of `mix format` and `mix credo`, but instead of _telling_ +you what's wrong, it just fixes the code for you to fit its style rules. ## Features From 81bb7ef4385b497a31ee5cafdc69ed25b097a640 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Thu, 25 Sep 2025 11:49:18 -0600 Subject: [PATCH 124/145] more readme tweakin --- README.md | 25 ++++++++----------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 609f7967..b1ef983c 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,7 @@ # Styler -Styler is an Elixir formatter plugin that's a combination of `mix format` and `mix credo`, but instead of _telling_ -you what's wrong, it just fixes the code for you to fit its style rules. +Styler is an Elixir formatter plugin that goes beyond formatting by rewriting your code for consistency, readability, and optimization. ## Features @@ -86,11 +85,13 @@ However, Smartrent has a fork of Styler named [Quokka](https://github.com/smartr Ultimately Styler is @adobe's internal tool that we're happy to share with the world. We're delighted if you like it as is, and just as excited if it's a starting point for you to make something even better for yourself. -## WARNING: Styler can change the behaviour of your program! +## WARNING: Styler can change the behaviour of your program -In some cases, this can introduce bugs. It goes without saying, but look over your changes before committing to main :) +While Styler endeavors to never purposefully create bugs, some of its rewrites can introduce them in obscure cases. -A simple example of a way Styler changes the behaviour of code is the following rewrite: +It goes without saying, but look over any changes Styler writes before committing to main. + +A simple example of a way Styler rewrite can introduce a bug is the following case statement: ```elixir # Before: this case statement... @@ -109,19 +110,9 @@ end These programs are not semantically equivalent. The former would raise if `foo` returned any value other than `true` or `false`, while the latter blissfully completes. -However, Styler is about _style_, and the `if` statement is (in our opinion) of much better style. If the exception behaviour was intentional on the code author's part, they should have written the program like this: - -```elixir -case foo do - true -> :ok - false -> :error - other -> raise "expected `true` or `false`, got: #{inspect other}" -end -``` - -Also good style! But Styler assumes that most of the time people just meant the `if` equivalent of the code, and so makes that change. If issues like this bother you, Styler probably isn't the tool you're looking for. +If issues like this bother you, Styler probably isn't the tool you're looking for. -Other ways Styler can change your program: +Other ways Styler _could_ introduce runtime bugs: - [`with` statement rewrites](https://github.com/adobe/elixir-styler/issues/186) - [config file sorting](https://hexdocs.pm/styler/mix_configs.html#this-can-break-your-program) From 78f35eb4afafee0fde0a04b31eac0c65346061c9 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Tue, 30 Sep 2025 10:48:57 -0600 Subject: [PATCH 125/145] Fix rewrite of single-clause case statement with assignment parent. Closes #247 --- CHANGELOG.md | 6 +++++ lib/style/blocks.ex | 15 +++++++++--- test/style/blocks_test.exs | 50 ++++++++++++++++++++++++++++++++------ test/style/pipes_test.exs | 6 ++--- 4 files changed, 63 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae54654a..6a931c10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ they can and will change without that change being reflected in Styler's semanti ## main +## 1.9.1 + +### Fix + +- fixes rewrites of single-clause case statement with assignment parent (Closes #247, h/t @vasspilka) + ## 1.9.0 This was a weird one, but I found myself often writing `to_timeout` with plural units and then having to go back and fix diff --git a/lib/style/blocks.ex b/lib/style/blocks.ex index 426becc4..216930ba 100644 --- a/lib/style/blocks.ex +++ b/lib/style/blocks.ex @@ -57,18 +57,25 @@ defmodule Styler.Style.Blocks do zipper = case Zipper.up(zipper) do - {{:=, am, [parent_lhs, _single_clause_case]}, _} = zipper -> - # this was a `x = case head, do: (lhs -> rhs)`. make it `x = lhs = head; rhs` + {{:=, am, [parent_lhs, _case_statement_rhs]}, _} = zipper -> + # this was a `x = case head, do: (lhs -> rhs)`. make it `x = lhs = head; x = rhs` meta = [line: am[:line]] - Zipper.replace(zipper, {:=, meta, [parent_lhs, {:=, meta, [lhs, head]}]}) + # change the if there are multiple clauses in the case, have the final one be the new rhs of parent_lhs + # the meta for the final equality has an incorrect line, but it shouldn't mess with comments so leaving it be + siblings = List.update_at(rhs, -1, &{:=, meta, [parent_lhs, &1]}) + + zipper + |> Zipper.replace({:=, meta, [lhs, head]}) + |> Zipper.insert_siblings(siblings) _ -> zipper |> Style.find_nearest_block() |> Zipper.replace({:=, [line: m[:line]], [lhs, head]}) + |> Zipper.insert_siblings(rhs) end - {:cont, Zipper.insert_siblings(zipper, rhs), ctx} + {:cont, zipper, ctx} end def run({{:cond, _, [[{do_, clauses}]]}, _} = zipper, ctx) do diff --git a/test/style/blocks_test.exs b/test/style/blocks_test.exs index a4313a71..1c0240af 100644 --- a/test/style/blocks_test.exs +++ b/test/style/blocks_test.exs @@ -100,18 +100,54 @@ defmodule Styler.Style.BlocksTest do test "when already an assignment" do assert_style( """ - m + preroll - x = - case a do - b -> c + assignment = + case head do + lhs -> body end """, """ - m + preroll - x = b = a - c + lhs = head + assignment = body + """ + ) + + assert_style( + """ + # assignmet + assignment = + # case head + case head do + # lhs + lhs -> + # body + body + # clauses + fn -> + look + im(a) + # big function + big function + end + end + """, + """ + # assignmet + # case head + # lhs + lhs = head + # body + body + # clauses + assignment = fn -> + look + im(a) + # big function + big(function) + end """ ) end diff --git a/test/style/pipes_test.exs b/test/style/pipes_test.exs index eed450ea..e5292b49 100644 --- a/test/style/pipes_test.exs +++ b/test/style/pipes_test.exs @@ -283,13 +283,13 @@ defmodule Styler.Style.PipesTest do assert_style( """ case x do - x -> x + y -> z end |> foo() """, """ - case_result = x = x - x + y = x + case_result = z foo(case_result) """ ) From bb0cde8587b0084b4623e3a4678a66fe72f6af06 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Tue, 30 Sep 2025 10:49:45 -0600 Subject: [PATCH 126/145] v1.9.1 --- mix.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mix.exs b/mix.exs index 658b8f75..6986a9b0 100644 --- a/mix.exs +++ b/mix.exs @@ -12,7 +12,7 @@ defmodule Styler.MixProject do use Mix.Project # Don't forget to bump the README when doing non-patch version changes - @version "1.9.0" + @version "1.9.1" @url "https://github.com/adobe/elixir-styler" def project do From 793cf27938a99c1492b0ce611519d91fda68311a Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Thu, 30 Oct 2025 13:00:39 -0600 Subject: [PATCH 127/145] optimize Req pipes --- CHANGELOG.md | 17 +++++++++++++++++ docs/pipes.md | 20 ++++++++++++++++++++ lib/style/pipes.ex | 24 ++++++++++++++++++++++++ test/style/pipes_test.exs | 27 +++++++++++++++++++++++++++ 4 files changed, 88 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a931c10..d5dd40a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ they can and will change without that change being reflected in Styler's semanti ## main +### Improvements + +#### Req pipe optimizations + +[Req](https://github.com/wojtekmach/req) is a popular HTTP Client. If you aren't using it, you can just ignore this whole section! + +Reqs 1-arity "execute the request" functions (`delete get head patch post put request run`) have a 2-arity version that takes a superset of the arguments `Req.new/1` does as its first argument, and the typical `options` keyword list as its second argument. And so, many places developers are calling a 1-arity function can be replaced with a 2-arity function. + +More succinctly, these two statements are equivalent: + +- `foo |> Req.new() |> Req.merge(bar) |> Req.post!()` +- `Req.post!(foo, bar)` + +Styler now rewrites the former to the latter, since "less is more" or "code is a liability". + +It also rewrites `|> Keyword.merge(bar) |> Req.foo()` to `|> Req.foo(bar)`. **This changes the program's behaviour**, since `Keyword.merge` would overwrite existing values in all cases, whereas `Req` 2-arity functions intelligently deep-merge values for some keys, like `:headers`. + ## 1.9.1 ### Fix diff --git a/docs/pipes.md b/docs/pipes.md index 1479e5e6..ad00b499 100644 --- a/docs/pipes.md +++ b/docs/pipes.md @@ -153,3 +153,23 @@ d(c(a |> b)) # At which point Styler will pipe-ify the entire chain a |> b() |> c() |> d() ``` + +## Req + +[Req](https://github.com/wojtekmach/req) is a popular HTTP Client. If you aren't using it, you can just ignore this whole section! + +Styler ensures a minimal number of functions are being called when using any Req 1-arity execution functions (`delete get head patch post put request run` and their bangified versions). + +```elixir +# before +keyword |> Req.new() |> Req.merge(opts) |> Req.post!() +# Styled: +Req.post!(keyword, opts) + +# before +foo |> Keyword.merge(opts) |> Req.head() +# Styled: +Req.head(foo, opts) +``` + +**This changes the program's behaviour**, since `Keyword.merge` would overwrite existing values in all cases, whereas `Req` 2-arity functions intelligently deep-merge values for some keys, like `:headers`. diff --git a/lib/style/pipes.ex b/lib/style/pipes.ex index 661d9aaa..6587b485 100644 --- a/lib/style/pipes.ex +++ b/lib/style/pipes.ex @@ -413,6 +413,30 @@ defmodule Styler.Style.Pipes do {:|>, pm, [lhs, {Style.set_line(new, em[:line]), em, [mapper]}]} end + @req2 for fun <- ~w(delete get head patch post put request run), bang <- ["", "!"], fun = :"#{fun}#{bang}", do: fun + + # rewrite `Keyword.merge(opt) |> Req.fun1()` to `Req.fun2(opt)` for 2 arity functions that take `opts` as a second arg + defp fix_pipe( + pipe_chain( + pm, + lhs, + {{:., _, [{_, _, [req_or_kw]}, :merge]}, m, [kw]}, + {{:., _, [{_, _, [:Req]}, fun]} = req, _, []} + ) + ) + when req_or_kw in [:Req, :Keyword] and fun in @req2 do + fix_pipe({:|>, pm, [lhs, {req, m, [kw]}]}) + end + + # Req.new |> Req.fun1,2 -> Req.fun1,2 + # all `fun` options take the same args as `Req.new`, so it's redundant to call Req.new before them + defp fix_pipe( + pipe_chain(pm, lhs, {{:., _, [{_, _, [:Req]}, :new]}, m, []}, {{:., _, [{_, _, [:Req]}, fun]} = req, _, args}) + ) + when fun in @req2 do + {:|>, pm, [lhs, {req, m, args}]} + end + defp fix_pipe(node), do: node defp valid_pipe_start?({op, _, _}) when op in @special_ops, do: true diff --git a/test/style/pipes_test.exs b/test/style/pipes_test.exs index e5292b49..7f388a18 100644 --- a/test/style/pipes_test.exs +++ b/test/style/pipes_test.exs @@ -1004,4 +1004,31 @@ defmodule Styler.Style.PipesTest do ) end end + + describe "Req pipes" do + @req2 for fun <- ~w(delete get head patch post put request run), bang <- ["", "!"], fun = :"#{fun}#{bang}", do: fun + + test "X.merge |> Req.foo/1 -> Req.foo/2" do + for fun <- @req2, merger <- ~w(Req Keyword) do + assert_style "foo |> #{merger}.merge(opts) |> Req.#{fun}()", "Req.#{fun}(foo, opts)" + assert_style "a |> b |> #{merger}.merge(opts) |> Req.#{fun}()", "a |> b() |> Req.#{fun}(opts)" + end + end + + test "Req.new |> Req.foo/1 -> Req.foo/2" do + for fun <- @req2 do + assert_style "foo |> Req.new() |> Req.#{fun}()", "Req.#{fun}(foo)" + assert_style "a |> b |> Req.new() |> Req.#{fun}()", "a |> b() |> Req.#{fun}()" + assert_style "foo |> Req.new() |> Req.#{fun}(c)", "Req.#{fun}(foo, c)" + assert_style "a |> b |> Req.new() |> Req.#{fun}(c)", "a |> b() |> Req.#{fun}(c)" + end + end + + test "new |> merge |> foo" do + for fun <- @req2 do + assert_style "foo |> Req.new() |> Req.merge(bar) |> Req.#{fun}()", "Req.#{fun}(foo, bar)" + assert_style "a |> b() |> Req.new() |> Req.merge(bar) |> Req.#{fun}()", "a |> b() |> Req.#{fun}(bar)" + end + end + end end From e48ca6cada3d7cd90b624609827ef0af26bf96fe Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Thu, 30 Oct 2025 13:05:56 -0600 Subject: [PATCH 128/145] less is more --- lib/style/pipes.ex | 2 +- test/style/pipes_test.exs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/style/pipes.ex b/lib/style/pipes.ex index 6587b485..20e61500 100644 --- a/lib/style/pipes.ex +++ b/lib/style/pipes.ex @@ -413,7 +413,7 @@ defmodule Styler.Style.Pipes do {:|>, pm, [lhs, {Style.set_line(new, em[:line]), em, [mapper]}]} end - @req2 for fun <- ~w(delete get head patch post put request run), bang <- ["", "!"], fun = :"#{fun}#{bang}", do: fun + @req2 for fun <- ~w(delete get head patch post put request run), bang <- ["", "!"], do: :"#{fun}#{bang}" # rewrite `Keyword.merge(opt) |> Req.fun1()` to `Req.fun2(opt)` for 2 arity functions that take `opts` as a second arg defp fix_pipe( diff --git a/test/style/pipes_test.exs b/test/style/pipes_test.exs index 7f388a18..0f93026c 100644 --- a/test/style/pipes_test.exs +++ b/test/style/pipes_test.exs @@ -1006,7 +1006,7 @@ defmodule Styler.Style.PipesTest do end describe "Req pipes" do - @req2 for fun <- ~w(delete get head patch post put request run), bang <- ["", "!"], fun = :"#{fun}#{bang}", do: fun + @req2 for fun <- ~w(delete get head patch post put request run), bang <- ["", "!"], do: :"#{fun}#{bang}" test "X.merge |> Req.foo/1 -> Req.foo/2" do for fun <- @req2, merger <- ~w(Req Keyword) do From 1a6a375a6fc2148f778d5f9da04b8f5b6bbcc5b1 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Mon, 3 Nov 2025 17:38:40 -0700 Subject: [PATCH 129/145] tweak intro sentence --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b1ef983c..5f7f4958 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # Styler -Styler is an Elixir formatter plugin that goes beyond formatting by rewriting your code for consistency, readability, and optimization. +Styler is an Elixir formatter plugin that goes beyond formatting by rewriting your code to optimize for consistency, readability, and performance. ## Features From 78ced6b4dc2c72df34dbb44973555bc8dfcf3e36 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Tue, 4 Nov 2025 13:58:05 -0500 Subject: [PATCH 130/145] TIL capital sigils cant be escaped --- lib/style/single_node.ex | 2 +- test/style/single_node_test.exs | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/lib/style/single_node.ex b/lib/style/single_node.ex index 151776de..a00b7fdf 100644 --- a/lib/style/single_node.ex +++ b/lib/style/single_node.ex @@ -92,7 +92,7 @@ defmodule Styler.Style.SingleNode do |> Stream.concat(@closing_delimiters) |> Enum.frequencies() |> Enum.min_by(fn - {~s|"|, count} -> {count, 1} + {"\"", count} -> {count, 1} {")", count} -> {count, 2} {"}", count} -> {count, 3} {"|", count} -> {count, 4} diff --git a/test/style/single_node_test.exs b/test/style/single_node_test.exs index 9312824e..28a49310 100644 --- a/test/style/single_node_test.exs +++ b/test/style/single_node_test.exs @@ -71,17 +71,17 @@ defmodule Styler.Style.SingleNodeTest do test "string sigil rewrites" do assert_style ~s|""| - assert_style ~s|"\\""| - assert_style ~s|"\\"\\""| - assert_style ~s|"\\"\\"\\""| - assert_style ~s|"\\"\\"\\"\\""|, ~s|~s("""")| + assert_style ~S|"\""| + assert_style ~S|"\"\""| + assert_style ~S|"\"\"\""| + assert_style ~S|"\"\"\"\""|, ~s|~s("""")| # choose closing delimiter wisely, based on what has the least conflicts, in the styliest order - assert_style ~s/"\\"\\"\\"\\" )"/, ~s/~s{"""" )}/ - assert_style ~s/"\\"\\"\\"\\" })"/, ~s/~s|"""" })|/ - assert_style ~s/"\\"\\"\\"\\" |})"/, ~s/~s["""" |})]/ - assert_style ~s/"\\"\\"\\"\\" ]|})"/, ~s/~s'"""" ]|})'/ - assert_style ~s/"\\"\\"\\"\\" ']|})"/, ~s/~s<"""" ']|})>/ - assert_style ~s/"\\"\\"\\"\\" >']|})"/, ~s|~s/"""" >']\|})/| + assert_style ~S/"\"\"\"\" )"/, ~s/~s{"""" )}/ + assert_style ~S/"\"\"\"\" })"/, ~s/~s|"""" })|/ + assert_style ~S/"\"\"\"\" |})"/, ~s/~s["""" |})]/ + assert_style ~S/"\"\"\"\" ]|})"/, ~s/~s'"""" ]|})'/ + assert_style ~S/"\"\"\"\" ']|})"/, ~s/~s<"""" ']|})>/ + assert_style ~S/"\"\"\"\" >']|})"/, ~s|~s/"""" >']\|})/| assert_style ~s/"\\"\\"\\"\\" \/>']|})"/, ~s|~s("""" />']\|}\\))| end From a490ad68bce097da65b0258f850a702035238c87 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Tue, 18 Nov 2025 10:59:48 -0700 Subject: [PATCH 131/145] sort |> reverse => sort(:desc) --- CHANGELOG.md | 3 +++ docs/pipes.md | 4 ++++ lib/style/pipes.ex | 17 +++++++++++++++++ test/style/pipes_test.exs | 7 +++++++ 4 files changed, 31 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5dd40a7..fdb7163c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ they can and will change without that change being reflected in Styler's semanti ### Improvements +- `enum |> Enum.sort() |> Enum.reverse()` => `Enum.sort(enum, :desc)` +- Req pipe optimizations (see below!) + #### Req pipe optimizations [Req](https://github.com/wojtekmach/req) is a popular HTTP Client. If you aren't using it, you can just ignore this whole section! diff --git a/docs/pipes.md b/docs/pipes.md index ad00b499..b367bae3 100644 --- a/docs/pipes.md +++ b/docs/pipes.md @@ -110,6 +110,10 @@ a |> Enum.filter(fun) |> List.first(default) |> ... # Styled: a |> Enum.find(fun) |> ... a |> Enum.find(default, fun) |> ... + +# Given: +a |> Enum.sort() |> Enum.reverse() |> ... +a |> Enum.sort(:desc) |> ... ``` ## Unpiping Single Pipes diff --git a/lib/style/pipes.ex b/lib/style/pipes.ex index 20e61500..1b813d5f 100644 --- a/lib/style/pipes.ex +++ b/lib/style/pipes.ex @@ -294,6 +294,23 @@ defmodule Styler.Style.Pipes do defp fix_pipe({:|>, m, [lhs, {{:., m2, [{anon_fun, _, _}] = fun}, _, []}]}) when anon_fun in [:&, :fn], do: {:|>, m, [lhs, {:then, m2, fun}]} + # `lhs |> Enum.sort([:asc, :desc]) |> Enum.reverse()` => `lhs |> Enum.sort(:desc | :asc)` + defp fix_pipe( + pipe_chain( + pm, + lhs, + {{:., _, [{_, _, [:Enum]}, :sort]} = sort, meta, sort_args}, + {{:., _, [{_, _, [:Enum]}, :reverse]}, _, []} + ) = node + ) do + case sort_args do + [] -> {:|>, pm, [lhs, {sort, meta, [{:__block__, [line: meta[:line]], [:desc]}]}]} + [{_, m, [:desc]}] -> {:|>, pm, [lhs, {sort, meta, [{:__block__, m, [:asc]}]}]} + [{_, m, [:asc]}] -> {:|>, pm, [lhs, {sort, meta, [{:__block__, m, [:desc]}]}]} + _ -> node + end + end + # `lhs |> Enum.reverse() |> Enum.concat(enum)` => `lhs |> Enum.reverse(enum)` defp fix_pipe( pipe_chain( diff --git a/test/style/pipes_test.exs b/test/style/pipes_test.exs index 0f93026c..6de413a4 100644 --- a/test/style/pipes_test.exs +++ b/test/style/pipes_test.exs @@ -576,6 +576,13 @@ defmodule Styler.Style.PipesTest do ) end + test "Enum.sort/Enum.reverse" do + assert_style("a |> Enum.sort(direction) |> Enum.reverse()") + assert_style("a |> Enum.sort(:asc) |> Enum.reverse()", "Enum.sort(a, :desc)") + assert_style("a |> Enum.sort(:desc) |> Enum.reverse()", "Enum.sort(a, :asc)") + assert_style("a |> Enum.sort() |> Enum.reverse()", "Enum.sort(a, :desc)") + end + test "reverse/concat" do assert_style("a |> Enum.reverse() |> Enum.concat()") assert_style("a |> Enum.reverse(bar) |> Enum.concat()") From 7884561a1294c3fbe36689c40851bab444cda078 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Tue, 2 Dec 2025 09:55:05 -0700 Subject: [PATCH 132/145] allow docs for Styler.string_to_ast --- lib/styler.ex | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/styler.ex b/lib/styler.ex index dc984ba4..cff2b17c 100644 --- a/lib/styler.ex +++ b/lib/styler.ex @@ -75,8 +75,7 @@ defmodule Styler do ast_to_string(ast, comments, formatter_opts) end - @doc false - # Wrap `Code.string_to_quoted_with_comments` with our desired options + @doc "Just `Code.string_to_quoted_with_comments/2` with the necessary options" def string_to_ast(code, file \\ "nofile") when is_binary(code) do Code.string_to_quoted_with_comments!(code, literal_encoder: &__MODULE__.literal_encoder/2, From 2af7d19948f2bfd78780ec38896086daebf048e1 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Tue, 2 Dec 2025 10:34:49 -0700 Subject: [PATCH 133/145] Enum.map |> Enum.intersperse => Enum.map_intersperse --- CHANGELOG.md | 6 +++- docs/pipes.md | 4 +++ lib/style/pipes.ex | 58 +++++++++++++++++++-------------------- lib/styler.ex | 2 +- test/style/pipes_test.exs | 44 +++++++++++++++++++++-------- 5 files changed, 71 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fdb7163c..fdad8961 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,12 @@ they can and will change without that change being reflected in Styler's semanti ### Improvements +Two new standard-library pipe optimizations + +- `enum |> Enum.map(fun) |> Enum.intersperse(separator)` => `Enum.map_intersperse(enum, separator, fun)` - `enum |> Enum.sort() |> Enum.reverse()` => `Enum.sort(enum, :desc)` -- Req pipe optimizations (see below!) + +And Req (the http client library) pipe optimizations, as detailed below #### Req pipe optimizations diff --git a/docs/pipes.md b/docs/pipes.md index b367bae3..a19b3bd6 100644 --- a/docs/pipes.md +++ b/docs/pipes.md @@ -114,6 +114,10 @@ a |> Enum.find(default, fun) |> ... # Given: a |> Enum.sort() |> Enum.reverse() |> ... a |> Enum.sort(:desc) |> ... + +# Given: +a |> Enum.map(fun) |> Enum.intersperse(separator) |> ... +a |> Enum.map_intersperse(separator, fun) |> ... ``` ## Unpiping Single Pipes diff --git a/lib/style/pipes.ex b/lib/style/pipes.ex index 1b813d5f..8824322d 100644 --- a/lib/style/pipes.ex +++ b/lib/style/pipes.ex @@ -311,6 +311,17 @@ defmodule Styler.Style.Pipes do end end + # `lhs |> Enum.map(fun) |> Enum.intersperse(sep)` => `lhs |> Enum.map_intersperse(sep, fun) + defp fix_pipe( + pipe_chain( + pm, + lhs, + {{:., dm, [{_, _, [:Enum]} = enum, :map]}, em, [fun]}, + {{:., _, [{_, _, [:Enum]}, :intersperse]}, _, [sep]} + ) + ), + do: {:|>, pm, [lhs, {{:., dm, [enum, :map_intersperse]}, em, [Style.set_line(sep, em[:line]), fun]}]} + # `lhs |> Enum.reverse() |> Enum.concat(enum)` => `lhs |> Enum.reverse(enum)` defp fix_pipe( pipe_chain( @@ -319,9 +330,8 @@ defmodule Styler.Style.Pipes do {{:., _, [{_, _, [:Enum]}, :reverse]} = reverse, meta, []}, {{:., _, [{_, _, [:Enum]}, :concat]}, _, [enum]} ) - ) do - {:|>, pm, [lhs, {reverse, [line: meta[:line]], [enum]}]} - end + ), + do: {:|>, pm, [lhs, {reverse, meta, [enum]}]} # `lhs |> Enum.filter(fun) |> List.first([default])` => `lhs |> Enum.find([default], fun)` defp fix_pipe( @@ -331,10 +341,8 @@ defmodule Styler.Style.Pipes do {{:., dm, [{_, _, [:Enum]} = enum, :filter]}, meta, [fun]}, {{:., _, [{_, _, [:List]}, :first]}, _, default} ) - ) do - line = meta[:line] - {:|>, pm, [lhs, {{:., dm, [enum, :find]}, [line: line], Style.set_line(default, line) ++ [fun]}]} - end + ), + do: {:|>, pm, [lhs, {{:., dm, [enum, :find]}, meta, Style.set_line(default, meta[:line]) ++ [fun]}]} # `lhs |> Enum.reverse() |> Kernel.++(enum)` => `lhs |> Enum.reverse(enum)` defp fix_pipe( @@ -344,9 +352,8 @@ defmodule Styler.Style.Pipes do {{:., _, [{_, _, [:Enum]}, :reverse]} = reverse, meta, []}, {{:., _, [{_, _, [:Kernel]}, :++]}, _, [enum]} ) - ) do - {:|>, pm, [lhs, {reverse, [line: meta[:line]], [enum]}]} - end + ), + do: {:|>, pm, [lhs, {reverse, meta, [enum]}]} # `lhs |> Enum.filter(filterer) |> Enum.count()` => `lhs |> Enum.count(count)` defp fix_pipe( @@ -357,9 +364,8 @@ defmodule Styler.Style.Pipes do {{:., _, [{_, _, [:Enum]}, :count]} = count, _, []} ) ) - when mod in @enum do - {:|>, pm, [lhs, {count, [line: meta[:line]], [filterer]}]} - end + when mod in @enum, + do: {:|>, pm, [lhs, {count, meta, [filterer]}]} # `lhs |> Stream.map(fun) |> Stream.run()` => `lhs |> Enum.each(fun)` # `lhs |> Stream.each(fun) |> Stream.run()` => `lhs |> Enum.each(fun)` @@ -371,9 +377,8 @@ defmodule Styler.Style.Pipes do {{:., _, [{_, _, [:Stream]}, :run]}, _, []} ) ) - when map_or_each in [:map, :each] do - {:|>, pm, [lhs, {{:., dm, [{a, am, [:Enum]}, :each]}, fm, fa}]} - end + when map_or_each in [:map, :each], + do: {:|>, pm, [lhs, {{:., dm, [{a, am, [:Enum]}, :each]}, fm, fa}]} # `lhs |> Enum.map(mapper) |> Enum.join(joiner)` => `lhs |> Enum.map_join(joiner, mapper)` defp fix_pipe( @@ -384,10 +389,8 @@ defmodule Styler.Style.Pipes do {{:., _, [{_, _, [:Enum]} = enum, :join]}, _, join_args} ) ) - when mod in @enum do - rhs = {{:., dm, [enum, :map_join]}, em, Style.set_line(join_args, dm[:line]) ++ map_args} - {:|>, pm, [lhs, rhs]} - end + when mod in @enum, + do: {:|>, pm, [lhs, {{:., dm, [enum, :map_join]}, em, Style.set_line(join_args, dm[:line]) ++ map_args}]} # `lhs |> Enum.map(mapper) |> Enum.into(empty_map)` => `lhs |> Map.new(mapper)` # or @@ -426,9 +429,8 @@ defmodule Styler.Style.Pipes do {{:., _, [{_, _, [mod]}, :new]} = new, _, []} ) ) - when mod in @collectable and enum in @enum do - {:|>, pm, [lhs, {Style.set_line(new, em[:line]), em, [mapper]}]} - end + when mod in @collectable and enum in @enum, + do: {:|>, pm, [lhs, {Style.set_line(new, em[:line]), em, [mapper]}]} @req2 for fun <- ~w(delete get head patch post put request run), bang <- ["", "!"], do: :"#{fun}#{bang}" @@ -441,18 +443,16 @@ defmodule Styler.Style.Pipes do {{:., _, [{_, _, [:Req]}, fun]} = req, _, []} ) ) - when req_or_kw in [:Req, :Keyword] and fun in @req2 do - fix_pipe({:|>, pm, [lhs, {req, m, [kw]}]}) - end + when req_or_kw in [:Req, :Keyword] and fun in @req2, + do: fix_pipe({:|>, pm, [lhs, {req, m, [kw]}]}) # Req.new |> Req.fun1,2 -> Req.fun1,2 # all `fun` options take the same args as `Req.new`, so it's redundant to call Req.new before them defp fix_pipe( pipe_chain(pm, lhs, {{:., _, [{_, _, [:Req]}, :new]}, m, []}, {{:., _, [{_, _, [:Req]}, fun]} = req, _, args}) ) - when fun in @req2 do - {:|>, pm, [lhs, {req, m, args}]} - end + when fun in @req2, + do: {:|>, pm, [lhs, {req, m, args}]} defp fix_pipe(node), do: node diff --git a/lib/styler.ex b/lib/styler.ex index cff2b17c..1d0d00a9 100644 --- a/lib/styler.ex +++ b/lib/styler.ex @@ -75,7 +75,7 @@ defmodule Styler do ast_to_string(ast, comments, formatter_opts) end - @doc "Just `Code.string_to_quoted_with_comments/2` with the necessary options" + @doc "Just `Code.string_to_quoted_with_comments/2` with the necessary options" def string_to_ast(code, file \\ "nofile") when is_binary(code) do Code.string_to_quoted_with_comments!(code, literal_encoder: &__MODULE__.literal_encoder/2, diff --git a/test/style/pipes_test.exs b/test/style/pipes_test.exs index 6de413a4..570f5b91 100644 --- a/test/style/pipes_test.exs +++ b/test/style/pipes_test.exs @@ -11,7 +11,7 @@ defmodule Styler.Style.PipesTest do use Styler.StyleCase, async: true - describe "big picture" do + describe "big picture / readability" do test "unnests multiple steps" do assert_style("f(g(h(x))) |> j()", "x |> h() |> g() |> f() |> j()") end @@ -78,6 +78,16 @@ defmodule Styler.Style.PipesTest do """ ) end + + test "rewrites anonymous function invocations to use then" do + assert_style("a |> (& &1).()", "then(a, & &1)") + assert_style("a |> (& {&1, &2}).(b)", "(&{&1, &2}).(a, b)") + assert_style("a |> (& &1).() |> c", "a |> then(& &1) |> c()") + + assert_style("a |> (fn x, y -> {x, y} end).() |> c", "a |> then(fn x, y -> {x, y} end) |> c()") + assert_style("a |> (fn x -> x end).()", "then(a, fn x -> x end)") + assert_style("a |> (fn x -> x end).() |> c", "a |> then(fn x -> x end) |> c()") + end end describe "block pipe starts" do @@ -501,17 +511,7 @@ defmodule Styler.Style.PipesTest do end end - describe "optimizations & readability improvements" do - test "rewrites anonymous function invocations to use then" do - assert_style("a |> (& &1).()", "then(a, & &1)") - assert_style("a |> (& {&1, &2}).(b)", "(&{&1, &2}).(a, b)") - assert_style("a |> (& &1).() |> c", "a |> then(& &1) |> c()") - - assert_style("a |> (fn x, y -> {x, y} end).() |> c", "a |> then(fn x, y -> {x, y} end) |> c()") - assert_style("a |> (fn x -> x end).()", "then(a, fn x -> x end)") - assert_style("a |> (fn x -> x end).() |> c", "a |> then(fn x -> x end) |> c()") - end - + describe "readability" do test "rewrites then/2 when the passed function is a named function reference" do assert_style "a |> then(&fun/1) |> c", "a |> fun() |> c()" assert_style "a |> then(&(&1 / 1)) |> c", "a |> Kernel./(1) |> c()" @@ -542,6 +542,26 @@ defmodule Styler.Style.PipesTest do test "adds parens to 1-arity pipes" do assert_style("a |> b |> c", "a |> b() |> c()") end + end + + describe "optimizations for stdlib functions" do + test "map/intersperse => map_intersperse" do + assert_style "a |> Enum.map(fun) |> Enum.intersperse(sep)", "Enum.map_intersperse(a, sep, fun)" + + assert_style( + """ + a + |> Enum.map(fun) + |> Enum.intersperse(sep) + |> foo() + """, + """ + a + |> Enum.map_intersperse(sep, fun) + |> foo() + """ + ) + end test "filter/first => find" do assert_style "a |> Enum.filter(fun) |> List.first()", "Enum.find(a, fun)" From 70b50451a8ded14ed8364ba26a5958f05220446e Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Tue, 2 Dec 2025 10:35:26 -0700 Subject: [PATCH 134/145] v1.10.0 --- README.md | 2 +- mix.exs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5f7f4958..bf2e3825 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ Add `:styler` as a dependency to your project's `mix.exs`: ```elixir def deps do [ - {:styler, "~> 1.9", only: [:dev, :test], runtime: false}, + {:styler, "~> 1.10", only: [:dev, :test], runtime: false}, ] end ``` diff --git a/mix.exs b/mix.exs index 6986a9b0..94ec098c 100644 --- a/mix.exs +++ b/mix.exs @@ -12,7 +12,7 @@ defmodule Styler.MixProject do use Mix.Project # Don't forget to bump the README when doing non-patch version changes - @version "1.9.1" + @version "1.10.0" @url "https://github.com/adobe/elixir-styler" def project do From ae19d31231dcbd58a6aee5303a77b792ead639ec Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Tue, 2 Dec 2025 14:22:22 -0700 Subject: [PATCH 135/145] bump version in changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fdad8961..08f91973 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ they can and will change without that change being reflected in Styler's semanti ## main +## 1.10.0 + ### Improvements Two new standard-library pipe optimizations From 77861bfb1259efd0477ce3e0f36d253fc41ab093 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Wed, 3 Dec 2025 08:33:43 -0700 Subject: [PATCH 136/145] pipes docs reorganization --- docs/pipes.md | 48 +++++++++++++++++++++++++----------------------- docs/styles.md | 2 +- 2 files changed, 26 insertions(+), 24 deletions(-) diff --git a/docs/pipes.md b/docs/pipes.md index a19b3bd6..0174feba 100644 --- a/docs/pipes.md +++ b/docs/pipes.md @@ -64,7 +64,7 @@ a |> then(&f(&1, ...)) |> b() a |> f(...) |> b() ``` -## Piped function optimizations +## Optimizations Two function calls into one! Fewer steps is always nice. @@ -120,7 +120,29 @@ a |> Enum.map(fun) |> Enum.intersperse(separator) |> ... a |> Enum.map_intersperse(separator, fun) |> ... ``` -## Unpiping Single Pipes +### Req Optimizations + +[Req](https://github.com/wojtekmach/req) is a popular HTTP Client. If you aren't using it, you can just ignore this whole section! + +Styler ensures a minimal number of functions are being called when using any Req 1-arity execution functions (`delete get head patch post put request run` and their bangified versions). + +```elixir +# before +keyword |> Req.new() |> Req.merge(opts) |> Req.post!() +# Styled: +Req.post!(keyword, opts) + +# before +foo |> Keyword.merge(opts) |> Req.head() +# Styled: +Req.head(foo, opts) +``` + +**This changes the program's behaviour**, since `Keyword.merge` would overwrite existing values in all cases, whereas `Req` 2-arity functions intelligently deep-merge values for some keys, like `:headers`. + +## Adding & Removing Pipes + +### Unpiping Single Pipes Styler rewrites pipechains with a single pipe to be function calls. Notably, this rule combined with the optimizations rewrites above means some chains with more than one pipe will also become function calls. @@ -134,7 +156,7 @@ map = a |> Enum.map(mapper) |> Map.new() map = Map.new(a, mapper) ``` -## Pipe-ify +### Pipe-ify If the first argument to a function call is a pipe, Styler makes the function call the final pipe of the chain. @@ -161,23 +183,3 @@ d(c(a |> b)) # At which point Styler will pipe-ify the entire chain a |> b() |> c() |> d() ``` - -## Req - -[Req](https://github.com/wojtekmach/req) is a popular HTTP Client. If you aren't using it, you can just ignore this whole section! - -Styler ensures a minimal number of functions are being called when using any Req 1-arity execution functions (`delete get head patch post put request run` and their bangified versions). - -```elixir -# before -keyword |> Req.new() |> Req.merge(opts) |> Req.post!() -# Styled: -Req.post!(keyword, opts) - -# before -foo |> Keyword.merge(opts) |> Req.head() -# Styled: -Req.head(foo, opts) -``` - -**This changes the program's behaviour**, since `Keyword.merge` would overwrite existing values in all cases, whereas `Req` 2-arity functions intelligently deep-merge values for some keys, like `:headers`. diff --git a/docs/styles.md b/docs/styles.md index e4710476..6f814ed5 100644 --- a/docs/styles.md +++ b/docs/styles.md @@ -8,6 +8,6 @@ Styler performs myriad rewrites, logically broken apart into the following group - [General Styles](./general_styles.md): general simple 1-1 rewrites that require a minimum amount of awareness of the AST - [Mix Configs](./mix_configs.md): Styler applies order to chaos by organizing mix `config ...` stanzas - [Module Directives](./module_directives.md): Styles for `alias`, `use`, `import`, `require`, as well as alias lifting and alias application. -- [Pipes](./pipes.md) styles for the famous Elixir pipe `|>`, including optimizations for piping standard library functions +- [Pipes](./pipes.md): Styles for the famous Elixir pipe `|>`, including optimizations for piping standard library functions Finally, if you're using Credo [see our documentation](./credo.md) about rules that can be disabled in Credo because Styler automatically enforces them for you, saving a modicum of CI time. From 3e29caea7fe12d9cbee5201ab983fa47b9581a03 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Mon, 12 Jan 2026 13:27:54 -0700 Subject: [PATCH 137/145] Add experimental mix styler.remove_unused task --- lib/mix/tasks/remove_unused.ex | 79 ++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 lib/mix/tasks/remove_unused.ex diff --git a/lib/mix/tasks/remove_unused.ex b/lib/mix/tasks/remove_unused.ex new file mode 100644 index 00000000..d4cefd86 --- /dev/null +++ b/lib/mix/tasks/remove_unused.ex @@ -0,0 +1,79 @@ +# Copyright 2025 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.Styler.RemoveUnused do + @shortdoc "EXPERIMENTAL: uses unused import/alias/require compiler warnings to remove those lines" + @moduledoc """ + WARNING: EXPERIMENTAL + + Removes unused import/alias/require statements by compiling the app and parsing warnings, then deleting the specified lines. + + Usage: + + mix styler.remove_unused + """ + use Mix.Task + + alias Mix.Shell.IO + + @impl Mix.Task + def run(_) do + # Warnings come over stderr, so gotta redirect + {output, _} = System.cmd("mix", ~w(compile --all-warnings), stderr_to_stdout: true) + + if output =~ "warning: unused" do + IO.info("Removing unused import/alias/require lines...\n") + + output + |> String.split("\n\n") + |> Stream.map(&Regex.run(~r/warning\: unused (alias|require|import).* (.*\.exs?):(\d+)\:/s, &1)) + |> Stream.filter(& &1) + |> Stream.map(fn [_full_message, _require_or_alias, file, line] -> + file = + if File.exists?(file) do + file + else + [umbrella_corrected] = Path.wildcard("apps/*/#{file}") + umbrella_corrected + end + + {file, String.to_integer(line)} + end) + |> Enum.group_by(fn {file, _} -> file end, fn {_, line} -> line end) + |> Enum.sort_by(fn {file, _} -> file end) + |> Enum.each(fn {file, lines} -> + contents = file |> File.read!() |> String.split("\n") + + IO.info("==> #{file}") + + lines + |> Enum.sort() + |> Enum.each(&IO.info("#{&1}: #{Enum.at(contents, &1 - 1)}")) + + contents = + lines + |> Enum.sort(:desc) + |> Enum.reduce(contents, &List.delete_at(&2, &1 - 1)) + |> Enum.join("\n") + + File.write!(file, contents) + IO.info("") + end) + + IO.info("Running `mix format` to remove any excess newlines.") + + Mix.Task.run("format") + + IO.info("Done.") + else + IO.info("No \"unused\" warnings detected, no work to do.") + end + end +end From 6055b072661596144d83a0315f6bc21a90797431 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Mon, 12 Jan 2026 14:08:19 -0700 Subject: [PATCH 138/145] Add `mix styler.inline_attrs ` refactor tool --- CHANGELOG.md | 44 ++++++++++++++++++ lib/mix/tasks/inline_attributes.ex | 54 +++++++++++++++++++++++ lib/style/inline_attrs.ex | 71 ++++++++++++++++++++++++++++++ 3 files changed, 169 insertions(+) create mode 100644 lib/mix/tasks/inline_attributes.ex create mode 100644 lib/style/inline_attrs.ex diff --git a/CHANGELOG.md b/CHANGELOG.md index 08f91973..74a4f7aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,50 @@ they can and will change without that change being reflected in Styler's semanti ## main +## 1.10.1 + +### Improvements + +Adds two experimental refactoring features as mix tasks. + +#### `mix styler.remove_unused` + +With Elixir 1.20 on the horizon, many projects are about to discover that they have _a lot_ of unnecessary `require Logger` lines throughout their codebase. + +`mix styler.remove_unused` will automate the removal of those `unused require:` statements, alongside any `unused import:` and `unused alias:` warnings. + +This has long been an internal script useful for running after a bigger refactor that resulted in many superfluous aliases, but with 1.20 coming it seems it might be useful for others as well. + +This will never be an integrated part of `Styler`'s format plugin features, as it would _not_ be correct to remove unused nodes whenever running format. It's typical to have unused warnings while in the midst of an implementation, and deleting that code would be obnoxious. + +#### `mix styler.inline_attrs ` + +Inlines one-off module attributes that define literal values. + +This is something that sometimes is good, and sometimes is bad. In general, defining a module attribute when you could've just written an atom is bad, so inlining is good! + +It would probably be most useful as a refactor ability for a language server, but CLIs are a nice second place. + +An example of a situation where it results in an improvement: + +```elixir +# Unnecessary indirection with single-use literal-value module attributes +defmodule A do + @http_client_key :http_key + @default_client MyHTTPClient + + def http_client, do: Application.get_env(:my_app, @http_client_key, @default_client) +end +# Much better! styler.inline_attrs will perform this refactor +defmodule A do + def http_client, do: Application.get_env(:my_app, :http_key, MyHTTPClient) +end +``` + +It's worthwhile to run this on some suspicious files, then followup with manual intervention when it went too far. This style is not aware of quote boundaries, and so might do some broken things. (Hence "EXPERIMENTAL") + +You've been warned =) + ## 1.10.0 ### Improvements diff --git a/lib/mix/tasks/inline_attributes.ex b/lib/mix/tasks/inline_attributes.ex new file mode 100644 index 00000000..2560527c --- /dev/null +++ b/lib/mix/tasks/inline_attributes.ex @@ -0,0 +1,54 @@ +# Copyright 2025 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.Styler.InlineAttrs do + @shortdoc "EXPERIMENTAL: inlines module attributes with literal values that are only referenced once" + @moduledoc """ + WARNING: EXPERIMENTAL + + Inlines module attributes that are only referenced once in their module. + + **This is known to create invalid code.** It's far from perfect. + It can still be a helpful first step in refactoring though. + + Formats the file with a currently hard-coded length of 122. + + **Usage**: + + mix styler.inline_attrs path/to/my/file.ex + + ## Example: + + # This ... + defmodule A do + @non_literal_attr Application.compile_env(...) + @literal_value_one_time_use :my_key + + def foo(), do: Application.get_env(:my_app, @literal_value_one_time_use) + end + + # Becomes this + defmodule A do + @non_literal_attr Application.compile_env(...) + + def foo(), do: Application.get_env(:my_app, :my_key) + end + """ + use Mix.Task + + alias Styler.Zipper + + @impl Mix.Task + def run([file]) do + {ast, comments} = file |> File.read!() |> Styler.string_to_ast(file) + {{ast, _}, _} = ast |> Zipper.zip() |> Zipper.traverse_while(nil, &Styler.Style.InlineAttrs.run/2) + File.write!(file, Styler.ast_to_string(ast, comments)) + end +end diff --git a/lib/style/inline_attrs.ex b/lib/style/inline_attrs.ex new file mode 100644 index 00000000..dc21312f --- /dev/null +++ b/lib/style/inline_attrs.ex @@ -0,0 +1,71 @@ +# Copyright 2025 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.InlineAttrs do + @moduledoc false + alias Styler.Style + alias Styler.Zipper + + def run(zipper, ctx) do + if defstructz = Zipper.find(zipper, &match?({:defmodule, _, _}, &1)) do + body_zipper = defstructz |> Zipper.down() |> Zipper.right() |> Zipper.down() |> Zipper.down() |> Zipper.right() + + {literal_attrs, others} = + body_zipper + |> Zipper.children() + |> Enum.split_with(fn + {:@, _, [{_name, _, [{:to_timeout, _, values}]}]} -> quoted_literal?(values) + {:@, _, [{name, _, values}]} -> name not in ~w(doc impl moduledoc)a and quoted_literal?(values) + _ -> false + end) + + {_, hits} = + Macro.prewalk(others, %{}, fn + {:@, _, [{name, _, _}]} = ast, acc -> + def = Enum.find(literal_attrs, &match?({:@, _, [{^name, _, _}]}, &1)) + val = def && {def, ast} + {ast, Map.update(acc, name, val, fn _ -> false end)} + + ast, acc -> + {ast, acc} + end) + + zipper = + hits + |> Enum.filter(fn {_, v} -> v end) + |> Enum.reduce(body_zipper, fn {_, {def, ast}}, zipper -> + {_, _, [{_, _, [value]}]} = def + replacement = Style.set_line(value, Style.meta(ast)[:line]) + + zipper + |> Zipper.find(&match?(^def, &1)) + |> Zipper.remove() + |> Zipper.find(&match?(^ast, &1)) + |> Zipper.replace(replacement) + |> Zipper.top() + end) + + {:halt, zipper, ctx} + else + {:halt, zipper, ctx} + end + end + # Can't rely on `Macro.quoted_literal?` up front because we wrapped our literals :/ + # This function is not complete, but it's good enough for the needs here. + defp quoted_literal?(value) when is_list(value) or is_map(value) do + Enum.all?(value, fn + {k, v} -> quoted_literal?(k) and quoted_literal?(v) + value -> quoted_literal?(value) + end) + end + + defp quoted_literal?({:__block__, _, [value]}), do: quoted_literal?(value) + defp quoted_literal?(value), do: Macro.quoted_literal?(value) +end From 4e013e0520f76937bc81b63dbf6abb4c0e2d3da8 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Mon, 12 Jan 2026 14:13:04 -0700 Subject: [PATCH 139/145] v1.10.1 --- README.md | 6 ++++++ docs/module_directives.md | 4 ++++ lib/mix/tasks/inline_attributes.ex | 4 +--- lib/mix/tasks/remove_unused.ex | 4 +--- mix.exs | 2 +- 5 files changed, 13 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index bf2e3825..e3d80ee5 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,12 @@ The fastest way to see what all it can do you for you is to just try it out in y - rewrites deprecated Elixir standard library code, speeding adoption of new releases - auto-fixes many credo rules, meaning you can spend less time fighting with CI +### Refactoring Mix Tasks + +Styler also includes two experimental refactoring tasks: +- `mix styler.remove_unused`: deletes unused `import|alias|require` nodes that generate compiler warnings +- `mix styler.inline_attrs `: inlines module attributes that have a literal value and are only referenced once, removing unnecessary indirection + ## Who is Styler for? > I'm just excited to be on a team that uses Styler and moves on diff --git a/docs/module_directives.md b/docs/module_directives.md index 906e7b0c..e544483d 100644 --- a/docs/module_directives.md +++ b/docs/module_directives.md @@ -126,6 +126,10 @@ alias Foo.Baz.A alias Foo.Bop ``` +## Remove Unnecessary / Basic Aliases + +Styler removes root node or single aliases like `alias Foo`, as they accomplish nothing. + ## Alias Lifting When a module with three parts is referenced two or more times, styler creates a new alias for that module and uses it. diff --git a/lib/mix/tasks/inline_attributes.ex b/lib/mix/tasks/inline_attributes.ex index 2560527c..318950fb 100644 --- a/lib/mix/tasks/inline_attributes.ex +++ b/lib/mix/tasks/inline_attributes.ex @@ -11,9 +11,7 @@ defmodule Mix.Tasks.Styler.InlineAttrs do @shortdoc "EXPERIMENTAL: inlines module attributes with literal values that are only referenced once" @moduledoc """ - WARNING: EXPERIMENTAL - - Inlines module attributes that are only referenced once in their module. + WARNING: EXPERIMENTAL | Inlines module attributes that are only referenced once in their module. **This is known to create invalid code.** It's far from perfect. It can still be a helpful first step in refactoring though. diff --git a/lib/mix/tasks/remove_unused.ex b/lib/mix/tasks/remove_unused.ex index d4cefd86..9fbbf968 100644 --- a/lib/mix/tasks/remove_unused.ex +++ b/lib/mix/tasks/remove_unused.ex @@ -11,9 +11,7 @@ defmodule Mix.Tasks.Styler.RemoveUnused do @shortdoc "EXPERIMENTAL: uses unused import/alias/require compiler warnings to remove those lines" @moduledoc """ - WARNING: EXPERIMENTAL - - Removes unused import/alias/require statements by compiling the app and parsing warnings, then deleting the specified lines. + WARNING: EXPERIMENTAL | Removes unused import/alias/require statements by compiling the app and parsing warnings, then deleting the specified lines. Usage: diff --git a/mix.exs b/mix.exs index 94ec098c..a4c54281 100644 --- a/mix.exs +++ b/mix.exs @@ -12,7 +12,7 @@ defmodule Styler.MixProject do use Mix.Project # Don't forget to bump the README when doing non-patch version changes - @version "1.10.0" + @version "1.10.1" @url "https://github.com/adobe/elixir-styler" def project do From 02292c60ead80487118355e3abd5e084f8edc8f3 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Wed, 14 Jan 2026 18:10:23 -0700 Subject: [PATCH 140/145] `mix styler.remove_unused`: allow multiple file args --- CHANGELOG.md | 4 ++++ lib/mix/tasks/inline_attributes.ex | 16 ++++++++++------ lib/style/inline_attrs.ex | 1 + 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74a4f7aa..47b3fc91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ they can and will change without that change being reflected in Styler's semanti ## main +### Improvements + +- `mix styler.inline_attrs`: Allow multiple file paths to be specified: `mix styler.inline_attrs [... additional files]` + ## 1.10.1 ### Improvements diff --git a/lib/mix/tasks/inline_attributes.ex b/lib/mix/tasks/inline_attributes.ex index 318950fb..bbe09f8e 100644 --- a/lib/mix/tasks/inline_attributes.ex +++ b/lib/mix/tasks/inline_attributes.ex @@ -16,11 +16,13 @@ defmodule Mix.Tasks.Styler.InlineAttrs do **This is known to create invalid code.** It's far from perfect. It can still be a helpful first step in refactoring though. - Formats the file with a currently hard-coded length of 122. + Formats files with a currently hard-coded length of 122. **Usage**: - mix styler.inline_attrs path/to/my/file.ex + mix styler.inline_attrs [... additional file paths] + + mix styler.inline_attrs path/to/my/file.ex path/to/another_file.ex ## Example: @@ -44,9 +46,11 @@ defmodule Mix.Tasks.Styler.InlineAttrs do alias Styler.Zipper @impl Mix.Task - def run([file]) do - {ast, comments} = file |> File.read!() |> Styler.string_to_ast(file) - {{ast, _}, _} = ast |> Zipper.zip() |> Zipper.traverse_while(nil, &Styler.Style.InlineAttrs.run/2) - File.write!(file, Styler.ast_to_string(ast, comments)) + def run(files) do + for file <- files do + {ast, comments} = file |> File.read!() |> Styler.string_to_ast(file) + {{ast, _}, _} = ast |> Zipper.zip() |> Zipper.traverse_while(nil, &Styler.Style.InlineAttrs.run/2) + File.write!(file, Styler.ast_to_string(ast, comments)) + end end end diff --git a/lib/style/inline_attrs.ex b/lib/style/inline_attrs.ex index dc21312f..5373a6a0 100644 --- a/lib/style/inline_attrs.ex +++ b/lib/style/inline_attrs.ex @@ -57,6 +57,7 @@ defmodule Styler.Style.InlineAttrs do {:halt, zipper, ctx} end end + # Can't rely on `Macro.quoted_literal?` up front because we wrapped our literals :/ # This function is not complete, but it's good enough for the needs here. defp quoted_literal?(value) when is_list(value) or is_map(value) do From 5f6d2ba381c0c61e034a8208dfc3da5571618bd9 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Sat, 17 Jan 2026 20:20:44 -0700 Subject: [PATCH 141/145] readme task link fix --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e3d80ee5..41a4ffed 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ The fastest way to see what all it can do you for you is to just try it out in y Styler also includes two experimental refactoring tasks: - `mix styler.remove_unused`: deletes unused `import|alias|require` nodes that generate compiler warnings -- `mix styler.inline_attrs `: inlines module attributes that have a literal value and are only referenced once, removing unnecessary indirection +- `mix styler.inline_attrs`: inlines module attributes that have a literal value and are only referenced once, removing unnecessary indirection ## Who is Styler for? From a68788c3046279f1bb5f89b9fb11bc1a92aa2b7c Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Sun, 8 Feb 2026 11:40:33 -0800 Subject: [PATCH 142/145] fix inline_attributes task docs --- lib/mix/tasks/inline_attributes.ex | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/lib/mix/tasks/inline_attributes.ex b/lib/mix/tasks/inline_attributes.ex index bbe09f8e..9f925eca 100644 --- a/lib/mix/tasks/inline_attributes.ex +++ b/lib/mix/tasks/inline_attributes.ex @@ -26,20 +26,20 @@ defmodule Mix.Tasks.Styler.InlineAttrs do ## Example: - # This ... - defmodule A do - @non_literal_attr Application.compile_env(...) - @literal_value_one_time_use :my_key + # This ... + defmodule A do + @non_literal_attr Application.compile_env(...) + @literal_value_with_only_one_reference :my_key - def foo(), do: Application.get_env(:my_app, @literal_value_one_time_use) - end + def foo(), do: Application.get_env(:my_app, @literal_value_with_only_one_reference) + end - # Becomes this - defmodule A do - @non_literal_attr Application.compile_env(...) + # Becomes this + defmodule A do + @non_literal_attr Application.compile_env(...) - def foo(), do: Application.get_env(:my_app, :my_key) - end + def foo(), do: Application.get_env(:my_app, :my_key) + end """ use Mix.Task From f839aa6509e712752fc0e825c575021061e063f5 Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Wed, 25 Feb 2026 11:22:18 -0500 Subject: [PATCH 143/145] module attributes: dont break references from use, moduledoc, etc --- CHANGELOG.md | 14 +++++ lib/style/module_directives.ex | 85 ++++++++++----------------- test/style/module_directives_test.exs | 52 ++++++++-------- 3 files changed, 70 insertions(+), 81 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47b3fc91..0b5957ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,20 @@ they can and will change without that change being reflected in Styler's semanti - `mix styler.inline_attrs`: Allow multiple file paths to be specified: `mix styler.inline_attrs [... additional files]` +#### Module Directive References + +Module directives got smarter. Styler will no longer move module attributes below their references in `use` or `@moduledoc`s. + +In other words, Styler will leave the following code untouched: + +```elixir +defmodule MyGreatLibrary do + @library_options [...] + @moduledoc make_pretty_docs(@library_options) + use OptionsMagic, my_opts: @library_options +end +``` + ## 1.10.1 ### Improvements diff --git a/lib/style/module_directives.ex b/lib/style/module_directives.ex index d81d6c5d..fac6189a 100644 --- a/lib/style/module_directives.ex +++ b/lib/style/module_directives.ex @@ -60,8 +60,7 @@ defmodule Styler.Style.ModuleDirectives do require: [], nondirectives: [], alias_env: %{}, - attrs: MapSet.new(), - attr_lifts: [] + attrs: MapSet.new() } # module directives typically doesn't do anything until it sees a module (typical .ex file) or a directive (like a snippet) @@ -73,7 +72,7 @@ defmodule Styler.Style.ModuleDirectives do def run(zipper, %{__MODULE__ => true} = ctx), do: do_run(zipper, ctx) def run({node, nil} = zipper, ctx) do - if interesting_zipper = Zipper.find(zipper, &interesting?/1) do + if interesting_zipper = Zipper.find(zipper, &match?({x, _, _} when x in [:defmodule, :@ | @directives], &1)) do do_run(interesting_zipper, Map.put(ctx, __MODULE__, true)) else # there's no defmodules or aliasy things - see if we can do some alias lifting? @@ -103,9 +102,6 @@ defmodule Styler.Style.ModuleDirectives do end end - defp interesting?({x, _, _}) when x in [:defmodule, :@ | @directives], do: true - defp interesting?(_), do: false - defp do_run({{:defmodule, _, children}, _} = zipper, ctx) do [name, [{{:__block__, do_meta, [:do]}, _body}]] = children @@ -198,28 +194,38 @@ defmodule Styler.Style.ModuleDirectives do # a dynamic module name, like `defmodule my_variable do ... end` defp moduledoc(_), do: nil - defp lift_module_attrs({node, _, _} = ast, %{attrs: attrs} = acc) do + # pops module attributes this directive depends on out of `nondirectives`, + # returning their definitions in a list alongside the updated accumulator + # + # naively grouping module attributes in nondirectives can break a common pattern where an attribute is referenced by + # `use` or `@moduledoc` clauses, which then get moved above the attributes they reference. + # this function finds and returns those dependencies, allowing the caller to keep them above the dependent directive + defp split_depended_attributes({_, _, _} = directive, %{attrs: attrs, nondirectives: nondirectives} = acc) do if MapSet.size(attrs) == 0 do - {ast, acc} + {[], acc} else - use? = node == :use - - Macro.prewalk(ast, acc, fn - {:@, m, [{attr, _, _} = var]} = ast, acc -> + directive + |> Macro.prewalk({[], acc}, fn + {:@, _, [{attr, _, _}]} = ast, {prepends, acc} -> if attr in attrs do - replacement = - if use?, - do: {:unquote, [closing: [line: m[:line]], line: m[:line]], [var]}, - else: var + {definitions, nondirectives} = Enum.split_with(nondirectives, &match?({:@, _, [{^attr, _, _}]}, &1)) + acc = %{acc | nondirectives: nondirectives} - {replacement, %{acc | attr_lifts: [attr | acc.attr_lifts]}} + recursed = + Enum.reduce(definitions, {definitions ++ prepends, acc}, fn ast, {prepends, acc} -> + {definitions, acc} = split_depended_attributes(ast, acc) + {prepends ++ definitions, acc} + end) + + {ast, recursed} else - {ast, acc} + {ast, {prepends, acc}} end ast, acc -> {ast, acc} end) + |> elem(1) end end @@ -230,21 +236,22 @@ defmodule Styler.Style.ModuleDirectives do |> Enum.reduce(@env, fn {:@, _, [{attr_directive, _, _}]} = ast, acc when attr_directive in @attr_directives -> # attr_directives are moved above aliases, so we need to expand them - {ast, acc} = acc.alias_env |> AliasEnv.expand_ast(ast) |> lift_module_attrs(acc) - %{acc | attr_directive => [ast | acc[attr_directive]]} + ast = AliasEnv.expand_ast(acc.alias_env, ast) + {prepends, acc} = split_depended_attributes(ast, acc) + %{acc | attr_directive => [ast | prepends ++ acc[attr_directive]]} {:@, _, [{attr, _, _}]} = ast, acc -> %{acc | nondirectives: [ast | acc.nondirectives], attrs: MapSet.put(acc.attrs, attr)} {directive, _, _} = ast, acc when directive in @directives -> - {ast, acc} = lift_module_attrs(ast, acc) + {prepends, acc} = split_depended_attributes(ast, acc) ast = expand(ast) - # import and used get hoisted above aliases, so need to expand them + # import and use get hoisted above aliases, so need to expand them ast = if directive in ~w(import use)a, do: AliasEnv.expand_ast(acc.alias_env, ast), else: ast alias_env = if directive == :alias, do: AliasEnv.define(acc.alias_env, ast), else: acc.alias_env # the reverse accounts for `expand` putting things in reading order, whereas we're accumulating in reverse - %{acc | directive => Enum.reverse(ast, acc[directive]), alias_env: alias_env} + %{acc | directive => Enum.reverse(ast, prepends ++ acc[directive]), alias_env: alias_env} ast, acc -> %{acc | nondirectives: [ast | acc.nondirectives]} @@ -261,38 +268,6 @@ defmodule Styler.Style.ModuleDirectives do |> lift_aliases() |> apply_aliases() - # Not happy with it, but this does the work to move module attribute assignments above the module or quote or whatever - # Given that it'll only be run once and not again, i'm okay with it being inefficient - {acc, parent} = - if Enum.any?(acc.attr_lifts) do - lifts = acc.attr_lifts - - nondirectives = - Enum.map(acc.nondirectives, fn - {:@, m, [{attr, am, _}]} = ast -> if attr in lifts, do: {:@, m, [{attr, am, [{attr, am, nil}]}]}, else: ast - ast -> ast - end) - - assignments = - Enum.flat_map(acc.nondirectives, fn - {:@, m, [{attr, am, [val]}]} -> if attr in lifts, do: [{:=, m, [{attr, am, nil}, val]}], else: [] - _ -> [] - end) - - {past, _} = parent - - parent = - parent - |> Zipper.up() - |> Style.find_nearest_block() - |> Zipper.prepend_siblings(assignments) - |> Zipper.find(&(&1 == past)) - - {%{acc | nondirectives: nondirectives}, parent} - else - {acc, parent} - end - nondirectives = acc.nondirectives directives = diff --git a/test/style/module_directives_test.exs b/test/style/module_directives_test.exs index 2dff6d27..5fe9a262 100644 --- a/test/style/module_directives_test.exs +++ b/test/style/module_directives_test.exs @@ -592,48 +592,48 @@ defmodule Styler.Style.ModuleDirectivesTest do end describe "module attribute lifting" do - test "replaces uses in other attributes and `use` correctly" do + test "maintains location when used in other spots" do + assert_style(""" + defmodule MyGreatLibrary do + @library_options [...] + @moduledoc make_pretty_docs(@library_options) + use OptionsMagic, my_opts: @library_options + end + """) + end + + test "interdepedent module attrs" do assert_style( """ defmodule MyGreatLibrary do - @library_options [...] + @foo :bar + import Meow + @library_options @foo @moduledoc make_pretty_docs(@library_options) use OptionsMagic, my_opts: @library_options end """, """ - library_options = [...] - defmodule MyGreatLibrary do - @moduledoc make_pretty_docs(library_options) - use OptionsMagic, my_opts: unquote(library_options) + @foo :bar + @library_options @foo + @moduledoc make_pretty_docs(@library_options) + use OptionsMagic, my_opts: @library_options - @library_options library_options + import Meow end """ ) end test "works with `quote`" do - assert_style( - """ - quote do - @library_options [...] - @moduledoc make_pretty_docs(@library_options) - use OptionsMagic, my_opts: @library_options - end - """, - """ - library_options = [...] - - quote do - @moduledoc make_pretty_docs(library_options) - use OptionsMagic, my_opts: unquote(library_options) - - @library_options library_options - end - """ - ) + assert_style(""" + quote do + @library_options [...] + @moduledoc make_pretty_docs(@library_options) + use OptionsMagic, my_opts: @library_options + end + """) end end From bb07d1601e7b2f049ffb29f3b23426d2cff4ebaf Mon Sep 17 00:00:00 2001 From: Matt Enlow Date: Thu, 26 Feb 2026 09:04:04 -0500 Subject: [PATCH 144/145] 1.11.0 --- CHANGELOG.md | 4 +++- README.md | 2 +- mix.exs | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b5957ac..4296aa50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,11 @@ they can and will change without that change being reflected in Styler's semanti ## main +## 1.11.0 + ### Improvements -- `mix styler.inline_attrs`: Allow multiple file paths to be specified: `mix styler.inline_attrs [... additional files]` +- `mix styler.inline_attrs`: Allow multiple file paths to be specified: `mix styler.inline_attrs [ ...]` #### Module Directive References diff --git a/README.md b/README.md index 41a4ffed..a3a9237d 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ Add `:styler` as a dependency to your project's `mix.exs`: ```elixir def deps do [ - {:styler, "~> 1.10", only: [:dev, :test], runtime: false}, + {:styler, "~> 1.11", only: [:dev, :test], runtime: false}, ] end ``` diff --git a/mix.exs b/mix.exs index a4c54281..24c964a3 100644 --- a/mix.exs +++ b/mix.exs @@ -12,7 +12,7 @@ defmodule Styler.MixProject do use Mix.Project # Don't forget to bump the README when doing non-patch version changes - @version "1.10.1" + @version "1.11.0" @url "https://github.com/adobe/elixir-styler" def project do From 0d190bfd80e5e610c77cc8265dd1118720b47bde Mon Sep 17 00:00:00 2001 From: Jesse Herrick Date: Tue, 5 May 2026 23:18:37 -0400 Subject: [PATCH 145/145] A few fixes for v1.11.0 --- lib/style/module_directives.ex | 7 +++ lib/style/single_node.ex | 3 ++ test/style/module_directives_test.exs | 64 +++++++++++++++++++++++++++ test/style/single_node_test.exs | 4 +- 4 files changed, 76 insertions(+), 2 deletions(-) diff --git a/lib/style/module_directives.ex b/lib/style/module_directives.ex index 03611f31..fbbcf67b 100644 --- a/lib/style/module_directives.ex +++ b/lib/style/module_directives.ex @@ -456,6 +456,13 @@ defmodule Styler.Style.ModuleDirectives do zipper = if to_as[modules], do: Zipper.remove(zipper), else: zipper {:cont, zipper} + # `alias Foo.Bar.Baz, as: Whatever` - never rewrite the LHS through application, + # otherwise we'd produce nonsense like `alias Whatever, as: Whatever`. + # only dedup when the inner alias is an exact duplicate of one defined further up. + {{:alias, _, [{:__aliases__, _, [_ | _] = modules}, [{_, {:__aliases__, _, [as]}}]]}, _} = zipper -> + zipper = if to_as[modules] == as, do: Zipper.remove(zipper), else: zipper + {:skip, zipper} + # We check even modules of 1 length to catch silly situations like # alias A.B.C # alias A.B.C, as: X diff --git a/lib/style/single_node.ex b/lib/style/single_node.ex index e071ae01..43ce25c5 100644 --- a/lib/style/single_node.ex +++ b/lib/style/single_node.ex @@ -49,6 +49,9 @@ defmodule Styler.Style.SingleNode do defp style({:refute, meta, [{:>, m, xy}]}), do: style({:assert, meta, [{:<=, m, xy}]}) defp style({:refute, meta, [{:>=, m, xy}]}), do: style({:assert, meta, [{:<, m, xy}]}) + # `assert x not in y` reads more naturally than `refute x in y`, so leave it alone (and same for refute). + defp style({a, _, [{:not, _, [{:in, _, _}]}]} = node) when a in [:assert, :refute], do: node + for {a, inverted} <- [{:assert, :refute}, {:refute, :assert}] do # invert negations defp style({unquote(a), meta, [{n, _, [x]}]}) when n in [:!, :not], do: style({unquote(inverted), meta, [x]}) diff --git a/test/style/module_directives_test.exs b/test/style/module_directives_test.exs index 29438bf9..7255abb0 100644 --- a/test/style/module_directives_test.exs +++ b/test/style/module_directives_test.exs @@ -580,6 +580,70 @@ defmodule Styler.Style.ModuleDirectivesTest do ) end + test "doesn't rewrite the LHS of `alias X, as: Y` directives" do + # exact-match dedup: inner `alias` is the same module + same `as` as outer, so it's removed + assert_style( + """ + defmodule Outer do + @moduledoc false + alias Foo.Bar.Baz, as: BazSchema + + defmodule Inner do + @moduledoc false + alias Foo.Bar.Baz, as: BazSchema + + def x, do: BazSchema + end + end + """, + """ + defmodule Outer do + @moduledoc false + + alias Foo.Bar.Baz, as: BazSchema + + defmodule Inner do + @moduledoc false + + def x, do: BazSchema + end + end + """ + ) + + # different `as`: inner alias stays put and its LHS is left alone + assert_style( + """ + defmodule Outer do + @moduledoc false + alias Foo.Bar.Baz, as: BazSchema + + defmodule Inner do + @moduledoc false + alias Foo.Bar.Baz, as: SomethingElse + + def x, do: SomethingElse + end + end + """, + """ + defmodule Outer do + @moduledoc false + + alias Foo.Bar.Baz, as: BazSchema + + defmodule Inner do + @moduledoc false + + alias Foo.Bar.Baz, as: SomethingElse + + def x, do: SomethingElse + end + end + """ + ) + end + test "forces a single alias" do assert_style( """ diff --git a/test/style/single_node_test.exs b/test/style/single_node_test.exs index 5c161cdb..8717fd16 100644 --- a/test/style/single_node_test.exs +++ b/test/style/single_node_test.exs @@ -18,7 +18,7 @@ defmodule Styler.Style.SingleNodeTest do assert_style "assert !x", "refute x" assert_style "assert not x", "refute x" assert_style "assert !is_nil(x)", "assert x" - assert_style "assert x not in y", "refute x in y" + assert_style "assert x not in y" assert_style "assert nil == nil", "assert nil == nil" assert_style "assert nil != nil", "assert nil" @@ -28,7 +28,7 @@ defmodule Styler.Style.SingleNodeTest do assert_style "refute x != nil", "assert x == nil" assert_style "refute !x", "assert x" assert_style "refute not x", "assert x" - assert_style "refute x not in y", "assert x in y" + assert_style "refute x not in y" assert_style "assert x == nil" assert_style "assert is_nil(x)"